Introduction
What is Qadra?
Qadra is a multitenant workload orchestration platform designed for enterprise AI workflows. Unlike traditional chat applications, Qadra treats AI agents as specialized team members that perform discrete tasks within structured pipelines.
The Problem with Traditional AI Applications
Most LLM applications follow this pattern:
- User asks a question
- System retrieves "relevant" documents via RAG
- Everything gets stuffed into a prompt
- Model generates a response
- Repeat
This approach has fundamental problems:
Context Window Abuse: Models have limited context windows. Traditional RAG dumps retrieved documents into the prompt, hoping the model figures out what's relevant. As complexity grows, you hit limits—and even before that, quality degrades because signal gets lost in noise.
Hallucination at Scale: When you ask a model to do everything (retrieve, reason, synthesize, format) in one shot, it hallucinates. The model confidently produces plausible-sounding nonsense because it has no structured way to verify claims against facts.
No Accumulation: Each interaction starts fresh. A research analyst who spent an hour gathering data doesn't pass that structured knowledge to the due diligence specialist—instead, you re-retrieve, re-prompt, and hope for consistency.
Prompt Spaghetti: As applications grow, prompts become unmaintainable. You end up with 10,000-token system prompts trying to handle every edge case, version-controlled in git with no testing strategy.
Core Thesis
Agents are programs that sometimes call models, not prompts that do everything.
This single idea changes everything:
Programs have structure. An agent has an identity (display name, avatar), a defined purpose (description), and behavioral instructions (system prompt). This is inspectable, testable, versionable.
Programs compose. Complex workflows aren't giant prompts—they're pipelines of specialized agents, or visual flow graphs that orchestrate multiple agents with branching, conditions, and human-in-the-loop gates. The research analyst hands off to the due diligence specialist, who hands off to the memo writer. Each does one job well.
Programs are bounded. An agent doesn't see "everything"—it sees exactly what the orchestration layer provides. Context flows forward through pipeline stages, accumulated and structured. This is demand-driven context, not supply-driven context dump.
Qadra's Approach
-
Persona-driven Agents: Each agent is a named specialist with a system prompt that defines its behavior, a description of its capabilities, and an identity (display name, avatar). Agents call LLMs with scoped context—they are team members, not catch-all assistants.
-
Flow-based Orchestration: Work is coordinated through visual flow graphs (built with a drag-and-drop flow builder) or sequential pipelines. Flow graphs support branching, conditions, loops, HTTP calls, human-in-the-loop gates, knowledge lookups, and sub-flow execution—all without writing code.
-
Demand-driven Context: Context flows forward through pipeline stages. Each stage receives the accumulated output of prior stages. No stage sees everything—only what was produced upstream. This prevents context window abuse and keeps signal-to-noise high.
-
Fact Grounding: Responses are verified against a knowledge graph (SPO triples). Claims that can't be grounded are flagged. This catches hallucinations before they reach users.
-
Knowledge Core Ingestion: Documents are analyzed, chunked, embedded, and routed into the appropriate store—structured facts become SPO triples, narrative content becomes vector chunks. The ingestion pipeline understands the difference and routes accordingly.
-
Conversations with Epistemic Grounding: Users can interact with agents directly through conversations. When an agent responds, the system automatically injects relevant knowledge graph context (facts, contradiction detections, knowledge gaps) so the agent's response is grounded in what the organization actually knows.
First Use Case: Investment Banking
Qadra's initial deployment focuses on investment banking workflows:
Deal → Research → Due Diligence → Investment Memo → Review
Why investment banking? These workflows are:
- High-stakes: A bad investment memo can lose millions. Accuracy matters.
- Structured: Deals follow predictable stages with clear handoffs.
- Knowledge-intensive: Analysts synthesize data from dozens of sources.
- Compliance-heavy: Every decision needs an audit trail.
This is the opposite of casual chat—it's exactly where "prompts that do everything" fail hardest.
Each stage has specialized agents that produce artifacts:
| Agent | Stage | What They Do | Why Specialized |
|---|---|---|---|
| Research Analyst | Research | Gathers market data, competitive analysis | Needs access to market databases, knows how to extract relevant metrics |
| Due Diligence Specialist | Due Diligence | Validates claims, assesses risks | Cross-references facts against knowledge graph, flags ungrounded claims |
| Memo Writer | Investment Memo | Synthesizes findings into recommendation | Formats for IC consumption, knows what decision-makers need |
| Reviewer | Review | Final quality check | Fresh eyes, catches inconsistencies between sections |
Each agent is an expert at one thing. The research analyst doesn't worry about IC formatting—that's the memo writer's job. This separation of concerns is why agents are specialized personas with focused system prompts, not monolithic catch-all assistants.
This is NOT a Chat App
Understanding what Qadra is NOT is as important as understanding what it is:
| Qadra IS | Qadra is NOT | Why It Matters |
|---|---|---|
| Workload orchestration | A chatbot framework | Work has structure, stages, and completion criteria |
| Agents as team members | AI assistants | Agents have roles, expertise, and responsibilities |
| Pipelines with stages | Conversation threads | Progress is measurable, handoffs are explicit |
| Structured artifacts | Free-form responses | Outputs are typed, versioned, and auditable |
| Multi-model coordination | Single-model prompting | Right model for each task, selected by orchestration |
| Fact-grounded outputs | Hopeful generation | Claims are verified against knowledge graph |
Why This Distinction Matters
Chat apps optimize for engagement. They want users talking to the AI as much as possible. Success is measured in messages sent, sessions started, time spent.
Qadra optimizes for outcomes. A workload has a goal (produce an investment memo). Success is measured in workloads completed, accuracy of outputs, time-to-decision.
This changes everything about the architecture:
- No conversation history bloat: Workloads have structured context, not message logs
- No "let me think about that": Agents either have the capability or they don't
- No hallucination tolerance: Ungrounded claims are flagged, not glossed over
- Conversations are supplementary: Users can chat with agents directly, but the core value is in orchestrated workflows that produce auditable artifacts
Key Differentiators
Persona-based Agents
Agents in Qadra are not prompt templates. They are named specialists—team members with defined identities, behavioral instructions, and scoped responsibilities.
What makes a Qadra agent different from a prompt?
| Prompt Template | Qadra Agent |
|---|---|
| "You are a helpful research analyst..." | Named persona with system prompt, display name, avatar, description |
| Hopes the model understands context | Receives scoped context from pipeline stages or flow state |
| Output is whatever the model says | Output is structured, captured as artifacts, forwarded to downstream stages |
| Testing = vibes | Testing = predictable orchestration with observable state at each node |
| Versioning = copy-paste | Versioning = semantic versions in database |
An agent has four components:
-
Identity: Display name, avatar, and description. This is the agent's face—how it appears in the UI, how other parts of the system discover and reference it. Example: "Research Analyst" with a description of "Gathers market data and competitive analysis."
-
System Prompt: Behavioral instructions that define how the agent thinks, responds, and operates. This is not a one-shot prompt—it's persistent guidance that shapes every interaction the agent has within a workload or conversation.
-
Orchestration Controls: How the agent participates in larger workflows.
control_typedetermines what happens after the agent completes (retain control, hand off to parent, or return to pipeline start).output_visibilitycontrols whether output is user-facing or kept internal.max_calls_per_parent_agentprevents runaway loops. -
Versioning: Agents support version numbers. New versions can be deployed alongside old ones, with
is_activecontrolling which version is live. This enables safe iteration without breaking running workloads.
Visual Flow Builder
Qadra includes a visual flow builder for designing agent workflows as directed graphs. Flows are stored as ReactFlow JSONB and executed by an event-driven graph executor.
Flow nodes provide the building blocks:
| Node Type | Purpose |
|---|---|
| Start / End | Entry and exit points of the graph |
| Agent | Invoke an agent with context from the flow state |
| Condition | Branch based on expressions evaluated against flow state |
| HumanInput | Pause execution and wait for user input before continuing |
| Loop | Iterate over a collection or repeat until a condition is met |
| Http | Make external HTTP calls |
| ExecuteFlow | Run a sub-flow (composition) |
| Transform | Manipulate flow state data |
| Delay | Wait a specified duration |
| KnowledgeLookup | Query the knowledge graph or vector store |
| Research | Autonomous external research |
| Extract | LLM-driven structured parameter extraction from unstructured text |
| Artifact | Produce a structured artifact from a template |
Flows support human-in-the-loop patterns: when a HumanInput node is reached, execution pauses and waits for user response via the API. This enables approval gates, review steps, and interactive decision points within automated workflows.
Knowledge Graph Integration
Traditional RAG has a fundamental problem: it treats all knowledge as bags of text chunks. "Apple was founded by Steve Jobs in 1976" becomes a chunk that might or might not be retrieved when you ask about Apple's founding.
Qadra uses a dual-storage approach that separates facts from narrative:
SPO Index (Subject-Predicate-Object): Structured facts stored as triples.
(Apple, founded_by, Steve Jobs)
(Apple, founding_year, 1976)
(Steve Jobs, co-founded, Apple)
Facts are queryable, linkable, and verifiable. When an agent claims "Apple was founded in 1976," the system can check: does the triple (Apple, founding_year, 1976) exist? This is grounding—the antidote to hallucination.
Vector Store: Embeddings for semantic similarity search.
Narrative content that doesn't decompose into facts—analysis, opinions, context—is chunked, embedded, and stored in pgvector with HNSW indexes. This enables "find me content similar to X" queries.
Why both? They serve different purposes:
| Query Type | Use SPO | Use Vectors |
|---|---|---|
| "When was Apple founded?" | Yes | |
| "What's Apple's founding story?" | Yes | |
| "Did Jobs found Apple?" | Yes | |
| "Find similar companies to Apple" | Yes | |
| "Verify this claim about Apple" | Yes |
The ingestion pipeline analyzes content and routes intelligently:
| Content Type | Routing | Example |
|---|---|---|
| Structured facts | SPO triples | "Revenue was $100B in Q4" |
| Narrative | Vector chunks | "The company has shown remarkable growth..." |
| Mixed | Both | Wikipedia article (facts + narrative) |
This isn't just optimization—it's architectural. Facts go where they can be verified. Narrative goes where it can be searched. The system knows the difference.
Conversations with Epistemic Grounding
While Qadra's core is workload orchestration, users can also interact with agents directly through conversations. These are ad-hoc chat threads—not pipeline executions—but they benefit from the same knowledge infrastructure.
When a user sends a message in a conversation, the system automatically queries the knowledge graph for relevant SPO triples, detects contradictions, and identifies knowledge gaps. This epistemic context is injected into the agent's prompt as structured facts, so the agent's response is grounded in the organization's actual knowledge base rather than the LLM's training data.
Conversations also serve as the interface for flow execution: when the LLM determines that a user's intent maps to an existing flow, it can trigger flow execution inline and narrate the real results back to the user. Progress streams in real time via Server-Sent Events.
Multitenancy
Enterprise AI isn't one company—it's many. Each customer needs complete isolation: their agents, their knowledge, their workflows, their data.
Qadra is multitenant by design, not by afterthought:
Organization (Tenant)
├── Users ← Multi-tenant via junction table
├── API Keys ← Tenant-scoped, revocable
├── Agents ← Custom specialists for this org
├── Pipelines ← Workflows specific to their processes
├── Workloads ← Their active and completed work
│ ├── Tasks ← Agent assignments
│ └── Artifacts ← Produced documents
├── Agent Flows ← Visual workflow definitions
│ └── Flow Executions
├── Conversations ← Direct agent interactions
├── SPO Index ← Their private knowledge graph
└── Vector Store ← Their private embeddings
Why multitenancy matters:
| Concern | How Qadra Handles It |
|---|---|
| Data isolation | Every query includes tenant_id. Cross-tenant queries are impossible at the repository layer. |
| Knowledge isolation | SPO triples and vector embeddings are tenant-scoped. Acme's facts never leak to Beta Corp. |
| Agent isolation | Tenants define their own agents. "Research Analyst" means different things to different orgs. |
| Billing isolation | LLM costs, storage, and compute are tracked per-tenant via usage records. |
| Compliance | Audit logs are tenant-scoped. SOC 2 auditors see only their customer's data. |
Row-level security is enforced at the repository layer, not application code:
#![allow(unused)] fn main() { // Every repository method requires tenant_id async fn get_workload(&self, tenant_id: Uuid, id: Uuid) -> Result<Workload> { sqlx::query!("SELECT * FROM workloads WHERE tenant_id = $1 AND id = $2", tenant_id, id) .fetch_one(&self.pool) .await } }
There's no way to accidentally query across tenants because tenant_id is a required parameter on every operation. This is defense in depth—even if application code has a bug, the database layer enforces isolation.
Technology Stack
Every technology choice has a reason. We optimized for correctness, observability, and operational simplicity.
| Layer | Technology | Purpose | Why This Choice |
|---|---|---|---|
| Core | Rust + NATS | Data layer, NATS handlers | Memory safety without GC pauses. Async-first. Compiles to single binary. |
| Gateway | Node.js + Express | HTTP server, JWT, CORS | Thin layer for auth and routing. Communicates with Rust Core via NATS. |
| Intelligence | Python Agent | LLM orchestration | Python ecosystem for ML/LLM tooling. Handles workload execution, agent loops. |
| Frontend | React + Vite | SPA with flow builder | ReactFlow for visual flow editing. Tailwind + zustand for state. |
| Database | PostgreSQL 16 | Source of truth, pgvector | ACID transactions, native vector search, mature ecosystem |
| Cache/Events | Redis Stack | Caching, pub/sub, streams | Sub-millisecond reads, consumer groups for reliable delivery |
| Messaging | NATS | Service communication | Request-reply between Rust Core, Gateway, and Python Agent |
| Audit | MongoDB | Decision trees, traces | Schema-flexible for evolving audit formats, append-optimized |
| Gateway | Kong | JWT validation, rate limiting | Offloads auth from application, proven at scale |
| Observability | OTel + Grafana | Metrics, logs, traces | Industry standard, vendor-neutral, correlates across services |
Why These Specific Choices?
Rust for the data layer: Investment banking workflows are long-running and high-stakes. Rust's memory safety eliminates entire classes of bugs (use-after-free, null pointers). No garbage collector means predictable latency—no surprise pauses during critical operations. All data operations (CRUD, auth, flows, knowledge) go through Rust Core via NATS.
PostgreSQL over specialized databases: One database for relational data AND vector search (pgvector). This eliminates sync complexity between separate vector databases. Transactions work across both—you can atomically insert a document and its embeddings.
NATS for service communication: The gateway, Rust Core, and Python Agent communicate via NATS request-reply. This decouples the HTTP layer from the data layer and allows horizontal scaling of any component independently.
Redis for events, not Kafka: Our event volumes don't justify Kafka's operational complexity. Redis Streams provide durable, acknowledged delivery with consumer groups. If we outgrow Redis, the abstraction layer allows swapping implementations.
MongoDB for audit only: Audit logs have different access patterns (append-heavy, rarely queried, schema-evolving). Keeping them separate from PostgreSQL prevents table bloat and allows independent scaling.
Development Guide
This guide covers local development setup, common tasks, and patterns for contributing to Qadra.
Prerequisites
| Requirement | Version | Why |
|---|---|---|
| Rust | 1.75+ | Async traits, let-else syntax, modern features |
| Docker | Latest | PostgreSQL, MongoDB, NATS, MinIO, observability stack |
| Node.js | 18+ | Gateway and web frontend development |
| Yarn | 1.x+ | Package manager for gateway and web |
| Python | 3.11+ | Agent development |
| sqlx-cli | Latest | Database migrations (cargo install sqlx-cli) |
Quick version check:
rustc --version # rustc 1.75.0 or higher
docker --version # Docker 24.x or higher
node --version # v18.x or higher
yarn --version # 1.x or higher
python --version # 3.11 or higher
Local Setup
Clone and Configure
git clone https://github.com/HGP-Technologies/qadra-ai.git
cd qadra-ai
# Copy environment template
cp .env.example .env
# Edit configuration (set API keys, passwords)
vim .env
Start Infrastructure
# Start all infrastructure services
docker compose up -d postgres mongodb nats minio imaginary
# Wait for PostgreSQL to be ready
docker compose exec postgres pg_isready -U qadra
Run Migrations
DATABASE_URL=postgres://qadra:qadra_dev_password@localhost:5432/qadra \
sqlx migrate run
Start Development Servers
Three application processes run independently during development:
# Terminal 1: Rust Core (NATS handlers, truth layer)
cargo run
# Terminal 2: Node.js Gateway (HTTP API)
cd gateway && yarn install && yarn dev
# Terminal 3: React SPA (frontend)
cd web && yarn install && yarn dev
The React SPA runs at http://localhost:5173, the Gateway at http://localhost:4000, and Rust Core connects to NATS internally.
Project Structure
qadra-ai/
├── Cargo.toml # Workspace manifest (10 crates)
├── crates/ # Library crates
│ ├── qadra-traits/ # ALL interfaces and shared types (tiny, stable)
│ ├── qadra-container/ # IoC container, mock implementations
│ ├── qadra-db-postgres/ # Repository: PostgreSQL
│ ├── qadra-llm-openrouter/ # LLM: OpenRouter (OpenAI-compatible)
│ ├── qadra-cache-redis/ # Cache: Redis
│ ├── qadra-queue-redis/ # Queue: Redis Streams
│ ├── qadra-vector-pgvector/ # Vector: pgvector
│ ├── qadra-semantic-cache-redis/ # Semantic cache for verification results
│ ├── qadra-epistemic-core/ # Epistemic operations (KG queries, attribution)
│ └── qadra-nats/ # NATS client wrapper
├── src/ # Main Rust application
│ ├── main.rs # Entry point (8 NATS handler spawns)
│ ├── lib.rs # Library exports
│ ├── config.rs # Configuration loading
│ ├── nats_service.rs # NATS handler dispatch
│ ├── nats_observer.rs # Catch-all NATS audit observer
│ ├── flow_executor.rs # Flow graph execution engine
│ ├── flow_generator.rs # Flow graph generation
│ ├── email_service.rs # SMTP2GO email delivery
│ ├── email_renderer.rs # Handlebars template rendering
│ ├── email_logging.rs # LoggingEmailService decorator
│ ├── error.rs # Error types
│ ├── ingestion/ # Document processing pipeline
│ │ ├── mod.rs
│ │ ├── analyzer.rs # Content routing (SPO/Vector/Both/Discard)
│ │ ├── chunker.rs # Text chunking
│ │ ├── embedder.rs # Embedding generation
│ │ ├── extractor.rs # LLM-based triple extraction
│ │ └── service.rs # Orchestrates the ingestion pipeline
│ ├── tracing/ # Audit trace to MongoDB
│ └── telemetry/ # OpenTelemetry integration
├── gateway/ # Node.js HTTP gateway
│ ├── src/
│ │ ├── index.ts # Express server entry point
│ │ ├── nats.ts # NATS client and request helper
│ │ ├── config.ts # Configuration
│ │ ├── storage.ts # MinIO S3 client
│ │ ├── images.ts # Imaginary image processing client
│ │ ├── auth/ # JWT middleware
│ │ ├── routes/ # HTTP route handlers (15 route files)
│ │ │ ├── auth.ts # Login, register, refresh, verify
│ │ │ ├── users.ts # Profile, avatar
│ │ │ ├── teams.ts # Members, invites, roles
│ │ │ ├── tenants.ts # Tenant settings, branding
│ │ │ ├── agents.ts # Agent CRUD
│ │ │ ├── flows.ts # Flow CRUD and execution
│ │ │ ├── conversations.ts # Chat conversations and messages
│ │ │ ├── knowledge.ts # Document ingestion and proposals
│ │ │ ├── artifactTemplates.ts # Artifact template CRUD
│ │ │ ├── files.ts # File upload/download
│ │ │ ├── email.ts # Email send
│ │ │ ├── notifications.ts # Email templates, notification logs
│ │ │ ├── audit.ts # NATS audit trail
│ │ │ ├── admin.ts # Super admin operations
│ │ │ └── health.ts # Health checks
│ │ └── types/ # TypeScript type definitions
│ ├── package.json
│ └── tsconfig.json
├── web/ # React SPA (Vite + Tailwind + zustand)
│ ├── src/
│ │ ├── App.tsx # Router and layout
│ │ ├── main.tsx # Entry point
│ │ ├── api/ # API client functions
│ │ ├── stores/ # zustand state stores
│ │ ├── pages/ # Page components
│ │ │ ├── Dashboard.tsx
│ │ │ ├── Conversations.tsx
│ │ │ ├── ConversationChat.tsx
│ │ │ ├── Agents.tsx
│ │ │ ├── AgentEditor.tsx
│ │ │ ├── Flows.tsx
│ │ │ ├── FlowBuilder.tsx
│ │ │ ├── ArtifactTemplates.tsx
│ │ │ ├── ArtifactTemplateEditor.tsx
│ │ │ ├── Settings.tsx
│ │ │ ├── Admin.tsx
│ │ │ ├── Login.tsx
│ │ │ ├── Register.tsx
│ │ │ └── ...
│ │ └── components/ # Reusable components
│ │ └── flow/nodes/ # ReactFlow node type components
│ ├── package.json
│ ├── vite.config.ts
│ └── tailwind.config.ts
├── agent/ # Python orchestration agent
│ ├── src/qadra_agent/
│ │ └── orchestrator.py # WorkloadOrchestrator (LLM-driven pipelines)
│ ├── tests/
│ ├── pyproject.toml
│ └── Dockerfile
├── migrations/ # SQL migrations (001-030)
├── tests/ # Rust integration tests
│ ├── common/ # TestClient, TestDb, fixtures
│ ├── workload_lifecycle.rs
│ ├── task_lifecycle.rs
│ ├── stage_forwarding.rs
│ ├── multi_stage_forwarding.rs
│ └── artifact_pipeline.rs
├── deploy/otel/ # Observability configuration
├── kong/ # Kong API gateway config
├── docs/ # Documentation
└── docker-compose.yml # Full development stack
Crate Workspace
The Rust code is organized into 10 workspace crates plus the root binary:
| Crate | Purpose | Dependencies |
|---|---|---|
qadra-traits | All interfaces and shared types | Minimal (async-trait, uuid, chrono) |
qadra-container | IoC container, mock stubs | traits |
qadra-db-postgres | PostgreSQL Repository impl | traits, sqlx |
qadra-llm-openrouter | LLM provider (OpenRouter) | traits |
qadra-cache-redis | Redis cache impl | traits |
qadra-queue-redis | Redis Streams queue impl | traits |
qadra-vector-pgvector | pgvector VectorIndex impl | traits |
qadra-semantic-cache-redis | Semantic verification cache | traits |
qadra-epistemic-core | Epistemic operations (KG, attribution) | traits |
qadra-nats | NATS client wrapper | nats crate |
qadra (root) | Binary entry point, NATS handlers, flow executor | all above |
Design principle: Everything is swappable. Core business logic depends only on traits, not implementations. Switch PostgreSQL for SQLite, swap OpenRouter for Ollama, replace Redis with in-memory---all via configuration.
Common Tasks
Running Tests
# Unit tests (all workspace crates, ~120 tests)
cargo test --workspace --lib
# Integration tests (requires running PostgreSQL with pgvector)
DATABASE_URL="postgres://qadra:qadra_dev_password@localhost:5432/qadra" \
cargo test --test workload_lifecycle -- --test-threads=1
# All integration test files
DATABASE_URL="postgres://qadra:qadra_dev_password@localhost:5432/qadra" \
cargo test -- --test-threads=1
# Specific test by name
cargo test test_name
# With output
cargo test -- --nocapture
# Web tests (if test files exist)
cd web && yarn test
# Gateway type checking
cd gateway && yarn build
Linting
# Rust formatting
cargo fmt
cargo fmt --check
# Rust linting
cargo clippy -- -D warnings
# Web linting (Biome)
cd web && npx @biomejs/biome check src/
# Web type checking
cd web && npx tsc --noEmit
Building
# Debug build
cargo build
# Release build
cargo build --release
# Check without building (faster)
cargo check
# Gateway build
cd gateway && yarn build
# Web build
cd web && yarn build
Database Operations
# Connect to database
psql postgres://qadra:qadra_dev_password@localhost:5432/qadra
# Run migrations
DATABASE_URL=postgres://... sqlx migrate run
# Create new migration
sqlx migrate add migration_name
# Revert last migration
sqlx migrate revert
# Prepare offline query data (for CI)
cargo sqlx prepare
Adding New Features
Standard Feature Flow
The typical flow for adding a new feature:
- Migration -- Create SQL migration for any new tables or columns
- Traits types -- Add types and trait methods in
qadra-traits - PostgreSQL impl -- Implement trait methods in
qadra-db-postgres - Container mock stubs -- Add default return values in
qadra-container - NATS handler -- Add handler case in
src/nats_service.rs - Gateway route -- Add HTTP route in
gateway/src/routes/ - Frontend -- Add page/store in
web/src/ - Rule docs -- Update
.claude/rules/files
Adding a New NATS Handler
- Define the operation in
src/nats_service.rs:
#![allow(unused)] fn main() { "your_operation" => { // Parse request, call repository, return response } }
- Add gateway route in
gateway/src/routes/yourFeature.ts:
router.get("/your-endpoint", requireAuth, async (req, res) => {
const result = await natsRequest(subject, payload);
res.json(result);
});
- Register route in
gateway/src/index.ts
Adding a New Database Table
- Create migration:
sqlx migrate add my_table
- Write SQL in
migrations/XXX_my_table.sql:
CREATE TABLE my_table (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
-- columns
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_my_table_tenant ON my_table(tenant_id);
- Add trait in
crates/qadra-traits/src/lib.rs - Implement in
crates/qadra-db-postgres/src/lib.rs - Add mock stub in
crates/qadra-container/src/lib.rs
Code Style
Naming Conventions
| Item | Convention | Example |
|---|---|---|
| Crates | qadra-* | qadra-db-postgres |
| Modules | snake_case | flow_executor |
| Types | PascalCase | FlowExecution |
| Functions | snake_case | create_flow |
| Constants | SCREAMING_SNAKE | MAX_TOTAL_STEPS |
| Database tables | snake_case | agent_flows |
| API endpoints | kebab-case | /flows/:flowId/execute |
| NATS subjects | dot-separated | qadra.{tid}.flow.do.create |
Error Handling
Use the project's error types:
#![allow(unused)] fn main() { use crate::{Error, Result}; fn my_function() -> Result<()> { something_that_might_fail()?; Ok(()) } }
Logging
Use tracing macros with structured fields:
#![allow(unused)] fn main() { use tracing::{info, warn, error, debug, instrument}; #[instrument(skip(self))] async fn my_function(&self, id: Uuid) -> Result<()> { info!(id = %id, "Processing item"); Ok(()) } }
Testing Patterns
Unit Tests
Unit tests live at the bottom of source files (Rust convention):
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; #[test] fn test_my_function() { let result = my_function(input); assert_eq!(result, expected); } #[tokio::test] async fn test_async_function() { let result = async_function().await; assert!(result.is_ok()); } } }
Integration Tests
Integration tests are in tests/ and require a running PostgreSQL instance:
#![allow(unused)] fn main() { #[tokio::test] async fn test_workload_lifecycle() { let container = setup_test_container().await; let workload = container.workload_repository() .create_workload(params) .await .unwrap(); assert_eq!(workload.status, "pending"); } }
Test Database
The TestDb helper resolves the test database URL:
- TEST_DATABASE_URL -- Explicit test database URL (if set)
- DATABASE_URL -- Derives test URL by replacing database name with
qadra_test - Default --
postgres://qadra:qadra_dev_password@localhost:5433/qadra_test
The helper auto-creates the qadra_test database if it does not exist.
Mocking
Use mock implementations from qadra-container:
#![allow(unused)] fn main() { use qadra_container::ContainerBuilder; let container = ContainerBuilder::new() .with_mock_repository() .with_mock_llm() .build(); }
Mock implementations return empty/default values.
Git Workflow
Commit Configuration
All commits must be signed as:
- Name: Mark Scott
- Email: mark.scott@hgp-technologies.com
Use Co-Authored-By: Mark Scott <mark.scott@hgp-technologies.com> for co-authored commits.
Branch Strategy
main-- Production-ready codedevelop-- Integration branchfeat/*-- Feature branchesfix/*-- Bug fix branches
CI Pipeline
GitHub Actions at .github/workflows/ci.yml runs on push to main/develop and PRs targeting main. Key jobs:
| Job | Description |
|---|---|
secret-scan | Gitleaks secret detection |
rust-format | cargo fmt --check |
rust-check-test | clippy + unit tests (one compile) |
test-integration | Database integration tests (pgvector) |
coverage-rust | cargo llvm-cov code coverage |
build-rust | Release binary build |
web | Biome + tsc + vitest + build |
docker | Buildx + Trivy + SBOM + cosign |
ci-pass | Aggregate gate for branch protection |
Common Pitfalls
Forgetting tenant_id
Every repository method takes tenant_id. Every query includes it. No exceptions.
#![allow(unused)] fn main() { // WRONG sqlx::query!("SELECT * FROM workloads WHERE id = $1", id) // RIGHT sqlx::query!("SELECT * FROM workloads WHERE tenant_id = $1 AND id = $2", tenant_id, id) }
Blocking the async runtime
Never call blocking functions directly. Use tokio::spawn_blocking for unavoidable blocking work.
#![allow(unused)] fn main() { // WRONG let result = std::fs::read_to_string("file.txt")?; // RIGHT let result = tokio::fs::read_to_string("file.txt").await?; }
DbWorkload/DbTask/DbArtifact do not derive Clone
Access fields directly instead of .clone().into().
Container repository access patterns
#![allow(unused)] fn main() { // Repository (agents, graphs, SPO) container.repository() // Auth-specific operations container.auth() // Workload operations container.workload_repository() // Agent flow operations container.agent_flow_repository() }
Debugging
Enabling Debug Logs
# Rust Core logs
RUST_LOG=qadra=debug cargo run
# Specific module
RUST_LOG=qadra::flow_executor=debug cargo run
# SQL queries
RUST_LOG=sqlx=debug cargo run
# Gateway logs
cd gateway && DEBUG=* yarn dev
Inspecting Database State
psql postgres://qadra:qadra_dev_password@localhost:5432/qadra
\dt # List tables
SELECT * FROM agent_flows;
SELECT * FROM flow_executions WHERE status = 'running';
SELECT * FROM conversations ORDER BY created_at DESC LIMIT 5;
SELECT * FROM documents WHERE ingestion_status = 'pending_approval';
Inspecting NATS
# Monitor all NATS subjects
nats sub "qadra.>"
# Monitor specific subject
nats sub "qadra.*.flow.do.*"
# NATS server info
curl http://localhost:8222/varz
Inspecting MongoDB Audit
docker exec qadra-mongodb mongosh \
"mongodb://qadra:qadra_traces_dev@localhost:27017/qadra_traces?authSource=admin" \
--quiet --eval "db.nats_audit.find().sort({timestamp: -1}).limit(5).toArray()"
Performance Tips
Compile Times
# Use cargo check instead of build when possible
cargo check
# Incremental builds (default)
export CARGO_INCREMENTAL=1
# Full workspace build ~3 min, incremental ~30s
Runtime Performance
#![allow(unused)] fn main() { // Pre-allocate vectors when size is known let mut results = Vec::with_capacity(expected_count); // Use references instead of clones fn process(data: &Data) -> Result<()> // Preferred // Batch database operations repo.insert_many(&items).await?; // 1 query, not N }
Tutorials
These walkthroughs take you end-to-end through the three workflows that most onboarding teams need first: getting knowledge into the graph and querying it, wiring an SPO persona pipeline as a flow, and tracing a memo claim back to its sources for sign-off.
Every step is a real HTTP call against the Node.js Gateway (http://localhost:4000). If you have not started the stack yet, follow the Development Guide first --- you need Rust Core, the Gateway, PostgreSQL, and NATS running.
A few conventions used throughout:
- All authenticated calls send
Authorization: Bearer $TOKEN. The$TOKENis theaccess_tokenfrom login (15-minute lifetime --- re-login or refresh if it expires). - You never pass
tenant_idin a request body. It is derived from your JWT (see API Reference -> Authentication). - Responses are abbreviated to the fields each step needs. The examples are illustrative --- UUIDs and tokens are placeholders.
| Tutorial | What you learn | Reference |
|---|---|---|
| 1. Ingest knowledge & query it | The human-gated ingestion lifecycle and epistemic grounding in conversations | Knowledge Graph |
| 2. Build & run an SPO persona pipeline flow | Wiring Scarlet -> Ayana -> Eliza -> Reagan with a claim-validation checkpoint | API Reference -> Flows |
| 3. Trace a memo claim for sign-off | Walking a claim back to its supporting triples and source documents | Knowledge Graph -> Claim Trace |
Tutorial 1: Ingest knowledge & query it
Goal: register an account, propose a document, approve it through the human gate so it ingests, then ask a question and see the answer grounded in the freshly ingested facts.
This tutorial exercises the human-gated ingestion lifecycle described in Knowledge Graph -> Document Ingestion Pipeline. Nothing enters the SPO graph or vector store until a human approves it.
-
Register a user and tenant. Registration creates the user, a default workspace tenant, and returns a token pair in one call.
curl -X POST http://localhost:4000/auth/register \ -H 'Content-Type: application/json' \ -d '{ "email": "analyst@acme.test", "password": "supersecret", "name": "Ada Analyst" }'{ "access_token": "eyJ0eXAiOiJKV1Q...", "refresh_token": "eyJ0eXAiOiJKV1Q...", "active_tenant": { "tenant_id": "tnt-1111", "tenant_name": "Ada Analyst's Workspace", "role": "owner" } }If the account already exists, log in instead --- the response shape is identical:
curl -X POST http://localhost:4000/auth/login \ -H 'Content-Type: application/json' \ -d '{ "email": "analyst@acme.test", "password": "supersecret" }'Capture the access token for the rest of the tutorial:
export TOKEN="eyJ0eXAiOiJKV1Q..." -
Propose a document for review. Proposing does not ingest --- it places the document in the review queue with status
pending_approval.curl -X POST http://localhost:4000/knowledge/propose \ -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "title": "Acme Corp Overview", "content": "Acme Corporation was founded in 1990 by John Smith. The company is headquartered in San Francisco and reported 2024 revenue of $391B. Acme acquired DataCo in 2015 for $50 million.", "content_type": "text/plain", "source_url": "https://example.com/acme", "source_type": "web" }'{ "document_id": "doc-2222" } -
Approve the proposal (the human gate). Approval stamps
approved_by/approved_atand kicks off ingestion asynchronously on the Rust side. It returns immediately --- ingestion (analyze -> extract triples -> chunk -> embed) runs in the background.curl -X POST http://localhost:4000/knowledge/documents/doc-2222/approve \ -H "Authorization: Bearer $TOKEN"{ "document_id": "doc-2222", "status": "approved", "message": "Ingestion started -- poll document status for progress" } -
Poll until ingestion completes. Status moves
approved -> processing -> completed. Theingestion_resultcarries the routing decision and counts.curl http://localhost:4000/knowledge/documents/doc-2222 \ -H "Authorization: Bearer $TOKEN"{ "document_id": "doc-2222", "title": "Acme Corp Overview", "ingestion_status": "completed", "ingestion_result": { "routing": "both", "triples_extracted": 4, "chunks_created": 2 } }Four triples are now in the SPO index --- e.g.
(Acme Corporation, founded_by, John Smith)and(Acme Corporation, revenue_is, $391B)--- each linked back todoc-2222for provenance. -
Create an agent to talk to. Conversations route through an agent persona (see API Reference -> Agents).
curl -X POST http://localhost:4000/agents \ -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "name": "research-analyst", "display_name": "Research Analyst", "description": "Answers questions grounded in the knowledge graph", "system_prompt": "You are a research analyst. Answer using only grounded facts." }'{ "id": "agt-3333", "name": "research-analyst" } -
Open a conversation.
curl -X POST http://localhost:4000/conversations \ -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "title": "Acme research" }'{ "id": "cnv-4444", "title": "Acme research" } -
Ask a question and see epistemic grounding. Sending a message runs the epistemic query pipeline on your message before synthesis: it pulls matching SPO triples, runs the competence gate, and injects the grounding context into the LLM call. After synthesis, the verification gate records whether the answer stayed grounded --- both results land in the message metadata.
curl -X POST http://localhost:4000/conversations/cnv-4444/messages \ -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "agent_id": "agt-3333", "content": "Who founded Acme Corporation and what was its 2024 revenue?" }'{ "id": "msg-5555", "role": "assistant", "content": "Acme Corporation was founded by John Smith and reported 2024 revenue of $391B.", "metadata": { "verification": { "passed": true, "faithfulness": 1.0, "attributions": [ { "subject": "Acme Corporation", "predicate": "founded_by", "object": "John Smith", "score": 0.62 }, { "subject": "Acme Corporation", "predicate": "revenue_is", "object": "$391B", "score": 0.41 } ], "ungrounded_claims": [] } } }The answer is drawn from the triples you just ingested. The
verificationblock confirms the response stayed grounded --- if you ask about something not in the graph, the competence gate coverage drops and the verification gate surfaces the unsupported claims underungrounded_claimsinstead of hiding them.To watch the gates and node steps in real time, POST to
/conversations/cnv-4444/messages/streaminstead and read thetext/event-stream. See API Reference -> Stream Message.
Tutorial 2: Build & run an SPO persona pipeline flow
Goal: wire a four-persona research pipeline as a flow graph, run it, and inspect the execution results --- including the claim-validation checkpoint that blocks unverified claims from flowing downstream.
The personas map to flow node behaviors described in API Reference -> Flow Node Types:
| Persona | Role | Node behavior |
|---|---|---|
| Scarlet | Research --- writes triples | Agent then SpoWrite (persists her output as SPO triples with provenance) |
| Ayana | Analysis --- filters triples | Agent then SpoFilter (queries SPO triples into state, optionally by asserter) |
| Eliza | Due diligence --- drafts the DD | Agent |
| Reagan | Review --- approves the output | Agent |
| --- | Cross-stage grounding check | ClaimValidation (grounds Scarlet's and Ayana's claims against the SPO graph) |
The ClaimValidation node is the hard checkpoint: it labels every prior-stage claim SUPPORTED, CONTRADICTED, or NOT_MENTIONED before Eliza builds on them. See Knowledge Graph -> Claim Validation Node.
-
Create the flow definition. The graph is a ReactFlow
{nodes, edges, viewport}object. Each node'sdatacarries its type-specific config. Reuse$TOKENfrom Tutorial 1.curl -X POST http://localhost:4000/flows \ -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "name": "spo-persona-dd-pipeline", "description": "Scarlet writes triples, Ayana filters, ClaimValidation checks, Eliza drafts DD, Reagan reviews", "flow_data": { "nodes": [ { "id": "start", "type": "Start", "data": {}, "position": { "x": 0, "y": 0 } }, { "id": "scarlet", "type": "Agent", "data": { "label": "Scarlet (Research)", "agent_name": "scarlet", "prompt": "Research {{target_company}} and state concrete facts.", "output_key": "scarlet_output" }, "position": { "x": 200, "y": 0 } }, { "id": "scarlet-write", "type": "SpoWrite", "data": { "label": "Persist Scarlet triples", "content_key": "scarlet_output", "asserter": "scarlet", "source": "spo-persona-dd-pipeline", "output_key": "scarlet_triples" }, "position": { "x": 400, "y": 0 } }, { "id": "ayana", "type": "Agent", "data": { "label": "Ayana (Analysis)", "agent_name": "ayana", "prompt": "Analyze the research for {{target_company}}.", "output_key": "ayana_output" }, "position": { "x": 600, "y": 0 } }, { "id": "ayana-filter", "type": "SpoFilter", "data": { "label": "Filter Scarlet triples", "asserter": "scarlet", "output_key": "scarlet_facts" }, "position": { "x": 800, "y": 0 } }, { "id": "validate", "type": "ClaimValidation", "data": { "label": "Ground prior claims", "input_keys": "scarlet_output,ayana_output", "output_key": "claim_validation" }, "position": { "x": 1000, "y": 0 } }, { "id": "eliza", "type": "Agent", "data": { "label": "Eliza (Due Diligence)", "agent_name": "eliza", "prompt": "Write a due-diligence summary using only SUPPORTED claims from {{claim_validation}}.", "output_key": "eliza_output" }, "position": { "x": 1200, "y": 0 } }, { "id": "reagan", "type": "Agent", "data": { "label": "Reagan (Review)", "agent_name": "reagan", "prompt": "Review Eliza's DD for sign-off readiness.", "output_key": "reagan_output" }, "position": { "x": 1400, "y": 0 } }, { "id": "end", "type": "End", "data": {}, "position": { "x": 1600, "y": 0 } } ], "edges": [ { "id": "e1", "source": "start", "target": "scarlet" }, { "id": "e2", "source": "scarlet", "target": "scarlet-write" }, { "id": "e3", "source": "scarlet-write","target": "ayana" }, { "id": "e4", "source": "ayana", "target": "ayana-filter" }, { "id": "e5", "source": "ayana-filter", "target": "validate" }, { "id": "e6", "source": "validate", "target": "eliza" }, { "id": "e7", "source": "eliza", "target": "reagan" }, { "id": "e8", "source": "reagan", "target": "end" } ], "viewport": { "x": 0, "y": 0, "zoom": 1 } } }'{ "id": "flw-6666", "name": "spo-persona-dd-pipeline", "node_count": 9 }Don't want to hand-author the graph?
POST /flows/generatebuilds one from a natural-language prompt instead (see API Reference -> AI Flow Generation). -
Execute the flow. The
inputobject seeds the flow state --- here, the company under review. The executor walks the graph fromStart, running each node and threading outputs through shared state.curl -X POST http://localhost:4000/flows/flw-6666/execute \ -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "input": { "target_company": "Acme Corporation" } }'{ "execution_id": "exe-7777", "status": "running" } -
Poll the execution until it finishes. Status follows
pending -> running -> completed(orfailed/cancelled). If you had inserted aHumanInputnode, it would pause atpausedhere and wait forPOST /flows/executions/exe-7777/input.curl http://localhost:4000/flows/executions/exe-7777 \ -H "Authorization: Bearer $TOKEN" -
Inspect the results. A completed execution exposes the per-node
node_states(each withstatus,input,output) and the sharedstateblackboard. TheClaimValidationnode's output is the checkpoint you care about for sign-off.{ "execution_id": "exe-7777", "status": "completed", "state": { "target_company": "Acme Corporation", "scarlet_output": "Acme was founded by John Smith; 2024 revenue $391B.", "scarlet_triples": [ { "subject": "Acme Corporation", "predicate": "founded_by", "object": "John Smith" } ], "claim_validation": { "summary": { "supported": 2, "contradicted": 0, "not_mentioned": 1 }, "claims": [ { "claim": "Acme was founded by John Smith", "label": "SUPPORTED" }, { "claim": "2024 revenue was $391B", "label": "SUPPORTED" }, { "claim": "Acme operates in 40 countries", "label": "NOT_MENTIONED" } ] }, "eliza_output": "Due-diligence summary (grounded claims only)...", "reagan_output": "Approved for sign-off." }, "node_states": { "scarlet": { "status": "completed" }, "scarlet-write":{ "status": "completed", "output": { "triples_written": 4 } }, "ayana": { "status": "completed" }, "ayana-filter": { "status": "completed" }, "validate": { "status": "completed" }, "eliza": { "status": "completed" }, "reagan": { "status": "completed" } } }The
NOT_MENTIONEDlabel on "Acme operates in 40 countries" is the system telling you Scarlet asserted something the graph cannot back up --- Eliza is instructed to drop it, and Reagan signs off only on the grounded remainder.List every run of a flow with
GET /flows/flw-6666/executions.
Tutorial 3: Trace a memo claim for sign-off
Goal: an investment-committee reviewer reads a claim in a memo and needs to verify it against the underlying knowledge in one round trip --- which triples support it, who asserted them, and from which source documents.
This uses the claim-trace path documented in Knowledge Graph -> Claim Trace. It runs the inverse of ingestion: free-text claim in, supporting triples + source documents out, under a 500 ms budget (a 450 ms internal timeout returns partial results rather than failing the review).
-
Trace the claim. Paste the memo statement verbatim.
max_resultscaps how many supporting matches come back (1--20). Reuse$TOKEN.curl -X POST http://localhost:4000/knowledge/trace \ -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "claim": "Acme Corporation was founded by John Smith", "max_results": 5 }' -
Read the trace. Each match groups a supporting triple (with its
asserterandassertion_type) under the claim and links it to the source documents that ground it --- each carrying the excerpt and the full source content.{ "claim": "Acme Corporation was founded by John Smith", "matches": [ { "triple": { "subject": "Acme Corporation", "predicate": "founded_by", "object": "John Smith", "confidence": 0.95, "source": "Acme Corp Overview", "asserter": "scarlet", "assertion_type": "fact" }, "documents": [ { "document_id": "doc-2222", "title": "Acme Corp Overview", "source_url": "https://example.com/acme", "source_type": "web", "excerpt": "Acme Corporation was founded in 1990 by John Smith", "content": "Acme Corporation was founded in 1990 by John Smith. The company is headquartered in San Francisco...", "confidence": 0.95 } ] } ], "duration_ms": 312 }The reviewer now has everything to sign off: the claim resolves to a
fact-type triple asserted byscarlet, grounded indoc-2222with the exact excerpt.duration_msconfirms it came in under the sub-500 ms IC budget. -
Handle an unsupported claim. If the memo asserts something the graph cannot back up,
matchescomes back empty --- a clear signal the statement is not grounded and should not be signed off:curl -X POST http://localhost:4000/knowledge/trace \ -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "claim": "Acme Corporation operates in 40 countries", "max_results": 5 }'{ "claim": "Acme Corporation operates in 40 countries", "matches": [], "duration_ms": 88 }An empty trace and a
NOT_MENTIONEDlabel from Tutorial 2'sClaimValidationnode are two views of the same gap --- the pipeline catches it before the memo is written, and the trace endpoint catches it again at sign-off. Together they close the loop from ingestion to investment-committee review.
Architecture
System Overview
Qadra uses a NATS-centric, multi-container architecture. There is no monolithic HTTP API in Rust. Instead, each concern lives in a dedicated container, and all inter-service communication flows through NATS request-reply messaging.
The Four Containers
| Container | Language | Role |
|---|---|---|
Rust Core (qadra-core) | Rust | Data layer. Subscribes to NATS subjects, reads/writes PostgreSQL, MongoDB, Redis. No HTTP server. |
Node.js Gateway (qadra-gateway) | TypeScript/Express | HTTP boundary. JWT creation/validation, CORS, file uploads. Bridges HTTP requests to NATS. Never touches PostgreSQL. |
React SPA (web/) | TypeScript/React | Frontend. Vite dev server at :5173, built and served via nginx in production. |
Python Agent (agent/) | Python | Intelligence layer. LLM-driven workload orchestration, synthesis, research, verification. Communicates with Rust Core via NATS. |
Request Flow
Browser (:5173)
|
v
Kong (:8000) ── JWT validation, rate limiting, routing
|
v
Node.js Gateway (:4000) ── JWT create/validate, CORS, multipart uploads
|
v NATS request-reply
|
Rust Core (no HTTP port) ── PostgreSQL, MongoDB, Redis
|
v NATS request-reply (for LLM workloads)
|
Python Agent ── LLM providers (OpenRouter, Cerebras)
Why NATS Instead of Direct HTTP?
- Decoupling: Gateway knows nothing about database schemas. Rust Core knows nothing about HTTP routing.
- Horizontal scaling: Spin up N Rust Core instances; NATS distributes requests automatically.
- Uniform messaging: Auth, workloads, flows, email, admin, audit, epistemic operations all use the same transport.
- Observability: A single catch-all NATS observer captures every message for audit without modifying handlers.
Trait-Based Dependency Injection
The Rust application layer uses a ServiceContainer with trait-based DI. All external dependencies (database, cache, LLM providers, vector index) are abstracted behind traits defined in qadra-traits:
#![allow(unused)] fn main() { pub struct ServiceContainer { pub repository: Arc<dyn Repository>, pub llm: Arc<dyn LlmProvider>, pub cache: Arc<dyn Cache>, pub docs: Arc<dyn DocumentStore>, pub queue: Arc<dyn MessageQueue>, pub runtime: Arc<dyn RuntimeExecutor>, pub vector_index: Arc<dyn VectorIndex>, pub embeddings: Arc<dyn EmbeddingProvider>, // ... more trait objects } }
Why traits?
- Testability: Mock implementations in
qadra-containerfor unit tests without a real database - Swappability: Change PostgreSQL to SQLite by implementing
Repository - Clarity: Business logic expresses intent, not infrastructure concerns
Persistence Layer
Three databases serve distinct purposes:
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ PostgreSQL │ │ Redis Stack │ │ MongoDB │
│ │ │ │ │ │
│ - Source of truth│ │ - Cache │ │ - Audit traces │
│ - pgvector HNSW │ │ - Streams │ │ - NATS audit log │
│ - All domain │ │ - Pub/Sub │ │ - Decision trees │
│ tables │ │ - Session state │ │ │
└──────────────────┘ └──────────────────┘ └──────────────────┘
| Database | Purpose | Why This Choice |
|---|---|---|
| PostgreSQL | Source of truth for all state | ACID transactions, pgvector for embeddings, mature tooling, sqlx compile-time verification |
| Redis | Hot layer for performance | Sub-millisecond cache reads, pub/sub for real-time, streams for event sourcing |
| MongoDB | Audit and compliance logs | Schema-flexible for decision trees, append-only traces, natural fit for NATS audit entries |
Why not just PostgreSQL? Redis provides 10-100x faster reads for hot data (agent definitions, pipeline configs). MongoDB handles append-heavy, schema-evolving audit logs without bloating the primary database.
What Lives Where
| PostgreSQL (Source of Truth) | Redis (Hot Layer) | MongoDB (Audit) |
|---|---|---|
| All domain tables | Agent definitions (cached) | Decision trees |
| pgvector embeddings | Pipeline configs (cached) | Request traces |
| Workloads, tasks, artifacts | Session state | NATS audit log (nats_audit) |
| Tenant data, users | Event streams | Compliance logs |
| SPO triples, documents | Pub/Sub channels | |
| Agent flows, executions | ||
| Email templates, files |
Data Flow Patterns
Write Operations
All writes go to PostgreSQL first, then publish events:
Gateway → NATS → Rust Core → PostgreSQL → Redis Stream (event)
Example: Creating a workload
- Gateway receives HTTP request, validates JWT, publishes to
qadra.{tenant}.workload.do.create - Rust Core handler validates payload, inserts into
workloadstable within a transaction - Snapshots pipeline stages into
pipeline_snapshotJSONB - Publishes
workload.createdto Redis Stream - Returns response via NATS reply
Why PostgreSQL first? PostgreSQL is the source of truth. If the Redis publish fails, the workload still exists. Event delivery can be retried.
Read Operations
Reads use the cache-aside pattern:
Gateway → NATS → Rust Core → Redis Cache → miss? → PostgreSQL → populate cache
What gets cached: Agent definitions, pipeline configurations, tenant settings (read frequently, change rarely).
What doesn't get cached: Workloads, tasks (frequently updated, stale data is dangerous), artifacts (too large, accessed infrequently).
NATS Request-Reply
All service communication uses NATS request-reply. The gateway publishes a request to a NATS subject and awaits a reply:
Gateway: NATS.request("qadra.auth.login", payload) → response
Rust Core: subscribes to "qadra.auth.>" → processes → replies
NATS Subject Hierarchy
NATS is the message bus connecting all services. Subjects follow a hierarchical naming convention.
Subject Pattern Summary
| Subject Pattern | Handler | Purpose |
|---|---|---|
qadra.{tenant}.epistemic.> | Rust Core | KG queries, triples, attribution, SPO, document ingestion |
qadra.{tenant}.workload.do.> | Rust Core | Workload CRUD (create, get, list, tasks, artifacts, stage ops) |
qadra.{tenant}.flow.do.> | Rust Core | Flow CRUD and execution (create, get, list, update, delete, execute, human_input) |
qadra.{tenant}.agent.do.> | Rust Core | Agent CRUD (create, get, list, update, delete) |
qadra.{tenant}.artifact_template.do.> | Rust Core | Artifact template CRUD (create, get, list, update, delete) |
qadra.{tenant}.chat.do.> | Rust Core | Conversations and messages (create, send, list, rate) |
qadra.auth.> | Rust Core | Authentication (login, register, refresh, tokens, email verification) -- tenant-less |
qadra.email.> | Rust Core | Email operations (send templated email via SMTP2GO) -- tenant-less |
qadra.audit.> | Rust Core | Audit log queries (list NATS bus audit trail) -- tenant-less |
qadra.admin.> | Rust Core | Super admin operations (list tenants/users, create tenant, promote/demote) -- tenant-less |
qadra.> | Rust Core (observer) | Catch-all observer -> MongoDB nats_audit collection (fire-and-forget, metadata only) |
qadra.{tenant}.workload.execute | Python Agent | Full pipeline orchestration (LLM-driven) |
qadra.{tenant}.agent.query | Python Agent | Agent loop (synthesis, research, gap detection) |
qadra.{tenant}.verify.document | Python Agent | Document verification |
Auth Operations (qadra.auth.*)
Auth subjects are tenant-less (pre-authentication context):
| Operation | Subject | Description |
|---|---|---|
login | qadra.auth.login | Verify credentials, return user + tenants |
register | qadra.auth.register | Create user + tenant + membership |
refresh | qadra.auth.refresh | Validate refresh token hash |
store_token | qadra.auth.store_token | Store refresh token hash |
revoke_token | qadra.auth.revoke_token | Revoke single refresh token |
revoke_all | qadra.auth.revoke_all | Revoke all refresh tokens for user |
get_user | qadra.auth.get_user | Get user profile + tenant memberships |
list_members | qadra.auth.list_members | List members of a tenant |
invite_member | qadra.auth.invite_member | Smart invite: add directly if user exists, email invite if not |
update_member_role | qadra.auth.update_member_role | Change a member's role (owner/admin only) |
remove_member | qadra.auth.remove_member | Remove member from tenant (owner/admin only) |
accept_invite | qadra.auth.accept_invite | Accept team invite by token |
list_invites | qadra.auth.list_invites | List pending team invites |
cancel_invite | qadra.auth.cancel_invite | Cancel a pending invite |
verify_email | qadra.auth.verify_email | Verify email token, mark user verified |
resend_verification | qadra.auth.resend_verification | Invalidate old tokens, send new verification email |
get_tenant | qadra.auth.get_tenant | Get tenant details + settings (branding) |
update_tenant | qadra.auth.update_tenant | Update tenant name/settings (owner/admin) |
Admin Operations (qadra.admin.*)
Admin subjects are tenant-less. Every operation checks is_super_admin before executing.
| Operation | Subject | Description |
|---|---|---|
list_tenants | qadra.admin.list_tenants | List all tenants (paginated) |
create_tenant | qadra.admin.create_tenant | Create tenant + assign owner |
list_users | qadra.admin.list_users | List all users with tenant memberships (paginated) |
reset_password | qadra.admin.reset_password | Reset a user's password (argon2) |
switch_tenant | qadra.admin.switch_tenant | Drop into any tenant (bypasses membership) |
promote | qadra.admin.promote | Grant super admin (cannot self-promote) |
demote | qadra.admin.demote | Revoke super admin (cannot self-demote) |
stats | qadra.admin.stats | Platform stats |
Workload Operations (qadra.{tenant}.workload.do.*)
| Operation | Subject Suffix | Description |
|---|---|---|
create | workload.do.create | Create workload (snapshots pipeline stages) |
get | workload.do.get | Get workload by ID |
list | workload.do.list | List workloads (filter by status) |
create_task | workload.do.create_task | Create task for agent at stage |
complete_task | workload.do.complete_task | Mark task completed with output |
fail_task | workload.do.fail_task | Mark task failed with error |
create_artifact | workload.do.create_artifact | Store artifact produced by agent |
record_stage | workload.do.record_stage | Record stage result |
advance_stage | workload.do.advance_stage | Advance to next pipeline stage |
forwarded_ctx | workload.do.forwarded_ctx | Build forwarded context from prior stages |
list_agents | workload.do.list_agents | List active agents |
get_pipeline | workload.do.get_pipeline | Get pipeline definition |
Flow Operations (qadra.{tenant}.flow.do.*)
| Operation | Subject Suffix | Description |
|---|---|---|
create | flow.do.create | Create flow definition |
get | flow.do.get | Get flow by ID |
list | flow.do.list | List flows (paginated) |
update | flow.do.update | Update flow (name, description, flow_data, is_active) |
delete | flow.do.delete | Delete flow |
execute | flow.do.execute | Start flow execution (creates execution, runs graph) |
execution_get | flow.do.execution_get | Get execution state |
execution_list | flow.do.execution_list | List executions for a flow |
execution_cancel | flow.do.execution_cancel | Cancel running execution |
human_input | flow.do.human_input | Submit human input (resumes paused execution) |
generate | flow.do.generate | AI-generate flow graph from natural language prompt |
Email Operations (qadra.email.*)
| Operation | Subject | Description |
|---|---|---|
send | qadra.email.send | Resolve template, render, send via SMTP2GO |
template.list | qadra.email.template.list | List global + tenant email templates |
template.get | qadra.email.template.get | Get template by slug (tenant-specific, global fallback) |
template.upsert | qadra.email.template.upsert | Create/update tenant template override |
template.delete | qadra.email.template.delete | Delete tenant template override |
template.test_send | qadra.email.template.test_send | Render template, send to requesting user |
logs | qadra.email.logs | List notification audit log (paginated) |
Epistemic Operations (qadra.{tenant}.epistemic.*)
Tenant-scoped knowledge-graph operations handled by Rust Core (and qadra-epistemic-core):
| Operation | Subject Suffix | Description |
|---|---|---|
trace_claim | epistemic.trace_claim | Trace a single claim back to its supporting SPO triples |
trace_triple | epistemic.trace_triple | Chain-of-custody provenance for a triple (source document, excerpt, asserter) |
list_attribution_sessions | epistemic.list_attribution_sessions | List KG-SMILE gate-run audit sessions (filterable, paginated) (Story 104) |
get_attribution_session | epistemic.get_attribution_session | Fetch one gate run with its per-triple attributions (Story 104) |
list_conflicts | epistemic.list_conflicts | List detected contradictions in the KG |
acknowledge_conflict | epistemic.acknowledge_conflict | Mark a conflict as acknowledged |
resolve_conflict | epistemic.resolve_conflict | Resolve a conflict (record the winning triple) |
dismiss_conflict | epistemic.dismiss_conflict | Dismiss a conflict as a non-issue |
extract_financial_metrics | epistemic.extract_financial_metrics | Financial data extraction sub-agent: query SPO for an entity's metrics (AB#100) |
NATS Observer (qadra.>)
A catch-all subscriber that records every NATS message to the nats_audit MongoDB collection. Fire-and-forget via tokio::spawn. Skips qadra.audit.> subjects to avoid recursive logging. Graceful no-op if MongoDB is unavailable. Metadata only -- no payload content stored.
Orchestration Flow (Python Agent)
Client
|
v NATS: qadra.{tenant}.workload.execute
|
Python Agent (WorkloadOrchestrator)
|
+-- For each pipeline stage:
| +-- get_forwarded_context (Rust Core via NATS)
| +-- LLM selects best agent for stage
| +-- create_task (Rust Core via NATS)
| +-- LLM executes with agent persona + context
| +-- complete_task (Rust Core via NATS)
| +-- create_artifact (Rust Core via NATS)
| +-- record_stage_result (Rust Core via NATS)
| +-- advance_stage (Rust Core via NATS)
|
+-- Return WorkloadResult (success, stages, artifacts)
Flow Execution Engine
Event-driven graph executor in src/flow_executor.rs. Processes ReactFlow graph definitions:
- Start: Parse flow graph, find Start node, seed execution queue
- Loop: Pop node from queue, check predecessors complete, execute, store output, enqueue successors, persist state
- Pause: HumanInput nodes pause execution, wait for user response
- Resume: Human input submitted, merge into state, re-enqueue successors, continue loop
- End: End node reached, execution status
completed
Node types: Start, End, Agent, Condition, HumanInput, Loop, Http, ExecuteFlow, Transform, Delay, KnowledgeLookup, Research, Artifact, Extract, SpoFilter, SpoWrite, ClaimValidation.
Safety rails: MAX_TOTAL_STEPS=500, max Loop iterations=100, max Delay=300s.
SPO Pipeline Node Types
Three node types integrate the SPO knowledge store directly into flow execution:
| Node | Purpose | Config |
|---|---|---|
| SpoFilter (Story 76) | Queries the SPO store for triples (optionally by asserter) and writes them to flow state for downstream filtering. | -- |
| SpoWrite (Story 81) | Persists an agent's output as SPO triples with provenance. | content_key, asserter, source, output_key |
| ClaimValidation (Story 98) | Grounds every prior-stage claim against SPO triples in the Knowledge Core; reports supported/contradicted/not-mentioned per claim. | input_keys, output_key, asserter_filter, query_limit |
SSE Streaming (Conversation Progress)
Real-time flow execution progress streamed to the frontend via Server-Sent Events:
React (SSE consumer) <-- SSE -- Gateway (SSE endpoint) <-- NATS pub/sub -- Rust Core
|
POST /messages/stream -> subscribes to _PROGRESS.{uuid} FlowExecutor emits
returns text/event-stream forwards events as SSE node_started/completed
closes on "done" event to progress_subject
Source Code Organization
Rust Core (src/)
Rust Core has no HTTP server. It subscribes to NATS subjects and handles all data operations:
src/
├── main.rs # NATS handler spawning (all wildcard subscribers)
├── nats_service.rs # NATS message routing and handler dispatch
├── nats_observer.rs # Catch-all NATS observer -> MongoDB audit
├── config.rs # Configuration loading
├── error.rs # Error types
├── lib.rs # Library root
│
├── ingestion/ # Knowledge Core ingestion pipeline
│ ├── analyzer.rs # LLM content classification (Spo/Both/Discard)
│ ├── chunker.rs # Text chunking (pure Rust, paragraph boundaries)
│ ├── extractor.rs # LLM triple extraction with epistemic metadata
│ ├── embedder.rs # Batch embedding wrapper (chunks + SPO nodes)
│ └── service.rs # Pipeline orchestrator (IngestionService impl)
│
├── flow_executor.rs # Event-driven graph executor for agent flows
├── flow_generator.rs # AI-powered flow graph generation from prompts
│
├── email_service.rs # SMTP2GO email delivery
├── email_renderer.rs # Handlebars template rendering
├── email_logging.rs # LoggingEmailService decorator (notification audit)
│
├── tracing/ # Audit trace system (MongoDB)
│ ├── context.rs # Trace context management
│ ├── decision.rs # Decision tree types
│ ├── repository.rs # MongoDB trace repository
│ └── types.rs # Trace, Decision, Metrics structs
│
├── telemetry/ # OpenTelemetry integration
│ └── mod.rs # Init, config
│
└── db/ # Database utilities
└── mod.rs # Connection pool helpers
Crate Workspace (crates/)
crates/
├── qadra-traits/ # ALL interfaces (tiny, stable, no deps)
├── qadra-container/ # IoC container, service wiring, mock stubs
├── qadra-db-postgres/ # Repository: PostgreSQL (sqlx)
├── qadra-vector-pgvector/ # Vector: pgvector HNSW index
├── qadra-llm-openrouter/ # LLM: OpenRouter (OpenAI-compatible)
├── qadra-cache-redis/ # Cache: Redis
├── qadra-queue-redis/ # Queue: Redis Streams
├── qadra-semantic-cache-redis/ # Semantic cache: Redis with embedding similarity
├── qadra-nats/ # NATS client utilities
└── qadra-epistemic-core/ # Epistemic operations (SPO, curvature, attribution)
Key Traits (qadra-traits)
#![allow(unused)] fn main() { /// Repository - Database operations (PostgreSQL) #[async_trait] pub trait Repository: Send + Sync { async fn get_agent(&self, id: Uuid) -> Result<Option<Agent>>; async fn list_active_agents(&self, tenant_id: Uuid) -> Result<Vec<Agent>>; // ... more methods } /// AuthRepository - User/tenant/auth operations #[async_trait] pub trait AuthRepository: Send + Sync { async fn login(&self, email: &str, password: &str) -> Result<User>; async fn register_user(&self, params: RegisterParams) -> Result<(User, Tenant)>; // ... more methods } /// WorkloadRepository - Workload/task/artifact CRUD #[async_trait] pub trait WorkloadRepository: Send + Sync { async fn create_workload(&self, params: CreateWorkloadParams) -> Result<Workload>; async fn create_task(&self, params: CreateTaskParams) -> Result<Task>; // ... more methods } /// AgentFlowRepository - Flow/execution CRUD #[async_trait] pub trait AgentFlowRepository: Send + Sync { async fn create_flow(&self, params: CreateFlowParams) -> Result<AgentFlow>; async fn create_execution(&self, params: CreateExecutionParams) -> Result<FlowExecution>; // ... more methods } /// LlmProvider - Model completions #[async_trait] pub trait LlmProvider: Send + Sync { async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse>; async fn stream(&self, request: CompletionRequest) -> Result<CompletionStream>; } /// VectorIndex - Embedding storage and search #[async_trait] pub trait VectorIndex: Send + Sync { async fn upsert(&self, tenant_id: Uuid, records: Vec<EmbeddingRecord>) -> Result<()>; async fn search(&self, tenant_id: Uuid, query: Embedding, k: usize) -> Result<Vec<SearchHit>>; } }
Node.js Gateway (gateway/)
The gateway is an Express application that handles all HTTP concerns:
gateway/src/
├── index.ts # Express app, middleware setup
├── config.ts # Environment configuration
├── nats.ts # NATS client connection
├── storage.ts # S3/MinIO file operations
├── images.ts # Imaginary image processing client
├── auth/ # JWT creation, validation, middleware
├── routes/
│ ├── auth.ts # Login, register, refresh, verify email
│ ├── users.ts # User profile, avatar
│ ├── teams.ts # Team members, invites
│ ├── tenants.ts # Tenant settings, branding
│ ├── admin.ts # Super admin operations
│ ├── agents.ts # Agent CRUD (-> NATS)
│ ├── flows.ts # Flow CRUD and execution (-> NATS)
│ ├── conversations.ts # Chat/conversation operations (-> NATS)
│ ├── knowledge.ts # Knowledge ingestion (-> NATS)
│ ├── artifactTemplates.ts # Artifact template CRUD (-> NATS)
│ ├── files.ts # File upload/download (S3 + NATS)
│ ├── email.ts # Email send (-> NATS)
│ ├── notifications.ts # Email templates, logs (-> NATS)
│ ├── audit.ts # NATS audit log (-> NATS)
│ └── health.ts # Liveness and readiness checks
└── types/ # TypeScript type definitions
Gateway responsibilities:
- JWT creation (login/register) and validation (all authenticated routes)
- CORS configuration
- Multipart file uploads to MinIO (S3-compatible)
- Image processing via imaginary (avatar resize)
- HTTP-to-NATS bridging (every route publishes a NATS request and returns the reply)
- SSE streaming for real-time conversation progress
Gateway does NOT:
- Touch PostgreSQL directly
- Execute business logic
- Manage workload state
React SPA (web/)
web/src/
├── App.tsx # Router, layout, auth guard
├── main.tsx # Entry point
├── api/ # HTTP client (Axios, talks to Gateway)
├── stores/ # Zustand state management
├── pages/
│ ├── Login.tsx
│ ├── Register.tsx
│ ├── Dashboard.tsx
│ ├── Conversations.tsx / ConversationChat.tsx
│ ├── Agents.tsx / AgentEditor.tsx
│ ├── Flows.tsx / FlowBuilder.tsx
│ ├── ArtifactTemplates.tsx / ArtifactTemplateEditor.tsx
│ ├── Admin.tsx
│ ├── Settings.tsx
│ ├── TenantPicker.tsx
│ └── ...
└── components/
└── flow/nodes/ # ReactFlow custom node components
Python Agent (agent/)
The Python Agent is the intelligence layer. It handles everything that requires LLM calls: workload orchestration, synthesis, research, gap detection, and verification. It communicates exclusively with Rust Core via NATS -- it never touches PostgreSQL, MongoDB, or Redis directly.
Entry point: qadra-agent CLI command starts a FastAPI/uvicorn HTTP server and subscribes to NATS subjects for workload execution, agent queries, and document verification.
agent/src/qadra_agent/
├── main.py # Entry point (uvicorn server)
├── api.py # FastAPI HTTP endpoints
├── nats_client.py # NATS connection and request helpers (CoreClient)
├── orchestrator.py # WorkloadOrchestrator (pipeline execution)
├── llm.py # LLM provider client (OpenRouter, Anthropic, OpenAI)
├── loop.py # Agent loop (synthesis, research, gap detection)
├── research.py # Autonomous research via Exa, Tavily, Perplexity
├── ingestion.py # Document ingestion coordination
├── attribution.py # KG-SMILE attribution
├── verification.py # Document verification
├── conflict_detection.py # Contradiction detection in KG
└── config.py # Pydantic settings (QADRA_ env prefix)
WorkloadOrchestrator
The WorkloadOrchestrator class drives workload execution through pipeline stages. It subscribes to qadra.{tenant}.workload.execute via NATS and processes each workload sequentially through its pipeline.
Execution flow for each workload:
- Load workload and agents: Fetch the workload (including
pipeline_snapshot) and list all active agents from Rust Core via NATS - Sort stages: Order pipeline stages by their
orderfield - For each stage (sequential):
get_forwarded_context-- retrieve merged outputs and artifact references from all prior completed stages (Rust Core via NATS)_select_agent-- LLM picks the best agent for the stage based on agentstructure,description, and forwarded context. If only one agent exists, it is selected directly. Temperature is set to 0.0 for deterministic selection.create_task-- record the agent assignment in PostgreSQL (Rust Core via NATS)_execute_task-- build a system prompt from the agent persona (display_name, structure, stage name) and a user prompt incorporating forwarded context from prior stages, then call the LLMcomplete_task-- store the LLM output on the task (Rust Core via NATS)- If output exceeds 200 characters,
create_artifact-- persist the output as a named document artifact (Rust Core via NATS) record_stage_result+advance_stage-- persist the stage result and move the workload to the next stage (Rust Core via NATS)
- Return
WorkloadResultwith success status, stages completed count, and artifact list
Error handling: If any stage fails, the task is marked as failed via fail_task, and the orchestrator returns a partial result with the error and any artifacts produced by earlier stages.
Key design choice: The orchestrator never writes to the database directly. Every data operation is a NATS request-reply to Rust Core. This keeps the Python Agent stateless and horizontally scalable.
NATS Subjects Handled by Python Agent
| Subject | Handler | Purpose |
|---|---|---|
qadra.{tenant}.workload.execute | WorkloadOrchestrator.execute_workload | Full pipeline orchestration |
qadra.{tenant}.agent.query | Agent loop | Synthesis, research, gap detection |
qadra.{tenant}.verify.document | Verification | Document verification against KG |
NATS Callbacks to Rust Core
The CoreClient wraps NATS request-reply calls to Rust Core for all data operations:
| CoreClient Method | NATS Subject | Purpose |
|---|---|---|
get_workload | qadra.{tid}.workload.do.get | Fetch workload with pipeline snapshot |
list_agents | qadra.{tid}.workload.do.list_agents | List active agents for agent selection |
get_pipeline | qadra.{tid}.workload.do.get_pipeline | Fetch pipeline definition (fallback) |
get_forwarded_context | qadra.{tid}.workload.do.forwarded_ctx | Merged outputs from prior stages |
create_task | qadra.{tid}.workload.do.create_task | Record agent assignment |
complete_task | qadra.{tid}.workload.do.complete_task | Store task output |
fail_task | qadra.{tid}.workload.do.fail_task | Mark task failed |
create_artifact | qadra.{tid}.workload.do.create_artifact | Persist agent output as artifact |
record_stage_result | qadra.{tid}.workload.do.record_stage | Persist stage completion |
advance_stage | qadra.{tid}.workload.do.advance_stage | Move to next pipeline stage |
LLM Provider Configuration
The Python Agent uses an OpenAI-compatible client (via httpx) that routes through OpenRouter by default. Provider selection is via environment variables:
| Variable | Default | Description |
|---|---|---|
QADRA_NATS_URL | nats://localhost:4222 | NATS server URL |
QADRA_PORT | 8080 | HTTP server port |
QADRA_LLM_PROVIDER | openai | LLM provider (openai, anthropic, openrouter) |
QADRA_LLM_API_KEY | -- | LLM API key (falls back to OPENROUTER_API_KEY from root .env) |
QADRA_LLM_MODEL | google/gemini-3-flash-preview | Default LLM model |
QADRA_LLM_EMBEDDING_MODEL | text-embedding-3-small | Embedding model |
QADRA_EXA_API_KEY | -- | Exa API key for autonomous research |
QADRA_TAVILY_API_KEY | -- | Tavily API key for research |
QADRA_PERPLEXITY_MODEL | perplexity/sonar | Perplexity model for research via OpenRouter |
QADRA_MAX_RESEARCH_ITERATIONS | 3 | Maximum research iterations per query |
QADRA_ENABLE_AUTONOMOUS_RESEARCH | true | Enable autonomous research on gap detection |
All OpenRouter HTTP requests are rate-limited through a process-global semaphore (max 8 concurrent) with exponential backoff and jitter on 429 responses.
Dependencies
Python 3.10+, key packages: nats-py (NATS client), fastapi/uvicorn (HTTP API), openai (OpenAI-compatible LLM calls), anthropic (Claude API), httpx (async HTTP), pydantic-settings (configuration), tenacity (retry logic), structlog (structured logging).
Infrastructure
Docker Compose Services
| Service | Container | Port | Purpose |
|---|---|---|---|
| postgres | qadra-postgres | 5432 | PostgreSQL 16 + pgvector |
| mongodb | qadra-mongodb | 27017 | Audit traces, NATS audit log |
| nats | qadra-nats | 4222 | NATS message bus |
| qadra-core | qadra-core | 3000 (internal) | Rust Core -- NATS handlers only |
| qadra-gateway | qadra-gateway | 4000 | Node.js HTTP gateway |
| kong | qadra-kong | 8000, 8001 | API gateway, JWT validation, routing |
| minio | qadra-minio | 9000, 9001 | S3-compatible object storage |
| imaginary | qadra-imaginary | 9002 | HTTP image processing (resize, crop, format conversion) |
Observability Stack
| Service | Container | Port | Purpose |
|---|---|---|---|
| otel-collector | qadra-otel-collector | 4317, 4318, 8889 | OpenTelemetry Collector |
| loki | qadra-loki | 3100 | Log aggregation |
| prometheus | qadra-prometheus | 9090 | Metrics storage (30d retention, 5GB max) |
| grafana | qadra-grafana | 3200 | Visualization and alerting |
| cadvisor | qadra-cadvisor | 8088 | Container metrics |
Resource Limits
| Service | Memory Limit | Memory Reserved |
|---|---|---|
| postgres | 2GB | 512MB |
| mongodb | 1GB | 256MB |
| qadra-core | 1GB | 256MB |
| qadra-gateway | 256MB | 64MB |
| minio | 512MB | 128MB |
| imaginary | 256MB | 64MB |
| prometheus | 1GB | 256MB |
| grafana | 512MB | 128MB |
Kong Configuration
Kong runs in DB-less mode with declarative config at kong/kong.yml:
- JWT validation for Qadra-issued tokens
- Injects headers:
X-User-Email,X-Tenant-Id,Authorization - Routes:
/api/*to Gateway,/*to React SPA
Network
All services run on the qadra-network Docker network. Internal service discovery uses container names (e.g., postgres:5432, nats:4222, loki:3100).
Event Streams
Redis Streams for asynchronous coordination:
| Stream | Purpose | Consumers |
|---|---|---|
tasks.created | New task assignments | Workers |
tasks.progress.{id} | Progress updates | WebSocket handlers |
tasks.completed | Task finished | Stage advancement |
stages.advance | Move workload to next stage | Pipeline coordinator |
artifacts.created | New artifact produced | Notification service |
Why Redis Streams over simple Pub/Sub?
- Durability: Messages persist until acknowledged
- Consumer Groups: Multiple workers share a stream without duplicate processing
- Replay: New consumers can read historical messages
Security Model
Authentication
Two authentication methods:
JWT Tokens (Human Users):
- Access token (15 min):
{ sub, email, name, tid, tname, role, sa? } - Refresh token (7 days):
{ sub, type: "refresh" }, SHA256-hashed for DB storage saclaim present only for super admins- Gateway creates and validates JWTs; Rust Core never sees raw HTTP auth headers
API Keys (Machine-to-Machine):
- Tenant-scoped, permission-restricted, rate-limited, revocable
Authorization Flow
Browser -> Kong (validates JWT) -> Gateway (extracts claims) -> NATS (claims in payload) -> Rust Core (enforces tenant_id)
Authorization happens at the boundary:
- Kong: Validates JWT signature and expiry
- Gateway: Extracts user/tenant claims, injects into NATS payload
- Rust Core: Enforces
tenant_idscoping on every database query
Row-Level Security
Every query is scoped to a tenant at the repository layer:
#![allow(unused)] fn main() { impl Repository for PostgresRepository { async fn get_workload(&self, tenant_id: Uuid, id: Uuid) -> Result<Workload> { sqlx::query_as!(Workload, "SELECT * FROM workloads WHERE tenant_id = $1 AND id = $2", tenant_id, id ).fetch_one(&self.pool).await } } }
Defense in Depth
| Layer | What It Prevents |
|---|---|
| Kong JWT validation | Invalid/expired tokens |
| Gateway middleware | Missing authentication |
| NATS payload validation | Malformed requests |
| Repository tenant filter | Cross-tenant data access |
| Database foreign keys | Orphaned/invalid references |
Super Admin
Platform-level is_super_admin boolean on the users table. NOT a per-tenant role. Capabilities: create tenants, drop into any tenant (bypasses membership with synthetic JWT), reset passwords, promote/demote. NATS handler: qadra.admin.>.
File Storage
MinIO provides S3-compatible object storage:
- Gateway-proxied downloads: URLs are always
/api/files/{id}/download, never direct S3 links - Image processing: Avatar uploads auto-resized via imaginary (HTTP service) to 256x256 WebP
- Purpose-based validation:
avatar/logohave 2MB limit + image-only types; others 10MB default - Object key format:
tenants/{tenant_id}/{purpose}/{entity_id}{ext} - Multi-backend: Tenants can configure their own S3 endpoint via
tenants.settings.storage
Observability
Services (Rust, Node, etc.)
|
v OpenTelemetry Protocol (OTLP)
|
OTel Collector (receives, batches)
|
+-- Loki (logs)
+-- Prometheus (metrics)
|
v
Grafana (visualization + alerting)
- Grafana dashboards: Qadra Observability (
/d/qadra-overview), Container Monitoring (/d/qadra-containers) - 8 provisioned alerts: High CPU, high memory, critical memory, scrape target down, OTel export errors, container restart, high error rate, low disk space
- Structured JSON logs: All services emit to stdout with
trace_id,tenant_id,servicefields
Qadra Architecture
C4 Level 1 — System Context
Who uses Qadra and what does it talk to.
flowchart TB
user["fa:fa-user Tenant User<br/><i>Owner, admin, or member</i>"]
admin["fa:fa-user-shield Super Admin<br/><i>Cross-tenant platform operator</i>"]
subgraph platform["Qadra Platform"]
qadra["Multitenant Workload Orchestration<br/><i>Agents perform tasks · produce artifacts · ingest knowledge</i>"]
end
subgraph external["External Services"]
direction LR
openrouter["OpenRouter<br/><i>LLM inference</i><br/>Claude · GPT · Llama"]
smtp["SMTP2GO<br/><i>Email delivery</i>"]
minio["S3 / MinIO<br/><i>Object storage</i>"]
end
user -->|"Workloads, agents,<br/>flows, conversations"| qadra
admin -->|"Create tenants,<br/>manage users"| qadra
qadra -->|"LLM completions<br/>+ embeddings"| openrouter
qadra -->|"Verification, invite,<br/>notification emails"| smtp
qadra -->|"File upload<br/>/ download"| minio
style platform fill:#0d1117,stroke:#58a6ff,stroke-width:2px
style external fill:#1a1a2e,stroke:#30363d
C4 Level 2 — Container Diagram
What's inside the Qadra platform.
flowchart TB
user["fa:fa-user Tenant User<br/><i>Browser</i>"]
subgraph platform["Qadra Platform"]
direction TB
subgraph frontend["Frontend"]
spa["Web SPA<br/><i>React · Vite · Tailwind</i><br/>:5173"]
end
subgraph gateway_layer["Gateway"]
gateway["API Gateway<br/><i>Node.js · Express</i><br/>:4000<br/>JWT auth · CORS · HTTP→NATS"]
end
nats{{"fa:fa-arrows-alt NATS<br/><i>Request-Reply Bus</i><br/>:4222"}}
subgraph services["Services"]
direction LR
core["Rust Core<br/><i>Truth layer</i><br/>SPO index · ingestion<br/>flow executor · auth"]
agent["Python Agent<br/><i>Intelligence layer</i><br/>LLM orchestration<br/>workload execution"]
end
subgraph data["Data Stores"]
direction LR
postgres[("PostgreSQL<br/><i>pgvector</i><br/>Source of truth")]
mongodb[("MongoDB<br/>Audit traces")]
redis[("Redis<br/>Cache · streams")]
end
end
subgraph external["External Services"]
direction LR
openrouter["OpenRouter<br/><i>LLM API</i>"]
minio["MinIO<br/><i>S3 storage</i>"]
smtp["SMTP2GO<br/><i>Email</i>"]
end
user -->|"HTTPS"| spa
spa -->|"HTTPS"| gateway
gateway <-->|"publish / request"| nats
nats <-->|"subscribe / reply"| core
nats <-->|"subscribe / reply"| agent
core --> postgres
core -.-> mongodb
core --> redis
gateway --> minio
core -->|"HTTPS"| openrouter
agent -->|"HTTPS"| openrouter
core -->|"HTTPS"| smtp
style platform fill:#0d1117,stroke:#58a6ff,stroke-width:2px
style frontend fill:#161b22,stroke:#30363d
style gateway_layer fill:#161b22,stroke:#30363d
style services fill:#161b22,stroke:#30363d
style data fill:#161b22,stroke:#30363d
style external fill:#1a1a2e,stroke:#30363d
C4 Level 3 — Rust Core Components
What's inside the Rust Core container.
flowchart TB
NATS["fa:fa-envelope NATS Bus"]
subgraph core["Rust Core"]
direction TB
nats_svc["NATS Service<br/><i>8 wildcard handlers</i>"]
subgraph handlers["Request Handlers"]
direction LR
h_epist["epistemic.*<br/>KG queries, ingestion"]
h_work["workload.do.*<br/>CRUD + stage ops"]
h_auth["auth.*<br/>Login, register, tokens"]
h_flow["flow.do.*<br/>CRUD + execution"]
h_email["email.*<br/>Send + templates"]
h_admin["admin.*<br/>Super admin ops"]
end
subgraph engines["Engines"]
direction LR
ingestion["Ingestion Pipeline<br/><i>analyzer → extractor → embedder</i>"]
flow_exec["Flow Executor<br/><i>graph runner, 13 node types</i>"]
epistemic["Epistemic Core<br/><i>competence, verification, curvature</i>"]
end
subgraph infra["Infrastructure"]
direction LR
container["Service Container<br/><i>IoC, trait wiring</i>"]
observer["NATS Observer<br/><i>catch-all audit trail</i>"]
email_svc["Email Service<br/><i>SMTP2GO + Handlebars</i>"]
end
nats_svc --> handlers
h_epist --> ingestion
h_epist --> epistemic
h_flow --> flow_exec
h_email --> email_svc
end
NATS <--> nats_svc
observer -.->|"fire & forget"| MongoDB[(MongoDB)]
container --> PostgreSQL[(PostgreSQL)]
ingestion --> PostgreSQL
style core fill:#0d1117,stroke:#58a6ff,stroke-width:2px
style handlers fill:#161b22,stroke:#30363d
style engines fill:#161b22,stroke:#30363d
style infra fill:#161b22,stroke:#30363d
Data Flow — Document Ingestion
flowchart LR
A[Agent proposes document] -->|NATS: propose_document| B[documents table<br/>status: pending_approval]
B --> C{Human reviews}
C -->|Approve| D[stamps approved_by + approved_at]
C -->|Reject| E[stamps rejected_by + reason]
D -->|async tokio::spawn| F[Ingestion Pipeline]
F --> G[LLM Analyzer<br/>Spo / Both / Discard]
G -->|Spo| H[LLM Extractor<br/>SPO triples]
G -->|Both| H
G -->|Both| I[Chunker + Embedder<br/>vector chunks]
H --> J[(spo_nodes + spo_triples)]
I --> K[(pgvector embeddings)]
H --> L[document_triples<br/>provenance links]
I --> M[document_chunks<br/>provenance links]
Data Flow — Workload Execution
flowchart LR
A[Client] -->|NATS: workload.execute| B[Python Agent]
B --> C[For each pipeline stage]
C --> D[Get forwarded context<br/>from prior stages]
D --> E[LLM selects best agent]
E --> F[Create task<br/>via NATS to Rust Core]
F --> G[LLM executes with<br/>agent persona + context]
G --> H[Complete task + create artifact<br/>via NATS to Rust Core]
H --> I[Record stage result<br/>advance to next stage]
I --> C
NATS Subject Map
flowchart TB
subgraph "Tenant-Scoped"
E["qadra.{tid}.epistemic.*<br/>KG queries, ingestion, attribution"]
W["qadra.{tid}.workload.do.*<br/>Workload/task/artifact CRUD"]
F["qadra.{tid}.flow.do.*<br/>Flow CRUD + execution"]
end
subgraph "Tenant-Less"
AU["qadra.auth.*<br/>Login, register, tokens, invites"]
EM["qadra.email.*<br/>Send, templates, logs"]
AD["qadra.admin.*<br/>Super admin operations"]
AUD["qadra.audit.*<br/>Audit log queries"]
end
subgraph "Observer"
OB["qadra.><br/>Catch-all → MongoDB"]
end
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.
Domain Model
Core Concepts
| Concept | Definition | Example |
|---|---|---|
| Tenant | Organization with isolated data | "Acme Capital Partners" |
| Agent | Named specialist with a persona and system prompt | "Research Analyst" |
| Pipeline | Configurable sequence of stages with draft/live versioning | "Deal Pipeline" |
| Workload | Unit of work that moves through pipeline stages | "Acme Corp Acquisition" |
| Task | Agent assignment to a workload at a stage | "Research Analyst -> Research stage" |
| Artifact | Output produced by an agent, optionally from a template | "Market Analysis Report.pdf" |
| Flow | Visual graph-based workflow with branching and human gates | "Intake Triage Flow" |
| Conversation | Direct agent chat interface with switchable agents | "Research chat with Analyst" |
| Document | Source material with human-gated ingestion approval | "Acme Corp 10-K Filing" |
Entity Relationships
Understanding the entity hierarchy is crucial for working with Qadra. Every entity belongs to a tenant, and relationships enforce data integrity.
Tenant
├── Users (via user_tenants junction)
│ ├── is_super_admin (platform-level flag)
│ ├── email_verified
│ └── avatar_url
├── API Keys
├── Team Invites
├── Files (S3/MinIO metadata)
├── Email Templates (override global defaults)
├── Notification Logs (multi-channel audit)
├── Agents[]
├── Pipelines[]
│ ├── stages (live JSONB)
│ └── draft_stages (in-progress edits)
├── Workloads[]
│ ├── pipeline_id -> Pipeline
│ ├── pipeline_snapshot (immutable copy at creation)
│ ├── stage_results (accumulated JSONB)
│ ├── Tasks[]
│ │ └── agent_id -> Agent
│ ├── Artifacts[]
│ │ ├── task_id -> Task
│ │ └── template_id -> ArtifactTemplate
│ └── Usage Records[]
├── Artifact Templates[]
│ └── renderer_id -> Plugin
├── Agent Flows[]
│ └── Flow Executions[]
├── Conversations[]
│ └── Messages[]
│ ├── agent_id -> Agent
│ └── flow_execution_id -> FlowExecution
├── Documents[] (human-gated ingestion)
│ ├── proposed_by -> User
│ ├── approved_by -> User
│ ├── DocumentTriples[] -> SPO Triples
│ └── DocumentChunks[] -> Embeddings
├── SPO Index
│ ├── Nodes[]
│ ├── Triples[]
│ └── Entity Aliases[]
├── Plugins[]
├── Renderers[]
├── BALLS Graphs[]
└── Memory Store[]
Key Relationships Explained
Tenant -> Everything: The tenant is the root of all data. Every table has a tenant_id foreign key. You cannot query across tenants, period.
Users -> Tenants (many-to-many): Users belong to tenants through the user_tenants junction table. Each membership carries a per-tenant role (owner, admin, user) and an is_default flag indicating which tenant loads on login. A user's email is globally unique.
Super Admin: The is_super_admin boolean on the users table is a platform-level flag, not a per-tenant role. Super admins can create tenants, drop into any tenant (bypasses membership), reset passwords, and manage other super admins. The first super admin is promoted via direct SQL.
Pipeline -> Stages: Pipelines define the workflow structure. Stages are stored as JSONB within the pipeline. Pipelines support draft_stages for editing without affecting running workloads, and a pipeline_snapshot is captured on each workload at creation time.
Workload -> Pipeline: A workload references a pipeline and snapshots its stages at creation. This ensures running workloads are unaffected by subsequent pipeline edits.
Workload -> Stage Results: Completed stages record structured results in workloads.stage_results JSONB, keyed by stage name. Downstream stages consume this as forwarded context containing task summaries, merged outputs, and artifact references.
Task -> Agent: Each task is assigned to exactly one agent. The agent performs the work. Multiple tasks in a stage might use the same or different agents.
Task -> Artifacts: Artifacts are outputs. One task might produce multiple artifacts. Artifacts can optionally reference an artifact_template for structured output formatting.
Flow -> Executions: Agent flows are visual graph definitions stored as ReactFlow JSONB. Each execution is a state machine (pending -> running -> paused -> completed/failed/cancelled) with a blackboard state dictionary, per-node states, and an execution queue.
Conversation -> Messages: Conversations are direct agent chat sessions. Messages reference the agent that produced them (switchable per message) and optionally link to a flow execution.
Document -> Triples + Chunks: Documents go through a human-gated approval flow before ingestion. Once approved, the ingestion pipeline extracts SPO triples and/or vector chunks, linking them back to the source document for provenance.
Agents
Agents are the workers in Qadra. Unlike chatbots that respond to prompts, agents are specialists with defined personas and system prompts that perform tasks within workflows and conversations.
Agent Model
Agents have been refactored from the atomics model (JavaScript programs in QuickJS) to a pure persona model. Agents are LLM personas with system prompts, not code artifacts.
Persona fields -- what defines the agent:
name: Internal identifier ("research-analyst")display_name: Human-readable name ("Research Analyst")description: What this agent does in plain languageavatar_url: Profile imagesystem_prompt: The LLM system prompt that defines behavior and expertise
Orchestration fields -- how the agent participates in workflows:
control_type: What happens after the agent completes (retain,relinquish_to_parent,relinquish_to_start)output_visibility: Whether output is shown to users (user_facing) or kept internal (internal)max_calls_per_parent_agent: Safety rail limiting recursive invocations (default: 3)
Agent Schema
CREATE TABLE agents (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id),
name TEXT NOT NULL,
version INTEGER DEFAULT 1,
-- Persona
display_name TEXT,
avatar_url TEXT,
description TEXT,
system_prompt TEXT NOT NULL DEFAULT '',
-- Orchestration
control_type control_type NOT NULL DEFAULT 'relinquish_to_parent',
output_visibility output_visibility NOT NULL DEFAULT 'user_facing',
max_calls_per_parent_agent INTEGER NOT NULL DEFAULT 3,
-- Metadata
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(tenant_id, name, version)
);
Control Types
Control types define what happens to execution flow after an agent completes its work:
| Type | Behavior | Use Case |
|---|---|---|
retain | Agent keeps control, can be called again | Conversational agents, iterative research |
relinquish_to_parent | Returns control to the calling agent or orchestrator | Most agents -- do your job, hand back |
relinquish_to_start | Returns control to the pipeline entry point | Terminal agents that complete a branch |
Output Visibility
| Visibility | Behavior | Use Case |
|---|---|---|
user_facing | Output displayed to the end user | Final deliverables, reports, answers |
internal | Output kept within orchestration, hidden from user | Intermediate reasoning, data gathering |
Example Agent
{
"name": "research-analyst",
"display_name": "Research Analyst",
"description": "Gathers market data and competitive analysis for deal evaluation",
"avatar_url": "/avatars/analyst.png",
"system_prompt": "You are a senior research analyst at an investment bank. Your role is to gather and synthesize market data, competitive intelligence, and industry trends. Always cite sources. Structure your output with clear sections: Market Overview, Competitive Landscape, Key Trends, and Risk Factors.",
"control_type": "relinquish_to_parent",
"output_visibility": "user_facing",
"max_calls_per_parent_agent": 3
}
Why system prompts instead of JavaScript code? The original atomics model required agents to be JavaScript programs executed in QuickJS sandboxes. In practice, agent behavior is better expressed as natural language instructions to an LLM. System prompts are easier to write, test, and iterate on. The orchestration layer handles execution coordination; agents focus on expertise.
Versioning still works the same way. You might have research-analyst v1 and v2 with different system prompts but the same persona. Users see "Research Analyst" throughout -- the upgrade is invisible.
Canonical SPO Pipeline Personas
After the SPO-architecture decision (Story 98), four seeded agent presets form the canonical investment pipeline. Each writes to or reads from the SPO knowledge graph, so claims stay grounded and traceable as a deal moves through the stages.
| Persona | Role | Behavior |
|---|---|---|
| Scarlet | SPO claim writer | Extracts claims and writes them as SPO triples with provenance (asserter, source, assertion_type, confidence). Output is {"triples":[…],"summary":…} JSON. |
| Ayana | Investment filter | Reads Scarlet's SPO triples, applies investment filters, and routes deals PASS/CONDITIONAL/FAIL with a pre-screen score. |
| Eliza | Due-diligence writer | Writes due-diligence findings back to the SPO store, grounded in Scarlet's triples. |
| Reagan | Senior Investment Reviewer | Synthesizes upstream outputs into a final review (Recommendation, Conviction, Key Positives, Key Risks, Reviewer Notes). |
These are the canonical personas following the SPO-architecture decision -- Scarlet establishes grounded facts, Ayana and Eliza consume and extend them, and Reagan produces the human-facing recommendation.
Agent CRUD
Agents are managed through the gateway HTTP API, which routes to Rust Core via NATS.
NATS subjects: qadra.{tenant_id}.agent.do.{operation} where operation is create, get, list, update, or delete.
| Method | Path | NATS Subject | Description |
|---|---|---|---|
POST | /agents | qadra.{tid}.agent.do.create | Create a new agent |
GET | /agents | qadra.{tid}.agent.do.list | List agents (paginated) |
GET | /agents/:agentId | qadra.{tid}.agent.do.get | Get agent by ID |
PUT | /agents/:agentId | qadra.{tid}.agent.do.update | Update agent fields |
DELETE | /agents/:agentId | qadra.{tid}.agent.do.delete | Delete agent |
Create request fields (all except name are optional with defaults):
| Field | Type | Default | Validation |
|---|---|---|---|
name | string | required | 1-200 chars |
display_name | string | null | max 200 chars |
avatar_url | string | null | valid URL, max 2048 chars |
description | string | null | max 2000 chars |
system_prompt | string | "" | no limit |
control_type | enum | relinquish_to_parent | retain, relinquish_to_parent, relinquish_to_start |
output_visibility | enum | user_facing | user_facing, internal |
max_calls_per_parent_agent | integer | 3 | 1-100 |
Update semantics: The gateway uses a sparse update pattern. Only fields explicitly included in the request body are sent to Rust Core. Missing fields are not updated. Fields set to null clear the value. This means you can update just the system prompt without touching other fields.
Agent Editor (UI)
The agent editor (web/src/pages/AgentEditor.tsx) provides a full editing interface organized into three panels:
- Persona panel: Display name, description. These define the agent's identity as seen by users.
- System Prompt panel: A monospace textarea for the LLM system prompt. This is the core of the agent -- the instructions that define behavior when invoked by a flow or pipeline. Supports Ctrl/Cmd+S to save.
- Behavior panel: Control type (dropdown), output visibility (dropdown), and max calls per parent (number input). These govern how the agent participates in orchestration.
The top toolbar shows the agent name (click to rename inline) and a save button with status indicator (saved/error). The editor loads the agent by ID from the URL param and populates all fields from the store.
Pipelines
Pipeline Schema
CREATE TABLE pipelines (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id),
name TEXT NOT NULL,
description TEXT,
stages JSONB NOT NULL DEFAULT '[]', -- Live stage definitions
draft_stages JSONB, -- In-progress edits (NULL = no draft)
max_handoffs_per_turn INTEGER NOT NULL DEFAULT 25, -- Loop prevention
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(tenant_id, name)
);
Draft/Live Versioning
Pipelines support editing without affecting running workloads:
stages: The live configuration. Running workloads use this (via snapshot).draft_stages: An in-progress edit. NULL means no active draft.- Publishing: Overwrites
stageswithdraft_stages, then clears the draft. - Discarding: Sets
draft_stagesto NULL, leavingstagesuntouched.
When a workload is created, the pipeline's current stages are copied into workloads.pipeline_snapshot. This makes the workload immune to subsequent pipeline edits.
Safety Rails
max_handoffs_per_turn(pipeline-level, default 25): Maximum agent-to-agent handoffs per orchestration turn. Prevents infinite loops.max_calls_per_parent_agent(agent-level, default 3): Maximum times a parent agent can invoke a specific child agent per turn. Prevents recursive abuse.
Stage Definition
{
"stages": [
{
"name": "Research",
"order": 1,
"description": "Gather market data and competitive analysis"
},
{
"name": "Due Diligence",
"order": 2,
"description": "Validate claims and assess risks"
},
{
"name": "Investment Memo",
"order": 3,
"description": "Synthesize findings into recommendation"
},
{
"name": "Review",
"order": 4,
"description": "Final quality check"
}
]
}
Workloads
Workloads are the central concept in Qadra -- they represent units of work that progress through pipeline stages. In investment banking terms, a workload is a deal. In IT terms, it might be a ticket or project.
Unlike chat threads that accumulate messages, workloads accumulate structured context as agents complete tasks. This context flows forward, enabling downstream agents to build on previous work.
Workload Schema
CREATE TABLE workloads (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id),
pipeline_id UUID REFERENCES pipelines(id),
title TEXT NOT NULL,
description TEXT,
status workload_status DEFAULT 'pending', -- pending, active, completed, failed, cancelled
current_stage TEXT,
current_stage_order INTEGER,
metadata JSONB DEFAULT '{}',
context JSONB DEFAULT '{}', -- Accumulated context from completed tasks
pipeline_snapshot JSONB, -- Immutable copy of pipeline stages at creation
stage_results JSONB NOT NULL DEFAULT '{}', -- Per-stage structured results
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
Workload Lifecycle
Workloads follow a strict state machine. Understanding these states is essential for building UIs and debugging issues.
+-------------+ +-------------+ +-------------+
[*] --> | pending | --> | active | --> | completed | --> [*]
+-------------+ +------+------+ +-------------+
|
v
+-------------+
| failed | --> [*]
+-------------+
State Descriptions:
| State | Meaning | What's Happening |
|---|---|---|
pending | Created but not started | Waiting for user to initiate processing |
active | Processing in progress | Tasks are running, stages advancing |
completed | All stages finished successfully | All artifacts produced, work is done |
failed | Unrecoverable error occurred | A task failed and couldn't be retried |
cancelled | Manually cancelled | User stopped processing |
State Transitions:
| From | To | Trigger | Notes |
|---|---|---|---|
| pending | active | Execution starts | Creates tasks for first stage, snapshots pipeline |
| active | active | Stage advancement | All stage tasks complete -> next stage |
| active | completed | All stages complete | Final stage tasks done |
| active | failed | Task failure (no retry) | Retry exhausted or unretryable error |
| active | cancelled | User cancellation | Manual stop |
Stage Results and Context Forwarding
Completed pipeline stages produce structured results that flow to downstream stages. This replaces the simple context accumulation model with a richer structure.
Stage Result structure:
{
"Research": {
"stage_name": "Research",
"stage_order": 1,
"tasks": [
{
"task_id": "uuid",
"agent_id": "uuid",
"stage": "Research",
"output": { "market_size": "$50B", "competitors": ["CompA", "CompB"] },
"artifacts": [
{ "id": "uuid", "name": "Market Analysis", "artifact_type": "document", "content_type": "text/markdown", "size_bytes": 4200 }
],
"completed_at": "2026-01-24T10:00:00Z"
}
],
"merged_output": { "market_size": "$50B", "competitors": ["CompA", "CompB"] },
"completed_at": "2026-01-24T10:00:00Z"
}
}
Key types:
| Type | Contents | Purpose |
|---|---|---|
| ArtifactRef | {id, name, artifact_type, content_type, size_bytes} | Lightweight pointer, no content blob |
| TaskSummary | {task_id, agent_id, stage, output, artifacts[], completed_at} | Task + its artifact references |
| StageResult | {stage_name, stage_order, tasks[], merged_output, completed_at} | Complete stage record |
Forwarded context is built by build_forwarded_context() and contains {prior_stages, merged_outputs, artifact_refs} for downstream stage consumption.
Why references only? Artifact references store metadata, not content. Full content stays in the artifacts table. This keeps stage_results lightweight while providing enough information for downstream agents to know what's available.
Tasks
Task Schema
CREATE TABLE tasks (
id UUID PRIMARY KEY,
workload_id UUID NOT NULL REFERENCES workloads(id),
agent_id UUID NOT NULL REFERENCES agents(id),
stage TEXT NOT NULL,
stage_order INTEGER NOT NULL,
status task_status DEFAULT 'pending', -- pending, queued, running, completed, failed, cancelled
progress INTEGER DEFAULT 0, -- 0-100
input JSONB DEFAULT '{}',
output JSONB,
error TEXT,
queued_at TIMESTAMPTZ,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
Task Execution Flow
The Python Agent orchestrator (WorkloadOrchestrator) drives task execution. Data operations go to Rust Core via NATS.
1. Client sends qadra.{tenant}.workload.execute (NATS)
2. Python Agent creates workload -> Rust Core (workload.do.create)
3. For each pipeline stage (sequential):
a. get_forwarded_context -> Rust Core (prior stage outputs, artifact refs)
b. LLM selects best agent for stage (based on agent description/persona)
c. create_task -> Rust Core (agent assigned to stage)
d. LLM executes with agent system prompt + forwarded context
e. complete_task -> Rust Core (output stored)
f. create_artifact -> Rust Core (if output > 200 chars)
g. record_stage_result -> Rust Core (persist to stage_results JSONB)
h. advance_stage -> Rust Core (move to next stage)
4. Return WorkloadResult (success, stages_completed, artifacts)
Progress updates flow through Redis pub/sub to WebSocket connections, giving users real-time visibility into long-running tasks.
Artifacts
Artifact Schema
CREATE TABLE artifacts (
id UUID PRIMARY KEY,
workload_id UUID NOT NULL REFERENCES workloads(id),
task_id UUID REFERENCES tasks(id),
agent_id UUID NOT NULL REFERENCES agents(id),
template_id UUID REFERENCES artifact_templates(id), -- Optional template
name TEXT NOT NULL,
artifact_type TEXT NOT NULL, -- 'document', 'analysis', 'memo', 'report', etc.
content_type TEXT, -- MIME type
content TEXT, -- Inline for small artifacts
file_path TEXT, -- Storage path for large artifacts
size_bytes BIGINT,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
);
Artifact Types
| Type | Description | Example |
|---|---|---|
document | Written content | Research report |
analysis | Structured analysis | Risk assessment |
memo | Investment memo | IC presentation |
report | Formatted report | Due diligence summary |
data | Structured data | Financial model |
Artifact Templates
Artifact templates define the expected structure and output format for artifacts. They give agents a blueprint: what sections to produce, what schema to follow, and optionally which renderer plugin transforms the output into a final format (PDF, HTML, etc.).
Template Schema
CREATE TABLE artifact_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name TEXT NOT NULL,
display_name TEXT,
description TEXT,
version INTEGER NOT NULL DEFAULT 1,
-- Template structure
artifact_type TEXT NOT NULL DEFAULT 'document', -- 'document', 'analysis', 'memo', 'report', etc.
expected_sections JSONB NOT NULL DEFAULT '[]', -- Section definitions (see below)
output_schema JSONB, -- JSON Schema for structured output validation
output_format TEXT NOT NULL DEFAULT 'markdown', -- 'markdown', 'html', 'pdf', 'json'
-- Renderer binding (optional)
renderer_id UUID REFERENCES plugins(id) ON DELETE SET NULL,
-- Metadata
is_active BOOLEAN NOT NULL DEFAULT true,
is_global BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(tenant_id, name, version)
);
-- Artifacts can optionally reference a template
ALTER TABLE artifacts ADD COLUMN template_id UUID REFERENCES artifact_templates(id) ON DELETE SET NULL;
Indexes: Tenant-scoped indexes on artifact_type and active templates (partial index on is_active = true).
Creating a Template
A template requires a name and defaults to artifact type document with markdown output. All other fields are optional.
| Field | Type | Default | Description |
|---|---|---|---|
name | string | required | Internal identifier (1-200 chars, unique per tenant+version) |
display_name | string | null | Human-readable name (max 200 chars) |
description | string | null | What this template is for (max 2000 chars) |
artifact_type | string | "document" | Category: document, analysis, memo, report, etc. |
expected_sections | array | [] | Section definitions (see below) |
output_schema | object | null | JSON Schema for validating structured output |
output_format | enum | "markdown" | markdown, json, html, pdf |
renderer_id | UUID | null | Optional reference to a renderer plugin |
is_active | boolean | true | Whether this template is available for use |
is_global | boolean | false | Whether this template is available to all tenants |
Renderer binding: When renderer_id is set, it references a plugin (from the plugins table) that transforms the agent's raw output into the target output_format. For example, a renderer plugin might convert markdown content into a styled PDF. If no renderer is bound, the raw output is stored as-is. Deleting the referenced plugin sets renderer_id to NULL (ON DELETE SET NULL).
Expected Sections
The expected_sections JSONB array defines the structure an agent should follow when producing an artifact from this template. Each section is an object:
[
{
"name": "Executive Summary",
"description": "High-level overview of findings and recommendation",
"required": true
},
{
"name": "Market Analysis",
"description": "Market size, growth trends, competitive landscape",
"required": true
},
{
"name": "Risk Factors",
"description": "Key risks and mitigation strategies",
"required": true
},
{
"name": "Appendix",
"description": "Supporting data tables and references",
"required": false
}
]
| Field | Type | Default | Description |
|---|---|---|---|
name | string | required | Section heading |
description | string | optional | Guidance for what this section should contain |
required | boolean | true | Whether the agent must produce this section |
Sections are passed to the agent as part of the prompt context. The agent's output is expected to follow this structure, though enforcement depends on the orchestration layer.
Using Templates
When an artifact references a template_id, the template's expected_sections and output_schema guide the agent's output:
- During task execution: The orchestrator includes the template's section definitions in the agent's prompt context, instructing it to produce output matching the template structure.
- After generation: If
output_schemais set, the output can be validated against the JSON Schema. - Rendering: If
renderer_idis set, the renderer plugin transforms the raw output into the targetoutput_format(e.g., markdown to PDF). - Storage: The resulting artifact is stored in the
artifactstable withtemplate_idset, linking it back to the template for provenance.
In the investment banking example, the "Investment Memo" artifact at Stage 3 uses a template that ensures every memo has an Executive Summary, Market Analysis, Risk Factors, and Recommendation section -- consistent formatting across all deals.
Artifact Template API
Templates are managed through the gateway HTTP API, routed to Rust Core via NATS.
NATS subjects: qadra.{tenant_id}.artifact_template.do.{operation} where operation is create, get, list, update, or delete.
| Method | Path | NATS Subject | Description |
|---|---|---|---|
POST | /artifact-templates | qadra.{tid}.artifact_template.do.create | Create a new template |
GET | /artifact-templates | qadra.{tid}.artifact_template.do.list | List templates (paginated, filterable by artifact_type) |
GET | /artifact-templates/:id | qadra.{tid}.artifact_template.do.get | Get template by ID |
PUT | /artifact-templates/:id | qadra.{tid}.artifact_template.do.update | Update template fields |
DELETE | /artifact-templates/:id | qadra.{tid}.artifact_template.do.delete | Delete template |
List query params: limit (1-100, default 20), offset (default 0), artifact_type (optional filter).
Artifact Template Editor (UI)
The template editor (web/src/pages/ArtifactTemplateEditor.tsx) provides four panels:
- Template Info: Display name, description, artifact type (dropdown), and output format (dropdown).
- Expected Sections: A dynamic list where you add, remove, and reorder sections. Each section has a name field, optional description, and a required checkbox.
- Output Schema: A monospace JSON textarea for defining a JSON Schema. Validated on save -- invalid JSON prevents saving.
The editor supports Ctrl/Cmd+S to save and shows inline save status (saved/error). The name is editable inline from the top toolbar.
Usage Records
Every LLM call, embedding generation, tool invocation, plugin execution, and renderer execution is tracked per-operation:
CREATE TABLE usage_records (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id),
workload_id UUID REFERENCES workloads(id),
task_id UUID REFERENCES tasks(id),
agent_id UUID REFERENCES agents(id),
model TEXT,
tokens_in INTEGER NOT NULL DEFAULT 0,
tokens_out INTEGER NOT NULL DEFAULT 0,
operation_type usage_operation_type NOT NULL DEFAULT 'llm_call',
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Operation types: llm_call, embedding, tool_call, plugin_exec, renderer_exec.
Usage records enable aggregate queries (SUM tokens per tenant/workload), time-range filtering via indexed created_at, and batch inserts via UNNEST. Stored in a separate table rather than JSONB inside tasks because aggregate queries across workloads would require scanning all tasks and extracting nested arrays.
Agent Flows
Agent flows provide visual, graph-based workflows with branching, conditions, loops, and human-in-the-loop gates.
Flow Definition Schema
CREATE TABLE agent_flows (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id),
name TEXT NOT NULL,
description TEXT,
flow_data JSONB NOT NULL DEFAULT '{"nodes":[],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}',
node_count INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT true,
created_by UUID REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(tenant_id, name)
);
flow_data stores the ReactFlow graph definition as JSONB: {nodes, edges, viewport}. The executor always loads the full graph, never queries individual nodes. JSONB stores it verbatim with no impedance mismatch.
Flow Execution Schema
CREATE TABLE flow_executions (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id),
flow_id UUID NOT NULL REFERENCES agent_flows(id),
flow_snapshot JSONB NOT NULL, -- Immutable copy at execution start
status flow_execution_status NOT NULL DEFAULT 'pending',
state JSONB NOT NULL DEFAULT '{}', -- Blackboard dictionary
node_states JSONB NOT NULL DEFAULT '{}', -- Per-node execution state
execution_queue JSONB NOT NULL DEFAULT '[]',
current_node_id TEXT,
error TEXT,
triggered_by UUID REFERENCES users(id),
input JSONB NOT NULL DEFAULT '{}',
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Flow Execution Lifecycle
stateDiagram-v2
[*] --> pending
pending --> running : execute
running --> completed : End node reached
running --> failed : node error / safety limit
running --> cancelled : user cancels
running --> paused : HumanInput node
paused --> running : user submits input
paused --> cancelled : user cancels
| Status | Meaning |
|---|---|
pending | Created but not started |
running | Actively executing nodes |
paused | Waiting for human input at a HumanInput node |
completed | End node reached |
failed | Node error or safety limit exceeded |
cancelled | User cancelled |
Node Types
The flow executor supports 14 node types. Each node stores its configuration in the data JSONB field of the ReactFlow node object. All nodes that produce output can specify an output_key in their data; after execution, the output is merged into the flow state dictionary under that key.
Start
Entry point for the flow. Exactly one per flow.
| Field | Type | Description |
|---|---|---|
label | string | Display name |
Behavior: Pass-through. The execution input (provided when starting the flow) becomes the initial state. All successors are enqueued immediately. No output_key -- state is seeded from execution input directly.
End
Terminal node. At least one per flow. When reached, the execution status becomes completed.
| Field | Type | Description |
|---|---|---|
label | string | Display name |
output_keys | string[] | Optional. If set, only these keys from state are included in the final output. If omitted, the entire state dictionary is the output. |
Behavior: Collects final output. If output_keys is specified, filters the state to only those keys. Otherwise returns the full state. No successors are enqueued.
Agent
Invokes an LLM with the agent's system prompt and a rendered prompt template. This is the primary intelligence node.
| Field | Type | Description |
|---|---|---|
label | string | Display name |
agent_id | UUID string | References an agent in the agents table. The agent's system_prompt is loaded and used as the LLM system message. |
prompt_template | string | Instructions for this step. Supports {{variable}} template substitution from state. |
output_key | string | State key where the result is stored. |
Behavior: Renders prompt_template by replacing {{key}} placeholders with values from the flow state. Loads the agent's system_prompt by agent_id. Injects upstream state data as a second system message so the agent has context from prior nodes. Calls the LLM with temperature: 0.7, max_tokens: 4096. Output is a JSON object {response, model, tokens}. All successors are enqueued.
Condition
Boolean branching node. Evaluates an expression against the flow state and routes to the matching branch.
| Field | Type | Description |
|---|---|---|
label | string | Display name |
expression | string | Expression to evaluate. Supports: key == value, key != value, or key (truthy check). |
mode | string | "expression" (default) or "llm" (LLM-based evaluation, placeholder). |
Expression evaluation rules:
key == value: Comparesstate[key]againstvalue. Strings, numbers, and booleans are compared by string representation. Returns route"true"or"false".key != value: Inverse of==. Returns"true"or"false".key(bare): Truthy check.truefor non-empty strings, any number,truebooleans, non-empty arrays/objects.falsefor empty strings,null, missing keys,falsebooleans.
Edge behavior: Outgoing edges MUST use sourceHandle to indicate which route they belong to. An edge with sourceHandle: "true" is followed when the expression evaluates to true; sourceHandle: "false" for the false branch. Only the matching branch is enqueued. Nodes on the non-matching branch (and their entire downstream subtree) are marked Skipped, unless they are also reachable from the taken branch.
HumanInput
Pauses the entire flow execution and waits for a user to submit input via the API.
| Field | Type | Description |
|---|---|---|
label | string | Display name |
prompt | string | Message shown to the user describing what input is needed. |
timeout_seconds | number | Optional. Not currently enforced. |
output_key | string | State key where the user's response is stored after resumption. |
Behavior: Immediately returns NodeResult::Paused. The node status is set to WaitingForInput and the execution status is set to Paused. State and queue are persisted to the database. When the user submits input via POST /flows/executions/:execId/input, the NATS handler stores the response in the node's output, marks the node as Completed, merges the response into state under output_key, re-enqueues successors, and resumes the execution loop.
Loop
Iterates over a collection in the flow state. Currently a simplified batch processor -- it reads the array, caps it to max_iterations, and outputs the items. It does NOT re-execute successor nodes per item.
| Field | Type | Description |
|---|---|---|
label | string | Display name |
collection_key | string | State key containing the array to iterate over. Default: "items". |
max_iterations | number | Maximum items to process. Default: 100. |
Behavior: Reads state[collection_key] as an array. If missing or not an array, outputs {iterations: 0, results: []}. Otherwise caps at max_iterations and outputs {iterations, results}. All successors are enqueued once (not per item).
Http
Makes an outbound HTTP request. Supports GET, POST, PUT, PATCH, DELETE.
| Field | Type | Description |
|---|---|---|
label | string | Display name |
method | string | HTTP method. Default: "GET". |
url | string | Required. Supports {{variable}} template substitution from state. |
headers | object | Optional. Key-value map of request headers. |
body | JSON | Optional. Request body for POST/PUT/PATCH (sent as JSON). |
output_key | string | State key where the response is stored. |
Behavior: Templates the URL by replacing {{key}} with state values. Sends the request with a 30-second timeout. Output is {status, body} where body is parsed as JSON if possible, otherwise stored as a string. All successors are enqueued.
ExecuteFlow
Triggers another flow as a sub-flow. Currently a stub -- returns a placeholder message.
| Field | Type | Description |
|---|---|---|
label | string | Display name |
target_flow_id | UUID string | The flow to execute. |
input_mapping | string | How to map current state to sub-flow input. |
Behavior: Returns {message: "Nested flow execution not yet implemented"}. All successors are enqueued. Future implementation will create a child FlowExecution, run it to completion, and merge its output back into the parent state.
Transform
Applies a data transformation by mapping state fields to new output fields.
| Field | Type | Description |
|---|---|---|
label | string | Display name |
mappings | object | Key-value map where keys are output field names and values are either state field names (string) to copy, or literal JSON values. |
output_key | string | State key where the result is stored. |
Behavior: For each entry in mappings: if the value is a string, it is treated as a state key reference and the corresponding state value is copied. If the value is any other JSON type, it is used as a literal. Output is a JSON object with the mapped fields. All successors are enqueued.
Example: {"mappings": {"company": "company_name", "ready": true}} copies state.company_name into output.company and sets output.ready to true.
Delay
Waits for a specified duration before continuing. Capped at 300 seconds.
| Field | Type | Description |
|---|---|---|
label | string | Display name |
duration_seconds | number | Seconds to wait. Default: 1. Maximum: 300 (5 minutes). |
Behavior: Sleeps for min(duration_seconds, 300) seconds using tokio::time::sleep. Output is {delayed_seconds}. All successors are enqueued after the delay.
KnowledgeLookup
Queries the tenant's SPO knowledge graph for relevant triples.
| Field | Type | Description |
|---|---|---|
label | string | Display name |
query | string | Search query. Supports {{variable}} template substitution. |
source | string | "spo", "vector", or "both". Default: "both". Currently queries SPO regardless of this value. |
output_key | string | State key where results are stored. Default: "knowledge". |
limit | number | Maximum results. Default: 10. |
Behavior: Renders the query template with state variables. Searches SPO nodes matching the query text via search_spo_nodes, then fetches triples connected to those nodes via get_triples_for_nodes. Deduplicates by triple ID. Output is {query, source, limit, results} where results is an array of {subject, predicate, object, confidence, source} objects. Gracefully returns empty results on error. All successors are enqueued.
Research
Performs external research via LLM. Designed for Exa neural search integration (not yet wired -- currently uses LLM knowledge as a stand-in).
| Field | Type | Description |
|---|---|---|
label | string | Display name |
query | string | Research query. Supports {{variable}} template substitution. |
max_results | number | Maximum research results. Default: 3. |
output_key | string | State key where findings are stored. Default: "research_result". |
Behavior: Renders the query template. Calls the LLM with a research analyst system prompt and the rendered query plus workflow context (truncated to 4000 chars). Output is {query, findings, model, source: "llm_knowledge"}. All successors are enqueued.
Artifact
Produces a structured artifact, optionally formatted against an artifact template.
| Field | Type | Description |
|---|---|---|
label | string | Display name |
template_id | UUID string | Optional. References an artifact_templates row. Provides section structure, output schema, and format guidance. |
artifact_name | string | Name for the artifact. Supports {{variable}} template substitution. Default: "Untitled Artifact". |
content_key | string | State key containing upstream content to format. If empty or null, the full state is used as context. |
output_key | string | State key where the artifact is stored. Default: "artifact". |
Behavior: Four modes depending on available inputs:
- No upstream content, no template: Calls LLM to generate content from the full state context.
- No upstream content, with template: Calls LLM with template section guidance to generate structured content.
- Upstream content, with template: Calls LLM to reformat the upstream content into the template structure.
- Upstream content, no template: Uses the upstream content as-is (no LLM call).
Output is {artifact_name, template_id, artifact_type, output_format, content}. All successors are enqueued.
Extract
Extracts structured parameters from text using an LLM. Typically placed immediately after the Start node to parse user intent into typed variables that downstream nodes reference via {{variable_name}}.
| Field | Type | Description |
|---|---|---|
label | string | Display name |
input_key | string | State key containing the raw text to extract from. Default: "user_message". |
output_variables | string | Comma-separated list of variable names to extract (e.g., "company_name, metric, time_period"). |
instructions | string | Optional. Additional extraction instructions for the LLM. |
Behavior: Reads state[input_key] as the raw text. If empty, sets all output variables to null. Otherwise, calls the LLM (temperature: 0.0, max_tokens: 1024) with a prompt asking it to extract the named variables as a JSON object. Strips markdown fences from the response if present. Parses the JSON and maps each requested variable into the output. If the LLM returns non-JSON, gracefully sets all variables to null. The output is a flat object -- each extracted variable becomes a top-level key. Unlike other nodes, Extract merges its output variables directly into state (each variable becomes a state key), making them available to all downstream nodes via {{variable_name}}.
Note: Extract nodes do NOT use output_key. Instead, each extracted variable is merged individually into the flow state. This is because the node returns a flat object (not nested under a key), and the executor's output-merge logic writes each top-level key into state.
AI Flow Generation
Flows can be generated from natural language descriptions using an LLM. The flow generator (src/flow_generator.rs) translates user prompts into valid ReactFlow graph definitions.
How It Works
- User submits a prompt via
POST /flows/generatewith a natural language description of the desired workflow. - Gateway forwards the request to Rust Core via NATS subject
qadra.{tid}.flow.do.generate. - Rust Core loads context: active agents and artifact templates for the tenant.
- LLM generates the graph: A system prompt describes all 14 node types with their data field schemas, edge format, layout rules, and structural constraints. The user message includes the available agents (with IDs, names, descriptions) and artifact templates, followed by the user's request.
- Validation: The generated JSON is validated for structural correctness.
- Position assignment: If nodes lack positions, a top-to-bottom grid layout is applied automatically.
- Result returned: The validated
flow_dataJSONB is returned to the gateway, ready to be saved as a flow.
Node Catalog
The node catalog (NODE_CATALOG in flow_generator.rs) is the single source of truth for node types. It defines the type_name, description, data_example, and notes for each node type. The LLM system prompt, validation logic, and frontend palette all derive from this catalog. Adding a new node type means adding one NodeSpec entry.
LLM Prompt Structure
The system prompt instructs the LLM to return raw JSON (no markdown fences) with this structure:
{
"nodes": [
{
"id": "{type}_{unique_number}",
"type": "{node_type}",
"position": { "x": number, "y": number },
"data": { "label": "Display Name", ...type_specific_fields }
}
],
"edges": [
{
"id": "e_{source_id}-{target_id}",
"source": "{source_node_id}",
"target": "{target_node_id}",
"animated": true,
"sourceHandle": "true|false"
}
],
"viewport": { "x": 0, "y": 0, "zoom": 1 }
}
Layout rules: Start node at (400, 50), 200px vertical spacing, 300px horizontal spacing for parallel branches. Condition edges use sourceHandle of "true" or "false".
LLM parameters: temperature: 0.3, max_tokens: 4096.
Validation
validate_flow_data() checks:
nodesandedgesarrays exist and are non-empty- Exactly 1
startnode - At least 1
endnode - Every node type is in the catalog (rejects unknown types)
- Every edge references valid source and target node IDs
If the LLM wraps its response in markdown code fences despite instructions, they are stripped before parsing.
Position Assignment
ensure_positions() checks whether any node is missing a position or has a position without x/y. If so, all nodes receive a simple top-to-bottom grid layout starting at (400, 50) with 200px vertical spacing.
Flow Execution Engine
The event-driven graph executor in src/flow_executor.rs processes flow graphs. It is a Rust in-process executor that shares the PostgreSQL connection pool and NATS handlers with the rest of Rust Core.
Execution Loop
1. Load execution record (must be status=Pending)
2. Parse graph from flow_snapshot (immutable copy)
3. Initialize: all nodes -> Pending, seed queue with Start node
4. Set execution status -> Running
5. LOOP:
a. Pop node_id from front of queue
b. Check all predecessors are Completed or Skipped (join gate)
- If not all done: re-enqueue and continue
c. Set node status -> Running, record started_at, snapshot state as input
d. Persist state to database (crash recovery point)
e. Emit "node_started" progress event via SSE channel
f. Execute the node (dispatch by type)
g. Handle result:
- Completed: mark Completed, merge output into state via output_key,
enqueue successors (respecting condition routing), skip non-taken branches
- Paused: mark WaitingForInput, persist state, set execution -> Paused, RETURN
- Failed: mark Failed, set execution -> Failed with error, RETURN
h. Emit "node_completed" or "node_failed" progress event
6. Queue empty: set execution status -> Completed, persist final state
State Merging
The flow state is a JSONB blackboard dictionary shared by all nodes. State flows through the graph as follows:
- Execution input seeds the initial state (e.g.,
{"user_message": "Analyze NVIDIA"}) - After each node completes, if the node has an
output_keyin its data, the node's output is stored asstate[output_key] = output - Extract nodes are special: they return a flat object of extracted variables, and each variable becomes an individual state key (no nesting under output_key)
- Each node receives the full state as its input. Nodes read from state via template substitution (
{{key}}) or direct key access - State is never pruned -- it accumulates throughout the execution. All prior node outputs remain available to all downstream nodes
Predecessor Join Gate
Before executing a node, the executor checks that ALL predecessor nodes (based on incoming edges) have status Completed or Skipped. This implements an implicit AND-join for nodes with multiple incoming edges. If predecessors are not yet done, the node is re-enqueued at the back of the queue.
Condition Routing and Branch Skipping
When a Condition node completes, it returns a route value ("true" or "false"). The executor then:
- For each outgoing edge, checks if the edge's
sourceHandlematches the route - Matching edges: target nodes are enqueued
- Non-matching edges: the
skip_subtree()function marks the target node and its entire downstream subtree asSkipped(via depth-first traversal), unless a node is also reachable from the taken branch
This ensures that only the relevant branch executes, and nodes on dead branches are marked Skipped rather than left Pending.
HumanInput Pause and Resume
Pause:
- HumanInput node immediately returns
NodeResult::Paused - Node status set to
WaitingForInput - Full execution state (state dict, node_states, queue) persisted to database
- Execution status set to
Paused - Executor returns -- the execution is now dormant
Resume (triggered by POST /flows/executions/:execId/input):
- NATS handler stores user input in the waiting node's
outputfield FlowExecutor::resume()is called- Loads persisted state from database
- Finds the
WaitingForInputnode, marks itCompleted - Merges the node's output into state under its
output_key - Re-enqueues the node's successors
- Sets execution status to
Running - Re-enters the same execution loop as
run()
Nested ExecuteFlow
The ExecuteFlow node type is currently a stub. It returns {message: "Nested flow execution not yet implemented"} and enqueues successors normally. The intended future behavior: load the target flow by target_flow_id, create a child FlowExecution, run it synchronously, and merge its final state back into the parent flow's state.
Safety Rails
| Rail | Limit | Behavior on Violation |
|---|---|---|
MAX_TOTAL_STEPS | 500 node executions per run | Execution fails with error |
| Loop max iterations | 100 items | Array truncated silently |
| Delay max duration | 300 seconds (5 min) | Duration capped silently |
| HTTP timeout | 30 seconds per request | Node fails with error |
MAX_TOTAL_STEPS is checked on every iteration of the execution loop (both initial run() and resume()). It counts total node executions, not unique nodes -- a loop that re-enqueues nodes can exhaust this limit.
State Persistence and Crash Recovery
The executor persists the full execution state to PostgreSQL after every node execution via update_execution_state(). This writes the current state, node_states, execution_queue, and current_node_id to the flow_executions row. If the Rust process crashes mid-execution, the persisted state contains enough information to determine which nodes completed and where the queue was, though automatic crash recovery is not currently implemented.
Progress Events
The executor optionally emits real-time progress events via an mpsc::UnboundedSender<FlowProgressEvent> channel (attached via with_progress()). Events are fire-and-forget and never block execution. Three event types:
| Event | Data | When |
|---|---|---|
node_started | {node_id, node_type, label} | Before node execution |
node_completed | {node_id, output_preview (200 char max), duration_ms} | After successful execution |
node_failed | {node_id, error} | After failed execution |
These events are streamed to the frontend via SSE for live flow execution visualization.
Conversations
Conversations provide a direct agent chat interface, separate from pipeline-based workloads. While workloads move structured data through pipeline stages, conversations are freeform -- a user talks to agents, switches between them, and can even trigger flow executions inline.
Conversation Model
A conversation belongs to a single user within a tenant. It holds an ordered sequence of messages, each of which records the agent that responded (if any), the LLM model used, performance metrics, and optional links to flow executions.
CREATE TABLE conversations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE SET NULL,
title TEXT, -- Auto-generated or user-set
is_archived BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ, -- Soft delete (filtered by default)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE conversation_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
agent_id UUID REFERENCES agents(id) ON DELETE SET NULL,
role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'system')),
content TEXT NOT NULL,
model TEXT, -- LLM model identifier
token_count INTEGER, -- Total tokens consumed
latency_ms INTEGER, -- End-to-end response time
finish_reason TEXT, -- 'stop', 'length', 'tool_calls', etc.
rating SMALLINT CHECK (rating IN (-1, 1)), -- Thumbs up/down
flow_execution_id UUID REFERENCES flow_executions(id) ON DELETE SET NULL,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Indexes:
idx_conversations_tenant-- tenant-scoped listing, excludes soft-deleted (WHERE deleted_at IS NULL)idx_conversations_tenant_user-- per-user listing within a tenant, excludes soft-deletedidx_conversation_messages_conv_created-- ordered message retrieval by conversationidx_conversation_messages_agent-- find all messages by a specific agentidx_conversation_messages_rating-- query rated messages for feedback analysis
Relationship to other entities:
- Each
conversation_messages.agent_idpoints to the agent that produced the assistant response. User messages haveagent_id = NULL. conversation_messages.flow_execution_idlinks to aflow_executionsrow when the message was produced by running a flow.conversation_messages.metadatais a JSONB bag that carriesartifacts(inline content blocks for rendering) andsuggested_actions(follow-up prompts the UI can present).
Agent Switching
Unlike most chat interfaces where the "assistant" is a single model, Qadra conversations let users switch agents on every message. The agent_id lives on conversation_messages, not on conversations.
How it works:
- The user selects an agent in the UI (agent picker dropdown).
- When sending a message, the selected
agent_idis included in the request body. - The backend invokes the chosen agent's system prompt + conversation history to generate a response.
- Both the user message and assistant message are persisted. The assistant message records the
agent_idthat produced it. - The UI auto-selects the last responding agent for the next message, but the user can switch at any time.
This means a single conversation can contain messages from multiple agents. For example, a user might ask the Research Analyst about market data, then switch to the Due Diligence Specialist to probe risk factors -- all within the same conversation thread.
Flow Tool-Use
Agents can trigger flow executions from within a conversation. When an agent determines that a user's request maps to an existing flow, the backend executes the flow and streams progress events back to the user in real time.
How flow integration works:
- The
send_messagehandler on the Rust Core evaluates whether the agent should invoke a flow (based on the agent's tool configuration and the conversation context). - If a flow is triggered, the executor runs the flow graph and publishes progress events to a NATS progress subject.
- The gateway forwards these NATS events as SSE events to the client (see SSE Streaming below).
- When the flow completes, the agent receives the flow's output state and synthesizes a final response.
- The assistant message is persisted with
flow_execution_idset, linking the conversational response to the full flow execution record.
The frontend renders flow progress inline in the chat: a live visualization of node execution with status indicators, output previews, and duration metrics.
SSE Streaming
The POST /conversations/:id/messages/stream endpoint uses Server-Sent Events to deliver real-time progress during message processing. This is a POST endpoint (not GET) because it sends the user's message in the request body and streams the response.
Connection setup:
- The gateway sets SSE headers (
Content-Type: text/event-stream,Cache-Control: no-cache,Connection: keep-alive,X-Accel-Buffering: no). - A unique NATS progress subject (
_PROGRESS.{uuid}) is created for this request. - The gateway subscribes to the progress subject, then sends the NATS request to Rust Core with the
progress_subjectfield set. - Rust Core publishes
FlowProgressEventmessages ({event, data, seq}) to the progress subject during processing. - The gateway forwards each event to the SSE stream.
Event types and their data payloads:
| Event | Data | When |
|---|---|---|
user_saved | {user_message} | User message persisted to DB (replaces optimistic message) |
thinking | {status} | Agent is processing (e.g. "Thinking...") |
flow_started | {flow_name, execution_id} | A flow execution has begun |
node_started | {node_id, node_type, label} | A flow node started executing |
node_completed | {node_id, output_preview, duration_ms} | A flow node finished successfully |
node_failed | {node_id, error} | A flow node failed |
flow_completed | {} | Flow execution completed |
flow_failed | {error} | Flow execution failed |
assistant_message | {user_message, assistant_message, conversation} | Final response with both persisted messages and updated conversation |
error | {message} | An error occurred |
done | {} | Stream is complete, client should close connection |
Wire format (standard SSE):
event: thinking
data: {"status":"Thinking..."}
event: flow_started
data: {"flow_name":"Intake Triage","execution_id":"abc-123"}
event: node_started
data: {"node_id":"node-1","node_type":"Agent","label":"Classify Request"}
event: node_completed
data: {"node_id":"node-1","output_preview":"Category: Research","duration_ms":1200}
event: assistant_message
data: {"user_message":{...},"assistant_message":{...},"conversation":{...}}
event: done
data: {}
Timeouts and fallbacks:
- The NATS request has a 240-second timeout (flow executor 180s + follow-up LLM ~30s + buffer).
- The gateway has a 250-second overall safety timeout on the SSE stream.
- If the progress events do not deliver an
assistant_messagebefore the NATS reply arrives, the gateway uses the NATS reply as a fallback and writes it as anassistant_messageSSE event. - If the SSE stream fails entirely, the frontend falls back to the non-streaming
POST /conversations/:id/messagesendpoint.
Message Rating
Users can rate individual assistant messages with thumbs up (+1) or thumbs down (-1). Ratings are stored directly on the conversation_messages row.
- Endpoint:
POST /conversations/:id/messages/:messageId/rate - Body:
{ "rating": 1 }or{ "rating": -1 } - NATS subject:
qadra.{tenant_id}.chat.do.rate_message - Constraint:
ratingcolumn isSMALLINT CHECK (rating IN (-1, 1)). Only-1and1are valid. - Idempotent: Rating the same message again overwrites the previous rating.
- Index:
idx_conversation_messages_ratingenables efficient queries on rated messages for feedback analysis and model evaluation.
Conversation Lifecycle
Create -- POST /conversations
Creates an empty conversation. Title is optional (can be auto-generated later from the first message).
Send message -- POST /conversations/:id/messages (non-streaming) or POST /conversations/:id/messages/stream (SSE)
Sends a user message and receives the agent's response. The request body includes agent_id (required), content (required, max 100,000 characters), and optionally flow_execution_id. Both endpoints persist the user message and assistant message, returning them along with the updated conversation record.
List conversations -- GET /conversations?limit=20&offset=0
Returns ConversationSummary objects with message_count, last_message_preview, and last_agent_id for sidebar display. Excludes soft-deleted conversations.
Get conversation -- GET /conversations/:id
Returns the full conversation record.
List messages -- GET /conversations/:id/messages?limit=100&offset=0
Returns messages ordered by created_at. Used on page load and as a recovery mechanism when SSE streaming fails.
Update title -- PATCH /conversations/:id
Updates the conversation title.
Delete -- DELETE /conversations/:id
Soft-deletes the conversation by setting deleted_at. The conversation and its messages are preserved for audit but excluded from all listing queries.
NATS Subjects
All conversation operations go through tenant-scoped NATS subjects:
| Operation | NATS Subject |
|---|---|
| Create | qadra.{tid}.chat.do.create |
| Get | qadra.{tid}.chat.do.get |
| List | qadra.{tid}.chat.do.list |
| Update title | qadra.{tid}.chat.do.update_title |
| Delete | qadra.{tid}.chat.do.delete |
| Send message | qadra.{tid}.chat.do.send_message |
| List messages | qadra.{tid}.chat.do.list_messages |
| Rate message | qadra.{tid}.chat.do.rate_message |
Key Design Decisions
agent_idon messages, not conversations: Users can switch agents mid-conversation. One message might come from the Research Analyst, the next from the Due Diligence Specialist. The conversation itself is agent-agnostic.flow_execution_idon messages: Messages can be produced by flow executions, linking conversational output to the full flow execution record with its node states and blackboard data.- Soft delete with
deleted_at: Conversations are never hard-deleted. Settingdeleted_atexcludes them from listing queries while preserving the audit trail. metadataJSONB bag: Carriesartifacts(inline content blocks like charts or agent-produced documents) andsuggested_actions(follow-up prompts). Extensible without schema migration.- SSE over WebSocket for streaming: SSE is simpler to implement (one-way server-to-client), works through proxies/CDNs, and naturally maps to the request-response pattern of sending a message and streaming the response. WebSocket would be overkill for this use case.
Document Ingestion
Documents go through a human-gated approval flow before knowledge ingestion. Agents can propose documents, but humans must approve before ingestion runs.
Document Schema
CREATE TABLE documents (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id),
title TEXT,
content TEXT NOT NULL,
content_type TEXT DEFAULT 'text/plain',
source_url TEXT,
source_type TEXT, -- 'upload', 'web', 'api', 'research'
-- Ingestion lifecycle
ingestion_status TEXT DEFAULT 'pending_approval',
ingestion_result JSONB,
-- Proposal gate
proposed_by UUID REFERENCES users(id), -- Who submitted for review
approved_by UUID REFERENCES users(id), -- Human who opened the gate
approved_at TIMESTAMPTZ,
rejected_by UUID REFERENCES users(id), -- Who rejected (if rejected)
rejected_at TIMESTAMPTZ,
rejection_reason TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
processed_at TIMESTAMPTZ
);
Document Lifecycle
pending_approval --> approved --> processing --> completed
--> failed
pending_approval --> rejected
| Status | Meaning |
|---|---|
pending_approval | Proposed but awaiting human review |
approved | Human approved, ready for ingestion |
processing | Ingestion pipeline running (routing, extraction, embedding) |
completed | Triples and/or chunks created |
failed | Ingestion error |
rejected | Human rejected the proposal |
Why human-gated? Agents can autonomously discover and propose documents for ingestion, but a human must approve before content enters the knowledge graph. This prevents polluting the KG with low-quality or irrelevant content.
Ingestion Routing
Once approved, the ingestion pipeline routes content:
| Route | Action | When |
|---|---|---|
| SPO | Extract triples only | Short structured content (facts, definitions) |
| Vector | Chunk and embed only | Long narrative (articles, reports) |
| Both | Extract triples AND chunk | Mixed content |
| Discard | Skip (boilerplate, junk) | Low-value content |
Results link back to the document:
document_triples: Which SPO triples came from this documentdocument_chunks: Which vector chunks came from this document
Users and Authentication
User Schema
CREATE TABLE users (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id), -- Legacy, kept for backward compat
email TEXT NOT NULL,
email_verified BOOLEAN NOT NULL DEFAULT false,
password_hash TEXT NOT NULL,
name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
avatar_url TEXT,
is_super_admin BOOLEAN NOT NULL DEFAULT false,
settings JSONB DEFAULT '{}',
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
Multi-Tenant Memberships
CREATE TABLE user_tenants (
user_id UUID NOT NULL REFERENCES users(id),
tenant_id UUID NOT NULL REFERENCES tenants(id),
role TEXT NOT NULL DEFAULT 'user', -- 'owner', 'admin', 'user'
is_default BOOLEAN NOT NULL DEFAULT false,
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, tenant_id)
);
- Globally unique email: One account per email, belonging to multiple tenants
- Per-tenant roles:
owner(immutable),admin,user is_default: Which tenant loads on login- Registration: Atomic transaction creates tenant + user + user_tenants in one operation
Email Verification
CREATE TABLE email_verification_tokens (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id),
token TEXT NOT NULL UNIQUE, -- 32 random bytes, hex-encoded (64 chars)
expires_at TIMESTAMPTZ NOT NULL, -- 24h expiry
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
- Token validated + not used + not expired -> mark
used_at, setemail_verified = true - Post-registration: fire-and-forget async task (doesn't block registration response)
- Resend: invalidates existing tokens, creates new, sends new email
Team Invites
CREATE TABLE team_invites (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id),
email TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
invited_by UUID NOT NULL REFERENCES users(id),
token TEXT NOT NULL UNIQUE, -- 32 random bytes, hex-encoded, 7-day expiry
expires_at TIMESTAMPTZ NOT NULL,
accepted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Smart invite logic: If a user with the email already exists globally, they are added to the tenant directly. If not, an invite is created and an email is sent. Only owner/admin can invite, change roles, or remove members.
Email Templates
CREATE TABLE email_templates (
id UUID PRIMARY KEY,
tenant_id UUID REFERENCES tenants(id), -- NULL = global default
slug TEXT NOT NULL, -- 'verification', 'team_invite', etc.
name TEXT NOT NULL,
subject TEXT NOT NULL, -- Handlebars: 'Verify your email, {{user_name}}'
html_body TEXT NOT NULL, -- Handlebars HTML template
text_body TEXT, -- Handlebars plain text fallback
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(tenant_id, slug)
);
- Template resolution: Tenant-specific wins over global (
ORDER BY tenant_id IS NULL ASC LIMIT 1) - Handlebars rendering:
{{var}}syntax for subject, html_body, text_body - Tenant overrides: Tenants can customize any global template by slug
Super Admin
The is_super_admin boolean on the users table is a platform-level privilege:
| Capability | Description |
|---|---|
| Create tenants | Create tenant + assign owner |
| Drop into any tenant | Synthetic JWT with target tenant context (bypasses membership) |
| Reset passwords | Force-reset any user's password |
| List all tenants/users | Platform-wide visibility |
| Promote/demote | Grant or revoke super admin (cannot self-promote/demote) |
The first super admin is promoted via direct SQL. JWT carries sa: true claim when present.
Files
File metadata is stored in PostgreSQL. Actual blobs live in S3-compatible object storage (MinIO by default, tenant-configurable).
CREATE TABLE files (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id),
uploaded_by UUID NOT NULL REFERENCES users(id),
storage_provider TEXT NOT NULL DEFAULT 'internal', -- 'internal' (MinIO) or 's3'
bucket TEXT NOT NULL DEFAULT 'qadra-files',
object_key TEXT NOT NULL,
filename TEXT NOT NULL,
content_type TEXT NOT NULL,
size_bytes BIGINT NOT NULL,
purpose TEXT NOT NULL, -- 'avatar', 'artifact', 'logo', 'attachment'
entity_type TEXT, -- Polymorphic: 'user', 'tenant', 'workload'
entity_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
- Gateway-proxied downloads: URLs are always
/api/files/{id}/download, never direct S3 links - Image processing: Avatar uploads auto-resized via imaginary (HTTP service) to 256x256 WebP
- Purpose-based validation:
avatar/logohave 2MB limit + image-only types; others 10MB default - Object key format:
tenants/{tenant_id}/{purpose}/{entity_id}{ext}
Notification Logs
Every notification sent by the platform is automatically logged. Multi-channel from day one.
CREATE TABLE notification_logs (
id UUID PRIMARY KEY,
tenant_id UUID REFERENCES tenants(id),
channel TEXT NOT NULL DEFAULT 'email', -- 'email', future: 'sms', 'push'
recipient TEXT NOT NULL,
template_slug TEXT,
subject TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'sent', -- 'sent', 'failed'
error TEXT,
provider_id TEXT, -- SMTP2GO email_id, future: Twilio SID
triggered_by TEXT, -- 'registration', 'verification', 'team_invite', etc.
user_id UUID REFERENCES users(id),
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Logging is transparent via the LoggingEmailService decorator pattern. All callers of EmailService::send() get automatic audit logging without code changes. Fire-and-forget via tokio::spawn to avoid blocking delivery.
Multitenancy Model
Each tenant is fully isolated with its own resources:
Example Tenant: Acme Capital
| Resource | Items | Details |
|---|---|---|
| Users | 5 | 1 owner, 1 admin, 3 users (via user_tenants) |
| API Keys | 2 | qk_live_abc... (admin), qk_live_xyz... (read-only) |
| Agents | 4 | research-analyst, due-diligence-specialist, memo-writer, reviewer |
| Pipelines | 1 | deal-pipeline: Research -> DD -> Memo -> Review |
| Workloads | 2 | Acme Corp Acquisition (4 tasks, 3 artifacts), Beta Inc Analysis (2 tasks, 1 artifact) |
| Flows | 1 | Intake triage flow (8 nodes, 3 executions) |
| Conversations | 12 | Direct agent chats by team members |
| Knowledge Graph | -- | 1,234 SPO nodes, 5,678 triples, 89 documents (43 approved, 6 pending) |
| Files | 15 | 5 avatars, 2 logos, 8 attachments |
| Email Templates | 3 | Custom verification, invite, and welcome templates |
Row-level security enforced at the database layer. The user_tenants junction table determines which tenants a user can access.
Example: Investment Banking Pipeline
Putting it all together with a concrete example:
Pipeline: "Deal Pipeline" with 4 stages
Agents:
- Research Analyst: Gathers market data, competitive analysis (
system_promptfocused on sourcing and synthesis) - Due Diligence Specialist: Validates claims, checks risks (
system_promptfocused on verification and risk assessment) - Memo Writer: Synthesizes findings into investment memo (
system_promptfocused on clear financial writing) - Reviewer: Final quality check (
system_promptfocused on completeness and accuracy)
Workload: "Acme Corp Acquisition Analysis"
Stage 1: Research
-> Research Analyst executes with forwarded context: {}
-> Output: { market_size: "$50B", competitors: [...], key_trends: [...] }
-> Artifact: "Acme Corp Market Analysis" (markdown)
-> stage_results["Research"] recorded
Stage 2: Due Diligence
-> Due Diligence Specialist executes with forwarded context from Research
-> Output: { risk_score: 0.3, red_flags: [], verified_claims: 12 }
-> Artifact: "Acme Corp Risk Assessment" (markdown)
-> stage_results["Due Diligence"] recorded
Stage 3: Investment Memo
-> Memo Writer executes with forwarded context from Research + DD
-> Output: { recommendation: "proceed", confidence: 0.85 }
-> Artifact: "Acme Corp Investment Memo" (pdf, via artifact template + renderer)
-> stage_results["Investment Memo"] recorded
Stage 4: Review
-> Reviewer executes with forwarded context from all prior stages
-> Output: { approved: true, comments: "Strong analysis, proceed to IC" }
-> Artifact: "Review Sign-off" (markdown)
-> Workload status -> completed
Each stage builds on the structured output of previous stages. The artifact template for the investment memo ensures consistent formatting across all deals.
Qadra Database ERD
42 tables across 8 domains. Each diagram renders independently.
Core: Tenants & Users
erDiagram
tenants {
UUID id PK
TEXT name
TEXT slug
JSONB settings
}
users {
UUID id PK
UUID tenant_id FK
TEXT email
TEXT name
TEXT role
BOOLEAN is_super_admin
BOOLEAN email_verified
}
user_tenants {
UUID user_id PK
UUID tenant_id PK
TEXT role
BOOLEAN is_default
}
api_keys {
UUID id PK
UUID tenant_id FK
TEXT key_hash
TEXT name
}
refresh_tokens {
UUID id PK
UUID user_id FK
TEXT token_hash
}
email_verification_tokens {
UUID id PK
UUID user_id FK
TEXT token
}
team_invites {
UUID id PK
UUID tenant_id FK
UUID invited_by FK
TEXT email
TEXT token
}
files {
UUID id PK
UUID tenant_id FK
UUID uploaded_by FK
TEXT object_key
TEXT purpose
}
email_templates {
UUID id PK
UUID tenant_id FK
TEXT slug
TEXT subject
}
notification_logs {
UUID id PK
UUID tenant_id FK
TEXT channel
TEXT recipient
TEXT status
}
tenants ||--o{ users : has
tenants ||--o{ user_tenants : members
users ||--o{ user_tenants : belongs_to
tenants ||--o{ api_keys : has
users ||--o{ refresh_tokens : has
users ||--o{ email_verification_tokens : has
tenants ||--o{ team_invites : has
users ||--o{ team_invites : invited_by
tenants ||--o{ files : has
users ||--o{ files : uploaded_by
tenants ||--o{ email_templates : has
tenants ||--o{ notification_logs : has
Agents, Pipelines & Workloads
erDiagram
agents {
UUID id PK
UUID tenant_id FK
TEXT name
TEXT display_name
TEXT system_prompt
TEXT control_type
BOOLEAN is_active
}
pipelines {
UUID id PK
UUID tenant_id FK
TEXT name
JSONB stages
JSONB draft_stages
}
workloads {
UUID id PK
UUID tenant_id FK
UUID pipeline_id FK
TEXT title
TEXT status
TEXT current_stage
JSONB stage_results
JSONB pipeline_snapshot
}
tasks {
UUID id PK
UUID workload_id FK
UUID agent_id FK
TEXT stage
TEXT status
INTEGER progress
JSONB output
}
artifacts {
UUID id PK
UUID workload_id FK
UUID task_id FK
UUID agent_id FK
UUID template_id FK
TEXT name
TEXT artifact_type
}
usage_records {
UUID id PK
UUID tenant_id FK
UUID workload_id FK
UUID task_id FK
UUID agent_id FK
TEXT operation_type
INTEGER tokens_in
INTEGER tokens_out
}
artifact_templates {
UUID id PK
UUID tenant_id FK
TEXT name
TEXT output_format
UUID renderer_id FK
}
pipelines ||--o{ workloads : uses
workloads ||--o{ tasks : has
agents ||--o{ tasks : assigned_to
workloads ||--o{ artifacts : produces
tasks ||--o{ artifacts : produces
agents ||--o{ artifacts : created_by
artifact_templates ||--o{ artifacts : template_for
workloads ||--o{ usage_records : tracks
Knowledge Graph (SPO)
erDiagram
spo_nodes {
UUID id PK
UUID tenant_id FK
TEXT value
TEXT node_type
VECTOR embedding
JSONB content
FLOAT confidence
}
spo_triples {
UUID id PK
UUID tenant_id FK
UUID subject_id FK
UUID predicate_id FK
UUID object_id FK
FLOAT confidence
TEXT asserter
TEXT assertion_type
}
entity_aliases {
UUID id PK
UUID tenant_id FK
TEXT surface_form
UUID canonical_node_id FK
TEXT entity_type
}
documents {
UUID id PK
UUID tenant_id FK
TEXT title
TEXT content
TEXT ingestion_status
JSONB ingestion_result
UUID proposed_by FK
UUID approved_by FK
TIMESTAMPTZ approved_at
UUID rejected_by FK
}
document_triples {
UUID document_id PK
UUID triple_id PK
FLOAT confidence
TEXT excerpt
}
document_chunks {
UUID document_id PK
UUID chunk_id PK
INTEGER chunk_index
INTEGER start_offset
}
spo_nodes ||--o{ spo_triples : subject
spo_nodes ||--o{ spo_triples : predicate
spo_nodes ||--o{ spo_triples : object
spo_nodes ||--o{ entity_aliases : canonical
documents ||--o{ document_triples : extracted
spo_triples ||--o{ document_triples : from
documents ||--o{ document_chunks : chunked
Attribution (KG-SMILE)
erDiagram
attribution_sessions {
UUID id PK
UUID tenant_id FK
UUID workload_id FK
TEXT query
BOOLEAN competence_passed
FLOAT spo_coverage
BOOLEAN verification_passed
FLOAT fidelity
FLOAT faithfulness
FLOAT stability
}
triple_attributions {
UUID id PK
UUID session_id FK
UUID triple_id FK
FLOAT attribution_score
FLOAT weight
}
perturbation_results {
UUID id PK
UUID session_id FK
FLOAT graph_similarity
FLOAT response_similarity
}
spo_component_groups {
UUID id PK
UUID session_id FK
TEXT group_name
}
conflict_events {
UUID id PK
UUID tenant_id FK
TEXT new_subject
TEXT new_predicate
TEXT new_object
UUID existing_triple_id FK
TEXT severity
TEXT reason
}
attribution_sessions ||--o{ triple_attributions : has
attribution_sessions ||--o{ perturbation_results : has
attribution_sessions ||--o{ spo_component_groups : has
Flows & Conversations
erDiagram
agent_flows {
UUID id PK
UUID tenant_id FK
TEXT name
JSONB flow_data
INTEGER node_count
UUID created_by FK
BOOLEAN is_active
}
flow_executions {
UUID id PK
UUID tenant_id FK
UUID flow_id FK
JSONB flow_snapshot
TEXT status
JSONB state
JSONB node_states
UUID triggered_by FK
}
conversations {
UUID id PK
UUID tenant_id FK
UUID user_id FK
TEXT title
}
conversation_messages {
UUID id PK
UUID conversation_id FK
UUID agent_id FK
UUID flow_execution_id FK
TEXT role
TEXT content
}
agent_flows ||--o{ flow_executions : runs
conversations ||--o{ conversation_messages : has
flow_executions ||--o{ conversation_messages : produced_by
BALLS Graphs
erDiagram
graphs {
UUID id PK
UUID tenant_id FK
TEXT name
JSONB metadata
}
vertices {
UUID id PK
UUID graph_id FK
FLOAT x
FLOAT y
FLOAT z
TEXT label
FLOAT curvature
}
edges {
UUID id PK
UUID graph_id FK
UUID source_id FK
UUID target_id FK
FLOAT weight
FLOAT curvature
}
graphs ||--o{ vertices : has
graphs ||--o{ edges : has
vertices ||--o{ edges : source
vertices ||--o{ edges : target
Plugins & Renderers
erDiagram
plugins {
UUID id PK
UUID tenant_id FK
TEXT plugin_type
TEXT name
INTEGER version
TEXT code
BOOLEAN is_active
BOOLEAN is_global
}
renderers {
UUID id PK
UUID tenant_id FK
TEXT name
INTEGER version
TEXT output_format
TEXT code
BOOLEAN is_active
BOOLEAN is_global
}
memory {
UUID id PK
UUID tenant_id FK
TEXT key
JSONB value
VECTOR embedding
}
sessions {
UUID id PK
UUID tenant_id FK
TEXT session_type
}
usage {
UUID id PK
UUID tenant_id FK
UUID agent_id FK
UUID session_id FK
INTEGER tokens_in
INTEGER tokens_out
}
sessions ||--o{ usage : during
Knowledge Graph
Overview
Qadra uses a dual-storage approach for knowledge retrieval, combining the precision of graph databases with the flexibility of semantic search.
The core insight: Different types of knowledge need different retrieval methods.
- Facts ("Apple was founded in 1976") --- Exact lookup via SPO triples
- Concepts ("What are the risks of this acquisition?") --- Semantic similarity via vectors
| Storage | Purpose | Query Pattern | Best For |
|---|---|---|---|
| SPO Index | Structured facts | Pattern matching (SP?, ?PO, S?O) | Known entities, relationships, verification |
| Vector Store | Semantic retrieval | Embedding similarity (top-k) | Fuzzy matching, concept exploration, discovery |
Why not just vectors? Vector search is powerful but imprecise. Ask "When was Apple founded?" and you might get chunks discussing Apple's founding, but not necessarily the exact year. SPO gives you (Apple, founded_in, 1976) directly.
Why not just SPO? Triple stores require structured data. For narrative content, exploratory queries, or fuzzy concepts, vectors find relevant context that wouldn't match any predefined pattern.
SPO Index
What is SPO?
SPO stands for Subject-Predicate-Object --- the fundamental unit of knowledge representation used in semantic web and knowledge graph systems.
| Component | Description | Example |
|---|---|---|
| Subject | The entity being described | "Apple" |
| Predicate | The relationship | "founded_by" |
| Object | The target of the relationship | "Steve Jobs" |
Example Triple: (Apple, founded_by, Steve Jobs)
Why SPO over property graphs?
Property graphs (Neo4j, etc.) allow arbitrary properties on nodes and edges. SPO is more constrained --- everything is a triple. This constraint is a feature:
- Uniformity: All facts have the same shape, simplifying queries and storage
- Composability: Complex facts decompose into simple triples
- Interoperability: RDF/SPARQL compatibility if needed later
Example: Representing a complex fact
"Apple was founded by Steve Jobs in Cupertino in 1976"
Becomes multiple triples:
(Apple, founded_by, Steve Jobs)
(Apple, founded_in_location, Cupertino)
(Apple, founded_in_year, 1976)
(Steve Jobs, co_founded, Apple)
Each fact is independently queryable, updateable, and traceable to its source document.
Schema
-- Nodes store entities and relationships
CREATE TABLE spo_nodes (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id),
value TEXT NOT NULL,
node_type TEXT NOT NULL, -- 'subject', 'predicate', 'object'
embedding vector(1536), -- For semantic search
content JSONB, -- Rich metadata
source_document TEXT, -- Provenance
confidence REAL DEFAULT 1.0,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(tenant_id, value, node_type)
);
-- Triples connect nodes
CREATE TABLE spo_triples (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id),
subject_id UUID NOT NULL REFERENCES spo_nodes(id),
predicate_id UUID NOT NULL REFERENCES spo_nodes(id),
object_id UUID NOT NULL REFERENCES spo_nodes(id),
confidence REAL DEFAULT 1.0,
source TEXT, -- "document:uuid", "user:uuid"
asserter TEXT, -- Who made this claim
assertion_type TEXT, -- 'fact', 'claim', 'opinion'
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(tenant_id, subject_id, predicate_id, object_id)
);
-- All 6 SPO permutation indexes for O(1) lookups
CREATE INDEX idx_spo ON spo_triples(tenant_id, subject_id, predicate_id, object_id);
CREATE INDEX idx_sop ON spo_triples(tenant_id, subject_id, object_id, predicate_id);
CREATE INDEX idx_pso ON spo_triples(tenant_id, predicate_id, subject_id, object_id);
CREATE INDEX idx_pos ON spo_triples(tenant_id, predicate_id, object_id, subject_id);
CREATE INDEX idx_osp ON spo_triples(tenant_id, object_id, subject_id, predicate_id);
CREATE INDEX idx_ops ON spo_triples(tenant_id, object_id, predicate_id, subject_id);
-- HNSW index for embedding similarity on SPO nodes
CREATE INDEX idx_spo_nodes_embedding ON spo_nodes
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
Query Patterns
SPO supports six fundamental query patterns, all scoped by tenant_id:
| Pattern | Query | Returns |
|---|---|---|
| SP? | Subject + Predicate -> ? | Objects |
| ?PO | ? + Predicate + Object | Subjects |
| S?O | Subject + ? + Object | Predicates |
| S?? | Subject -> ? | Predicate+Object pairs |
| ?P? | ? + Predicate -> ? | Subject+Object pairs |
| ??O | ? -> Object | Subject+Predicate pairs |
Examples:
-- SP?: What is Apple's founding year?
-- (Apple, founded_in_year, ?) -> ["1976"]
SELECT o.value FROM spo_nodes s
JOIN spo_triples t ON t.subject_id = s.id
JOIN spo_nodes p ON t.predicate_id = p.id
JOIN spo_nodes o ON t.object_id = o.id
WHERE t.tenant_id = $1 AND s.value = 'Apple' AND p.value = 'founded_in_year';
-- ?PO: Who founded Apple?
-- (?, founded_by, Apple) -> ["Steve Jobs"]
SELECT s.value FROM spo_nodes s
JOIN spo_triples t ON t.subject_id = s.id
JOIN spo_nodes p ON t.predicate_id = p.id
JOIN spo_nodes o ON t.object_id = o.id
WHERE t.tenant_id = $1 AND p.value = 'founded_by' AND o.value = 'Apple';
-- S?O: What is the relationship between Apple and Steve Jobs?
-- (Apple, ?, Steve Jobs) -> ["founded_by", "co_founded_by"]
SELECT p.value FROM spo_nodes s
JOIN spo_triples t ON t.subject_id = s.id
JOIN spo_nodes p ON t.predicate_id = p.id
JOIN spo_nodes o ON t.object_id = o.id
WHERE t.tenant_id = $1 AND s.value = 'Apple' AND o.value = 'Steve Jobs';
Predicate Design
Good predicates are meaningful relationships:
| Good | Bad |
|---|---|
founded_by | research_finding |
is_located_in | key_point |
acquired | is_about |
has_feature | relates_to |
revenue_is | mentioned_in |
Rules:
- Predicates should be verbs or verb phrases
- Use snake_case:
is_located_in, notisLocatedIn - Be specific:
founded_bynotassociated_with - Avoid meta-predicates that describe the data, not relationships
The extraction pipeline enforces this with a junk-predicate filter that rejects is_about, key_point, research_finding, relates_to, has_aspect, mentioned_in, discussed, and noted.
Vector Store
pgvector with HNSW
All vector storage uses PostgreSQL pgvector with HNSW (Hierarchical Navigable Small World) indexes for fast approximate nearest-neighbor search.
CREATE TABLE embeddings (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
chunk_id UUID NOT NULL,
document_id UUID NOT NULL,
embedding vector(1536) NOT NULL,
content TEXT NOT NULL,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_embeddings_embedding ON embeddings
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
Two things get embedded:
- Document chunks: Paragraph-boundary text chunks (~500 chars) for narrative retrieval
- SPO node values: Subject/predicate/object values for semantic search on the knowledge graph itself
Semantic Search
#![allow(unused)] fn main() { let results = vector_index.search( tenant_id, query_embedding, k: 10, filter: None, ).await?; for hit in results { println!("{}: {:.3}", hit.content, hit.score); } }
SPO navigates structure; pgvector retrieves similar content. Standard RAG searches everything; SPO-RAG searches where SPO points.
Document Ingestion Pipeline
Human-Gated Design
Ingestion into the Knowledge Core is explicitly human-gated. Agents can surface matching documents and propose them for ingestion, but a human must confirm before any data is written to the SPO graph or vector store.
Why: Prevents accidental data co-mingling across tenants or knowledge domains. We never want unintended data polluting the Knowledge Core.
How it works:
- An agent discovers relevant content (via search, connector, or user upload)
- The agent proposes the document for ingestion --- it appears in a review queue
- A human reviews and approves (or rejects) the proposal
- Only after explicit confirmation does the ingestion pipeline run
No automatic or background ingestion of discovered content. The UI presents proposed documents for review, and only after explicit confirmation does data enter the system.
Pipeline Overview
Once a document is approved, the pipeline transforms raw content into queryable knowledge:
Approved Document
|
v
1. Record document (status='processing')
|
v
2. Content Analyzer (LLM routing: Spo / Both / Discard)
|
+--- Discard ---> Mark discarded, stop
|
v
3a. Triple Extractor (LLM, epistemic metadata, windowed for large docs)
| --> batch-create SPO nodes + triples
| --> embed SPO node values into HNSW index
|
3b. Text Chunker (if routing = Both)
| --> pure Rust, paragraph boundaries, ~500 chars
| --> batch embed chunks into pgvector
|
v
4. Provenance linking (document_triples, document_chunks)
|
v
5. Update document status='completed'
Key principle: SPO is always the index. Every non-discard document gets SPO triples extracted. There is no "vector-only" category. The Both routing adds vector chunks on top of SPO extraction for documents that also benefit from narrative retrieval.
Implementation: src/ingestion/ module, orchestrated by IngestionPipeline (implements the IngestionService trait).
Content Analyzer
The analyzer determines how to route content. It uses an LLM classifier with a heuristic fallback.
Routing categories:
| Routing | Content Type | Action | Example |
|---|---|---|---|
Spo | Short, structured facts | Extract triples only | "Apple was founded in 1976 by Steve Jobs." |
Both | Mixed or long content | Extract triples AND chunk for vectors | News article with facts and commentary |
Discard | Junk content | Skip storage | Cookie notices, navigation menus, ads |
There is no "vector-only" routing. If the LLM returns "vector", the analyzer upgrades it to "both" --- SPO extraction always runs on non-junk content.
LLM classification: The analyzer sends a 2000-character sample to a haiku-tier model with a routing prompt. The LLM returns JSON with routing, has_extractable_facts, estimated_chunks, confidence, and reasoning.
Heuristic fallback: When the LLM is unavailable or returns unparseable output, the analyzer falls back to length-based heuristics:
- Content < 500 chars -->
Spo(SPO extraction only) - Content >= 500 chars -->
Both(SPO + vector chunks)
Implementation: src/ingestion/analyzer.rs
Triple Extraction
The extractor uses an LLM to pull Subject-Predicate-Object triples from text, including epistemic metadata on every triple.
Extraction prompt rules:
- Use meaningful predicates:
founded_by,is_located_in,acquired,revenue_is - Reject junk predicates:
is_about,key_point,research_finding,relates_to - Each triple must represent a concrete, verifiable relationship
- Include assertion type:
fact,claim, oropinion - Include the source excerpt for provenance
Windowed extraction for large documents: When content exceeds 2000 characters, the pipeline chunks it into extraction windows (~2000 chars with 100-char overlap) and runs extraction on each window independently. Results are deduplicated by (subject, predicate, object), keeping the highest-confidence version.
Post-extraction filtering:
- Triples below 0.5 confidence are discarded
- Triples with junk predicates are discarded
- Remaining triples are batch-created as SPO nodes and triples in PostgreSQL
- Subject node values are embedded into the HNSW index for semantic search on the KG itself
Example extraction:
Input: "Acme Corporation was founded in 1990 by John Smith.
The company is headquartered in San Francisco."
Output:
[
{
"subject": "Acme Corporation",
"predicate": "founded_in",
"object": "1990",
"confidence": 0.95,
"assertion_type": "fact",
"excerpt": "Acme Corporation was founded in 1990"
},
{
"subject": "Acme Corporation",
"predicate": "founded_by",
"object": "John Smith",
"confidence": 0.95,
"assertion_type": "fact",
"excerpt": "Acme Corporation was founded in 1990 by John Smith"
},
{
"subject": "Acme Corporation",
"predicate": "headquartered_in",
"object": "San Francisco",
"confidence": 0.90,
"assertion_type": "fact",
"excerpt": "The company is headquartered in San Francisco"
}
]
Implementation: src/ingestion/extractor.rs
Text Chunking
Large content routed as Both gets split into overlapping chunks for vector embedding. The chunker is pure Rust --- no LLM calls.
Algorithm:
- Target chunk size: ~500 characters
- Overlap: ~50 characters (re-includes the tail of the previous chunk)
- Split on paragraph boundaries (double newlines)
- Each chunk tracks
index,start_offset, andend_offsetfor provenance
The chunker accumulates paragraphs until the target size is exceeded, then flushes. Overlap is achieved by carrying the last paragraph(s) from the previous chunk into the next, up to the overlap budget.
Implementation: src/ingestion/chunker.rs
Embedding
The embedder handles two kinds of embedding in batch:
- Document chunks -->
embed_and_store_chunks: Batch-embeds chunk content, upsertsEmbeddingRecordentries into the vector index with metadata (chunk_index, start_offset, end_offset) - SPO node values -->
embed_spo_nodes: Batch-embeds subject node values, updates theirembeddingcolumn inspo_nodesviaVectorSearchRepository
Both operations use the EmbeddingProvider trait, which can be backed by OpenAI, local models, or any provider implementing the trait.
Implementation: src/ingestion/embedder.rs
Provenance Linking
All ingested content maintains full provenance --- every triple and every chunk links back to its source document.
-- Source documents
CREATE TABLE documents (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
title TEXT,
content TEXT NOT NULL,
content_type TEXT,
source_url TEXT,
source_type TEXT, -- 'upload', 'web', 'api', 'research'
ingestion_status TEXT, -- 'pending', 'processing', 'completed', 'discarded'
ingestion_result JSONB, -- Routing decision, counts, analysis
created_at TIMESTAMPTZ,
processed_at TIMESTAMPTZ
);
-- Links documents to extracted triples
CREATE TABLE document_triples (
document_id UUID REFERENCES documents(id),
triple_id UUID REFERENCES spo_triples(id),
confidence REAL,
excerpt TEXT, -- Source text the triple was extracted from
PRIMARY KEY (document_id, triple_id)
);
-- Links documents to vector chunks
CREATE TABLE document_chunks (
document_id UUID REFERENCES documents(id),
chunk_id UUID,
chunk_index INTEGER,
start_offset INTEGER,
end_offset INTEGER,
PRIMARY KEY (document_id, chunk_id)
);
This enables:
- Tracing any triple back to its source document and excerpt
- Tracing any vector chunk back to its document and character offsets
- Answering "where did this fact come from?" for audit and compliance
Chain-of-Custody Provenance
Provenance linking answers which document a triple came from. Chain-of-custody extends this to a complete, queryable audit trail: every SPO triple resolves to its originating document along with when the source was published and when Qadra retrieved it.
Migration 035 adds custody columns:
-- On spo_triples: where this triple came from and when it was captured
ALTER TABLE spo_triples ADD COLUMN source_uri TEXT;
ALTER TABLE spo_triples ADD COLUMN retrieval_timestamp TIMESTAMPTZ;
-- On documents: when the source was published vs. when we ingested it
ALTER TABLE documents ADD COLUMN publication_date TIMESTAMPTZ;
ALTER TABLE documents ADD COLUMN retrieval_timestamp TIMESTAMPTZ;
The TripleProvenance type resolves the full chain in a single indexed join --- triple to its document, carrying title, source URL, publication date, retrieval timestamp, and the source excerpt:
#![allow(unused)] fn main() { pub struct TripleProvenance { pub triple_id: Uuid, pub source_uri: Option<String>, pub retrieval_timestamp: Option<DateTime<Utc>>, pub document_title: Option<String>, pub document_source_url: Option<String>, pub publication_date: Option<DateTime<Utc>>, pub document_retrieval_timestamp: Option<DateTime<Utc>>, pub excerpt: Option<String>, } }
NATS subject: qadra.{tid}.epistemic.trace_triple --- resolves the custody chain for a single triple. The single-join design keeps resolution under a 100 ms budget.
This answers not just "where did this fact come from?" but "how old is the source, and when did we last see it?" --- essential for time-sensitive financial knowledge where a stale figure is worse than no figure.
Claim Trace
Where chain-of-custody traces a single triple, claim trace runs the inverse direction for investment-committee (IC) sign-off: given a free-text claim from a memo (e.g. "Acme's 2024 revenue was $391B"), trace it back to the supporting SPO triples --- with their asserter and source --- and the source documents, including the excerpt and full content.
Flow: two indexed queries plus a reverse document join.
- SPO node search --- match the claim text against SPO node values (subject/object) to find candidate nodes
- Triple lookup --- fetch triples involving those nodes, carrying
asserterandassertion_type - Reverse document join --- walk
document_triplesback todocumentsfor each supporting triple, returning excerpt + content
The whole trace is budgeted under 500 ms (a 450 ms internal timeout guards the budget; partial results are returned rather than failing the IC review).
| Surface | Value |
|---|---|
| NATS subject | qadra.{tid}.epistemic.trace_claim |
| HTTP endpoint | POST /knowledge/trace |
The response groups supporting triples (with asserter + source) under the claim and links each to its source documents (with excerpt + content), so a reviewer can verify a memo statement against the underlying knowledge in one round trip.
Epistemic Metadata
Not All Knowledge Is Equal
A fact from a regulatory filing differs from an opinion in a blog post. Qadra tracks who made a claim and its epistemic status on every SPO triple.
Assertion Types
| Type | Definition | Example |
|---|---|---|
| Fact | Verifiable through multiple independent sources, widely accepted as true | (Apple, founded_in_year, 1976) --- asserter: SEC filings |
| Claim | Stated by a source but not universally verified, may be contested | (NVIDIA, will_reach, $5T_market_cap) --- asserter: Morgan Stanley |
| Opinion | Subjective assessment or preference, varies by individual | (NVIDIA, is, overvalued) --- asserter: Jim Cramer |
The default assertion type is Claim (conservative). If the extractor is uncertain, it defaults to claim rather than fact.
Schema
Two columns on spo_triples:
ALTER TABLE spo_triples ADD COLUMN asserter TEXT;
ALTER TABLE spo_triples ADD COLUMN assertion_type TEXT
CHECK (assertion_type IN ('fact', 'claim', 'opinion'));
Extraction
The triple extraction prompt explicitly asks the LLM to classify each triple's assertion type. The ExtractedTriple struct carries this through the pipeline:
#![allow(unused)] fn main() { pub struct ExtractedTriple { pub subject: String, pub predicate: String, pub object: String, pub confidence: f32, pub assertion_type: Option<AssertionType>, // fact, claim, or opinion pub excerpt: Option<String>, } }
Query Filtering
Epistemic metadata enables filtering by trust level:
#![allow(unused)] fn main() { // Get only verified facts let facts = repository .query_triples(tenant_id) .where_assertion_type(AssertionType::Fact) .fetch_all() .await?; // Get claims from a specific analyst let analyst_claims = repository .query_triples(tenant_id) .where_asserter("Goldman Sachs") .where_assertion_type(AssertionType::Claim) .fetch_all() .await?; }
Claim Classification
Beyond the three-way assertion type (fact, claim, opinion), the ClaimClassifier in qadra-epistemic-core provides fine-grained classification of assertions into six epistemic categories.
ClaimType enum:
| ClaimType | Maps to AssertionType | Description |
|---|---|---|
VerifiedFact | Fact | Multi-source corroborated (multiple fact indicators like "confirmed", "verified", "documented") |
StatedFact | Claim | Single authoritative source (e.g., press release, one fact indicator) |
Projection | Opinion | Forward-looking statement ("expects", "forecast", "guidance", "outlook") |
ThirdPartyAssessment | Claim | External evaluation ("rated", "scored", "analyst", "benchmark") |
Opinion | Opinion | Subjective assessment ("I believe", "seems", "likely", "probably") |
Derived | Claim | Computed from other facts |
How classification works:
The classifier uses pre-compiled regex indicator patterns (word-boundary matching, inflection-aware) across four categories: projection, opinion, assessment, and fact. For each input text:
- Score the text against each indicator set (count of matches / total indicators in set)
- Apply priority ordering: projection > opinion > assessment > fact
- The first category exceeding a 0.05 score threshold wins
- Confidence is computed as
0.6 + (score * 0.4), capped at 1.0 - Multiple fact indicators (score > 0.15) promote
StatedFacttoVerifiedFact - If no indicators match, default to
StatedFactat 0.5 confidence
Response-level classification:
classify_response splits a full response into sentences (handling abbreviations like "Dr.", "U.S.", "e.g.") and classifies each sentence independently. This enables per-claim epistemic tracking within a single agent output.
Integration with groundedness validation:
The ClaimType maps to AssertionType for storage via to_assertion_type(). This bridges the fine-grained classification (useful for analysis and debugging) with the storage-level three-way system. During groundedness validation, claim classification helps distinguish which parts of a response need strict fact-checking (verified facts) versus which are inherently subjective (opinions, projections).
Customization:
The classifier supports custom indicator lists via builder methods (with_projection_indicators, with_opinion_indicators) for domain-specific vocabulary. For example, an investment banking pipeline might add financial projection indicators like "DCF implies" or "base case".
Implementation: crates/qadra-epistemic-core/src/claims.rs
Confidence Weighting
Epistemic status feeds into synthesis context weighting:
| Assertion Type | Weight |
|---|---|
| Fact | 1.0 |
| Claim | 0.7 |
| Opinion | 0.4 |
In synthesis prompts, facts and claims are separated:
VERIFIED FACTS:
(Apple, founded_in_year, 1976) [asserter: SEC filings]
ANALYST CLAIMS (verify independently):
(NVIDIA, will_reach, $5T_market_cap) [asserter: Morgan Stanley]
KG-SMILE Attribution
The Two Gates Model
Every agent response passes through two gates that determine whether the system has the competence to answer and whether the answer stayed grounded in known facts.
Query --> Extract Entities --> Competence Gate --> Synthesis --> Verification Gate --> Response
| |
"Should I attempt?" "Did I stay grounded?"
Based on the KG-SMILE paper --- explainable GraphRAG attribution.
Gate 1: Competence (Pre-Synthesis)
Question: "Does my knowledge domain cover this query?"
Before the agent synthesizes a response, the competence gate checks whether the SPO graph has meaningful coverage of the query's entities.
#![allow(unused)] fn main() { CompetenceResult { passed: bool, entities: Vec<String>, // Extracted from query coverage: f32, // Fraction with SPO triples (0-1) connectivity: f32, // How connected in SPO graph (0-1) relevant_triples: Vec<Uuid>, } }
Thresholds:
min_coverage: 0.3--- At least 30% of query entities must have triplesmin_connectivity: 0.2--- At least 20% of entity pairs must be connected
Failure modes:
- "Insufficient SPO coverage" --- Query about an unknown domain
- "Insufficient connectivity" --- Entities exist but aren't related in the graph
If the competence gate fails, the system can decline to answer, trigger research (see 5.9), or return a response with a low-confidence warning.
Gate 2: Verification (Post-Synthesis)
Question: "Does my output's semantic structure exist in my knowledge domain?"
After synthesis, the verification gate checks whether the response's implied facts are grounded in the SPO graph. Qadra ships three verifier implementations with different cost/precision trade-offs. Two of them — both LLM-free — run on every chat response in production; the perturbation-based attribution path is implemented but not yet wired into the live pipeline.
| Verifier | Mechanism | LLM calls? | Status |
|---|---|---|---|
Rule-based grounding (verify_response_rule_based) | Sentence parse + predicate-keyword detection + word-overlap against context triples | No | Production — Step 10.5 of send_message |
Belnap four-valued (verify_with_belnap) | UD-parse the response, ground each claim against a polarity-tagged SPO graph, reconcile over the Belnap lattice | No (pure Rust; optional ONNX UD parser) | Production — Step 10.6 of send_message |
Perturbation attribution (verify_response) | Curvature-guided perturbation + surrogate regression → per-triple attribution scores | Yes (one re-synthesis per perturbation) | Future / higher-precision — implemented, not yet on the live path |
Both production gates write their result into the assistant message's metadata (metadata["verification"] and metadata["belnap"]) and always surface ungrounded or contradicted claims rather than silently dropping them. Both are a passing no-op when the query produced no context triples — this prevents false negatives when the knowledge graph has nothing relevant to say.
Implementation: crates/qadra-epistemic-core/src/attribution/verification.rs (rule-based + perturbation) and crates/qadra-epistemic-core/src/belnap.rs (Belnap).
Rule-Based Grounding Gate (production)
#![allow(unused)] fn main() { VerificationGateResult { passed: bool, fidelity: f32, faithfulness: f32, // fraction of implied triples that are grounded attributions: Vec<AttributedEdge>, ungrounded_claims: Vec<UngroundedClaim>, reason: Option<String>, } }
Process:
- Extract implied triples from the response — split into sentences; detect a predicate keyword (e.g. "reported", "produces", "owns"); subject = noun phrase before the keyword, object = noun phrase after.
- Classify each triple — compute word overlap with every context triple;
SUPPORTEDif any overlap exists, otherwiseUNGROUNDED. - Faithfulness = supported / total implied (1.0 if none were extracted).
- Gate decision — pass iff
faithfulness >= min_faithfulness(default 0.5). - Always surface
ungrounded_claims.
fidelity and stability are reported as rule-based proxies (fidelity mirrors faithfulness; stability is a determinism flag) — they become true R²/Jaccard only on the perturbation path below.
Belnap Four-Valued Gate (production)
A second, complementary gate from the qadra-belnap crate. It evaluates each claim over Belnap four-valued logic, where the graph can simultaneously support and contradict a claim, and "no evidence" is distinct from "false":
| Belnap value | Meaning | Graph evidence |
|---|---|---|
| Grounded (True) | Graph supports the claim | matching edge, same polarity |
| Contradicted (False) | Graph asserts the opposite | matching edge, opposite polarity |
| Contested (Both) | Graph both supports and contradicts | competing edges |
| Ungrounded (Neither) | Graph says nothing | no matching edge |
Pipeline (verify_with_belnap):
build_belnap_graph(triples)— each SPO triple becomes a polarity-tagged edge. Edge polarity is derived from the predicate string viapredicate_polarity(a negation/contrast-marker heuristic) — v1. The research-faithful source isclassify_polarityover the asserting excerpt computed at SPO write time; that is a tracked follow-up (seecrates/qadra-belnap/MODEL.md).- Parse the response into claims
(subject entity, predicate polarity)using the UD parser. - Ground each claim against the graph and reconcile the claim's polarity against the four-valued result.
passedis false iff the graph contradicts a claim. (Ungrounded/contested claims are reported but do not fail the gate.)
The UD parser comes from shared_parser(), a process-wide OnceLock. Under the onnx-ort Cargo feature it loads the real ONNX goeswith parser (KoichiYasuoka/roberta-base-english-ud-goeswith, run via ort) from QADRA_BELNAP_MODEL / QADRA_BELNAP_TOKENIZER / QADRA_BELNAP_LABELS. Without the feature it returns a StubParser whose empty parse makes the gate a passing no-op — so the default build is unchanged, and the Belnap gate only does real work when the feature is on and the model assets are present. BelnapVerification is serialized into metadata["belnap"] only when the response yields at least one claim.
Perturbation Attribution (future, higher-precision)
The verify_response path measures grounding by perturbation — removing subsets of triples, re-synthesizing, and measuring how much the response changes — then trains a weighted surrogate model to produce per-triple attribution scores (fidelity = R², faithfulness = correlation with actual impact, stability = Jaccard robustness). It is the highest-precision verifier but costs one LLM re-synthesis per perturbation, so it is not yet invoked by the live chat pipeline. Its distinguishing optimization — curvature-guided perturbation, which targets structurally important edges instead of 20 blind random removals — is described next.
Curvature-Guided Perturbation
The KG-SMILE paper recommends 20 random perturbations to measure how triple removal affects response quality. Random selection wastes LLM calls on redundant edges that change nothing. Qadra replaces random selection with Forman-Ricci curvature --- a discrete curvature measure computed directly on the knowledge graph --- to target perturbations at structurally important edges.
Implementation: crates/qadra-epistemic-core/src/curvature/
What Is Forman-Ricci Curvature
Forman-Ricci curvature is a discrete analogue of Ricci curvature from differential geometry, adapted for graphs. For an edge $e = (u, v)$, the formula is:
$$\kappa_F(e) = 4 - \deg(u) - \deg(v) + 3 \cdot |\triangle(u, v)|$$
Where:
- $\deg(u)$ is the degree of node $u$ (number of neighbors)
- $\deg(v)$ is the degree of node $v$
- $|\triangle(u, v)|$ is the number of triangles containing edge $e$ (i.e., common neighbors of $u$ and $v$)
Intuition: High-degree nodes pull curvature negative (the edge is a bottleneck funneling many paths). Triangles push curvature positive (the edge has parallel alternatives through shared neighbors).
Complexity: $O(|E| \cdot d_{\max})$ where $d_{\max}$ is the maximum node degree --- linear in graph size for bounded-degree graphs.
Worked examples:
| Topology | $\deg(u)$ | $\deg(v)$ | $\triangle$ | $\kappa_F$ | Classification |
|---|---|---|---|---|---|
| Triangle edge | 2 | 2 | 1 | $4 - 2 - 2 + 3 = 3$ | Cluster |
| Chain (A--B--C), edge A-B | 1 | 2 | 0 | $4 - 1 - 2 + 0 = 1$ | Cluster |
| Hub with 4 spokes, spoke edge | 1 | 4 | 0 | $4 - 1 - 4 + 0 = -1$ | Bridge |
| Isolated edge | 1 | 1 | 0 | $4 - 1 - 1 + 0 = 2$ | Cluster |
Edge Classification
Curvature values partition edges into three structural roles:
| Classification | Curvature Range | Structural Role | Attribution Impact |
|---|---|---|---|
| Bridge | $\kappa_F < -0.1$ | Bottleneck connecting distinct knowledge clusters. Removing it disconnects regions. | High --- removing a bridge produces a large change in synthesis output |
| Path | $-0.1 \leq \kappa_F \leq 0.1$ | Chain edge with moderate connectivity. Neither critical bottleneck nor redundant. | Medium --- some signal when removed |
| Cluster | $\kappa_F > 0.1$ | Edge inside a dense, well-connected region. Multiple alternative paths exist. | Low --- removal changes little because neighbors provide the same information |
The threshold $-0.1$ is configurable via CurvatureConfig::bridge_threshold.
graph LR
subgraph "Cluster (kappa = 3)"
A((A)) --- B((B))
B --- C((C))
C --- A
end
subgraph "Bridge (kappa = -3)"
D((D)) --- H((Hub))
E((E)) --- H
F((F)) --- H
G((G)) --- H
end
C -. "bridge edge<br/>kappa < -0.1" .-> D
style H fill:#f96,stroke:#333
Structure analysis: Before choosing a sampling method, the system computes the percentage of bridge edges. If bridges make up at least 15% of all edges (min_bridge_percentage), curvature-guided sampling is recommended. Otherwise, the graph is too uniform and random sampling is used as a fallback.
Weight Computation
Raw curvature values are transformed into sampling probabilities through a shifted-inversion + normalization pipeline:
Step 1: Shifted inversion --- Flip the sign so negative curvature (bridges) maps to high weight:
$$w(e) = \max!\bigl(\varepsilon,; -\kappa_F(e) + \kappa_{\max} + 1\bigr)$$
Where $\kappa_{\max} = \max_e \kappa_F(e)$ and $\varepsilon$ is the weight floor (default $0.01$) ensuring every edge retains a nonzero sampling chance.
Step 2: Normalize to probabilities:
$$P(e) = \frac{w(e)}{\sum_{e'} w(e')}$$
Effect on sampling distribution:
| Edge Type | Typical $\kappa_F$ | Weight $w(e)$ | Sampling Bias |
|---|---|---|---|
| Bridge (hub spoke, $\kappa = -1$) | $-1$ | $-(-1) + \kappa_{\max} + 1 = \kappa_{\max} + 2$ | Highest probability |
| Path ($\kappa = 0$) | $0$ | $\kappa_{\max} + 1$ | Moderate probability |
| Cluster (triangle, $\kappa = 3$) | $3$ | $\kappa_{\max} - 2$ | Lowest probability |
When all edges have identical curvature (e.g., a uniform hub-spoke), all weights are equal and sampling degrades gracefully to uniform random.
Weighted sampling: Edges are drawn without replacement using the computed probabilities. The sampler iteratively selects an edge proportional to its probability, removes it from the pool, renormalizes, and repeats. This ensures each perturbation set contains unique edges.
How It Feeds Into Attribution
The curvature pipeline replaces random perturbation selection in the KG-SMILE verification gate:
flowchart TD
A[SPO Triples for Query] --> B[Compute Forman-Ricci Curvature]
B --> C{Bridge % >= 15%?}
C -->|Yes| D[Curvature-Guided Sampling]
C -->|No| E[Random Sampling Fallback]
D --> F[Select Perturbation Sets]
E --> F
F --> G[Run Perturbations<br/>Remove selected edges, re-synthesize]
G --> H[Measure Response Similarity]
H --> I[Train Surrogate Model<br/>Weighted Linear Regression]
I --> J[Attribution Scores per Triple]
Why this matters:
- Fewer perturbations needed: Bridge edges produce the strongest attribution signal. By sampling them preferentially, each perturbation is more informative. Where random sampling might need 20 perturbations to achieve stable attributions, curvature-guided sampling can achieve comparable fidelity with fewer runs.
- Each perturbation is more informative: Removing a bridge edge disconnects knowledge clusters, producing a measurable drop in response quality. Removing a cluster edge changes almost nothing --- it wastes an LLM call that produces near-zero signal.
- Cost reduction: Each perturbation requires an LLM re-synthesis call. Targeting structurally important edges reduces the total number of LLM calls needed for reliable attribution.
- Context edge selection: Beyond perturbation sampling, the curvature pipeline also selects which edges to include in the LLM context window. Bridge edges (sorted by curvature ascending, most negative first) are prioritized. If there are at least
min_bridges_for_selectionbridge edges, only bridges are included. Otherwise, edges below the mean curvature are used as a fallback. The result is capped atmax_context_edges.
Configuration
All curvature behavior is controlled by CurvatureConfig:
| Field | Type | Default | Purpose |
|---|---|---|---|
bridge_threshold | f32 | $-0.1$ | Curvature below this value classifies an edge as a Bridge. More negative = stricter bridge detection. |
min_bridge_percentage | f32 | $0.15$ | Minimum fraction of edges that must be bridges before curvature-guided sampling is recommended over random. |
weight_floor | f32 | $0.01$ | Minimum weight $\varepsilon$ ensuring every edge has nonzero sampling probability, even highly positive-curvature cluster edges. |
max_context_edges | usize | $50$ | Maximum number of edges to include in LLM context or perturbation sets. Caps the output of select_context_edges. |
min_bridges_for_selection | usize | $3$ | If fewer than this many bridges exist, context selection falls back to below-mean-curvature edges instead of bridge-only selection. |
Full pipeline entry point: compute_curvature(triples, config) runs the complete pipeline --- Forman-Ricci computation, structure analysis, weight transformation, and context edge selection --- returning a CurvatureResult with edges sorted by curvature ascending (bridges first).
Attribution Scoring
Triple attribution via weighted linear regression:
S_i = B_0 + B_1*W_1*P_1 + ... + B_k*W_k*P_k + e
Where:
S_i= Response similarity for perturbation iP_k= Binary indicator (triple k present?)W_k= Kernel weight (Gaussian:exp(-(C_i)^2 / sigma^2))B_k= Attribution score (higher = more influential)
Configuration
| Metric | Default | Purpose |
|---|---|---|
min_coverage | 0.3 | Pre-synthesis entity coverage |
min_connectivity | 0.2 | Pre-synthesis entity connectivity |
min_faithfulness | 0.5 | Post-synthesis attribution validity |
min_fidelity | 0.7 | Post-synthesis model fit (R-squared) |
kernel_sigma | 0.5 | Gaussian kernel width |
Schema
-- Sessions track both gates
CREATE TABLE attribution_sessions (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
query TEXT NOT NULL,
competence_passed BOOLEAN,
spo_coverage REAL,
spo_connectivity REAL,
verification_passed BOOLEAN,
fidelity REAL,
faithfulness REAL,
stability REAL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Triple-level attribution scores
CREATE TABLE triple_attributions (
id UUID PRIMARY KEY,
session_id UUID REFERENCES attribution_sessions(id),
triple_id UUID REFERENCES spo_triples(id),
attribution_score REAL,
weight REAL,
component_group TEXT
);
-- Raw perturbation data
CREATE TABLE perturbation_results (
id UUID PRIMARY KEY,
session_id UUID REFERENCES attribution_sessions(id),
removed_triple_ids UUID[],
graph_similarity REAL,
response_similarity REAL
);
Attribution Session Persistence
Every gate run is durable, not just in-memory. Each call into handle_query persists a row to attribution_sessions and --- when triples were ranked --- batch-inserts the per-triple curvature/weight into triple_attributions.
The write happens via fire-and-forget (tokio::spawn), so query latency is unaffected and a failed insert never propagates to the caller.
Persisted on every gate run:
query,tenant_id, agent namespo_coverageandspo_connectivity(the curvature analysis bridge percentage)competence_passedand competence timing- per-triple attributions (
weight= curvature)
Verification-side columns (fidelity, faithfulness, stability, verification_passed) are left NULL until the verification gate is invoked from a higher-level pipeline.
Querying persisted sessions:
| Surface | Value |
|---|---|
| List | NATS qadra.{tid}.epistemic.list_attribution_sessions --- HTTP GET /knowledge/attribution-sessions |
| Get one | NATS qadra.{tid}.epistemic.get_attribution_session --- HTTP GET /knowledge/attribution-sessions/:id |
The list endpoint is paginated and filterable by workload_id, agent_name, date range (from/to), and the gate-pass booleans (competence_passed, verification_passed). Getting a single session returns it together with its triple_attributions.
Metrics: each gate run emits a tracing event with the target kg_smile.gate_run, carrying gate, result, coverage, and connectivity. These flow through the standard OTLP --> OTel Collector --> Loki pipeline and feed a KG-SMILE Grafana dashboard that charts pass/fail rates and average coverage/connectivity over time --- no new exporter or metrics endpoint required.
Groundedness Validation
Purpose
Detect hallucinations by checking if claims in an agent's response can be traced back to the provided context. Groundedness validation complements the Two Gates model as a third layer of quality assurance.
Validation Flow
Agent Response
|
v
Extract Claims (LLM)
"NVIDIA has market cap of $3T"
"Revenue grew 200% YoY"
|
v
For each claim:
--> Find supporting triple or chunk
--> Check entailment (SUPPORTED / CONTRADICTED / NOT_MENTIONED)
--> Score confidence
|
v
Aggregate: { grounded: bool, score: 0-1, ungrounded_claims: [] }
Scoring
#![allow(unused)] fn main() { pub struct ValidationResult { pub is_grounded: bool, pub score: f32, // 0-1, fraction of claims grounded pub total_claims: usize, pub grounded_claims: usize, pub ungrounded_claims: Vec<String>, } }
| Score | Level | Meaning |
|---|---|---|
| >= 0.9 | Highly grounded | Nearly all claims supported by context |
| >= 0.7 | Mostly grounded | Most claims supported |
| >= 0.5 | Partially grounded | Some claims unsupported |
| < 0.5 | Poorly grounded | Majority of claims lack support |
Integration
Called after synthesis, before returning a response:
- If grounded: return as-is
- If partially grounded: attach warning listing ungrounded claims
- If poorly grounded: trigger gap detection and autonomous research (see 5.9), or regenerate with stricter prompt
Relationship to KG-SMILE
The three quality layers form a chain:
- Competence Gate (pre-synthesis): Do we have knowledge about this topic?
- Verification Gate (post-synthesis): Did we stay grounded in that knowledge?
- Groundedness Validation (post-synthesis): Are specific claims individually supported?
Claim Validation Node
Groundedness validation runs inside a single agent's synthesis. Claim validation lifts the same idea into a flow graph: a ClaimValidation node grounds every claim produced by prior-stage agents against the SPO triples in the Knowledge Core.
This is the cross-stage check for pipelines where one agent's output feeds another --- for example, validating Scarlet's research claims and Ayana's analysis against known facts before a downstream stage builds on them.
What it does:
- Read the prior-stage agent outputs from the flow state (the
input_keys) - Extract individual claims from each output
- Ground each claim against SPO triples in the Knowledge Core
- Label each claim SUPPORTED, CONTRADICTED, or NOT_MENTIONED
- Write a per-claim result list plus a validation summary back to state under
output_key
Configuration:
| Field | Default | Purpose |
|---|---|---|
input_keys | scarlet_output,ayana_output | Comma-separated state keys holding prior-stage agent outputs to validate |
output_key | --- | State key under which the per-claim results and summary are written |
asserter_filter | --- | Optional --- restrict grounding to triples from a specific asserter |
query_limit | --- | Cap on triples fetched per claim during grounding |
The node surfaces contradictions and unsupported claims explicitly rather than silently passing them downstream, giving the pipeline a hard checkpoint before unverified claims propagate into an investment memo.
Entity Aliases
The Problem
The same entity can appear under different names: "Apple", "Apple Inc.", "Apple Inc", "AAPL". Without alias resolution, SPO queries miss related triples.
Approach
Entity aliases are stored as SPO triples themselves:
(Apple Inc., alias_of, Apple)
(AAPL, alias_of, Apple)
(Apple Computer, alias_of, Apple)
During query processing, the system resolves aliases before SPO lookup:
- Query entities are checked against
alias_ofpredicates - If an alias is found, the canonical entity is used for the SPO query
- Results from all aliases are merged
This uses the existing SPO infrastructure --- no separate alias table needed.
Gap Detection and Autonomous Research
When to Research
After initial synthesis, the system analyzes the response for signs of insufficient knowledge. Research is triggered (not on every query) when any of these conditions are detected:
1. Uncertainty phrases in the response:
"i don't know", "i'm not sure", "cannot find",
"don't have enough information", "not in the context",
"unable to answer", "no relevant", "insufficient"
2. Weak attribution signal: Average absolute attribution score < 0.001 across all attributed triples. Indicates no triples strongly influenced the response.
3. Missing bridges: Bridge edge ratio < 10% when the graph has 5+ edges. The graph lacks structural connectors between knowledge regions.
Research Flow
Gap Detected
|
v
Exa Neural Search (3 results)
Returns: URLs + full text content
|
v
For each source:
--> Chunk text (500 chars, 50 overlap)
--> Embed each chunk into pgvector
--> Extract triples via LLM (with epistemic metadata)
--> Store triples in SPO index
--> Link provenance (document_triples, document_chunks)
|
v
Re-synthesize with enriched context
Re-Synthesis
After research, context is rebuilt from BOTH the original and new knowledge:
EXISTING KNOWLEDGE:
(Apple, founded_in_year, 1976) [fact]
(Apple, headquartered_in, Cupertino) [fact]
NEW RESEARCH:
(Apple, revenue_2024, $391B) [claim, asserter: Reuters]
(Apple, ceo, Tim Cook) [fact, asserter: SEC filing]
SOURCE EXCERPTS:
[chunk text from newly ingested documents]
Constraints
- Only trigger research when gaps are detected (not every query)
- Limit to 3 Exa results per research cycle to control cost
- All new triples carry epistemic metadata (asserter, assertion_type)
- All new content links to source documents for provenance
- Research triggers are logged for debugging and cost monitoring
- Research results go through the same ingestion pipeline (human-gated for background research, automatic for in-flight gap filling during agent execution)
Conflict Detection
The Problem
When new triples contradict existing ones, the system must detect and handle the conflict rather than silently storing both.
Example:
Existing: (Tesla, founded_by, Elon_Musk) confidence=0.95 fact
New: (Tesla, founded_by, Martin_Eberhard) confidence=0.80 claim
--> Contradiction detected: different founders asserted for the same subject+predicate
How Contradictions Are Detected
Two triples contradict when they share the same subject and predicate but assert different objects. The detect_contradictions function in qadra-epistemic-core implements this:
- Normalize and group: All triples are grouped by normalized
(subject, predicate)pairs. Normalization lowercases and collapses whitespace, so"Tesla"and"tesla"group together. - Check for distinct objects: Within each group, object values are normalized and compared. A contradiction exists only when 2+ distinct objects appear for the same subject+predicate.
- Same object, different confidence: Two triples asserting
(Tesla, founded_by, Elon Musk)at different confidence levels are NOT a contradiction --- they reinforce each other. - Parallel analysis: Groups are analyzed in parallel using rayon, since each
(subject, predicate)group is independent.
Resolution Scoring
Each competing assertion receives a resolution score that determines the winner. The score combines four factors:
resolution_score = confidence * assertion_type_weight * source_bonus * trust_factor
Assertion type weights:
| Assertion Type | Weight | Rationale |
|---|---|---|
fact | 1.0 | Verified, highest credibility |
claim | 0.6 | Stated but not universally verified |
opinion | 0.3 | Subjective, lowest credibility |
| unknown/missing | 0.5 | Conservative middle ground |
Source bonus: When multiple independent sources assert the same object value, the representative gets a source bonus of 1.0 + (source_count - 1) * 0.1. Two sources asserting the same object = 1.1x multiplier. This rewards corroboration.
Source trust scores: An optional source_trust map (source name to 0.0-1.0 score) can downweight unreliable sources. For example, {"sketchy-blog.com": 0.3, "reuters.com": 1.0} causes a high-confidence fact from a sketchy blog to lose to a lower-confidence claim from Reuters:
sketchy-blog.com: 0.9 * 1.0 (fact) * 0.3 (trust) = 0.27
reuters.com: 0.8 * 0.6 (claim) * 1.0 (trust) = 0.48 --> winner
Unknown sources default to trust factor 1.0 (full trust until proven otherwise).
Winner selection: Assertions are sorted by resolution score descending. The highest-scoring assertion wins. The Contradiction struct records the winner's triple ID and all competing assertions with their scores.
Contradiction Result
#![allow(unused)] fn main() { pub struct ContradictionResult { pub has_contradictions: bool, pub contradictions: Vec<Contradiction>, // Each with subject, predicate, assertions, winner pub triples_analyzed: usize, pub conflict_count: usize, // Number of unique S+P pairs with conflicts } pub struct Contradiction { pub subject: String, pub predicate: String, pub assertions: Vec<CompetingAssertion>, // Sorted by resolution_score descending pub winner: Option<Uuid>, // Triple ID of the highest-scoring assertion pub detection_confidence: f32, // 0.9 when scores are computable, 0.5 otherwise } }
Multi-Way Contradictions
The system handles contradictions with more than two competing assertions. For example, three different CEO assertions for the same company:
(Company_X, ceo, Alice) [fact, confidence=0.9] --> score: 0.9 * 1.0 = 0.9 (winner)
(Company_X, ceo, Bob) [claim, confidence=0.7] --> score: 0.7 * 0.6 = 0.42
(Company_X, ceo, Charlie) [opinion, confidence=0.5] --> score: 0.5 * 0.3 = 0.15
All three assertions are recorded in the Contradiction with their resolution scores. Alice wins.
Integration with Ingestion Pipeline
During triple extraction, before storing a new triple, the pipeline checks for existing triples with the same subject and predicate but different object. When found:
- Same assertion type: Flag for human review --- which is current?
- Higher-confidence new triple: Replace old triple, archive the original
- Lower-confidence new triple: Store as alternative, mark as contested
- Temporal context available: Both may be correct at different points in time --- store both with temporal metadata
Contested Triples
When a conflict cannot be automatically resolved, both triples are stored and marked as contested. Synthesis prompts include both with appropriate hedging:
CONTESTED:
(Acme Corp, ceo, John Smith) [fact, asserter: 10-K filing, 2024-01]
(Acme Corp, ceo, Jane Doe) [claim, asserter: Bloomberg, 2024-06]
Note: CEO may have changed. Verify with latest source.
Implementation: crates/qadra-epistemic-core/src/contradictions.rs
Usage Examples
Ingesting a Document
POST /v1/documents
Content-Type: application/json
{
"title": "Acme Corp Overview",
"content": "Acme Corporation was founded in 1990 by John Smith. The company
is headquartered in San Francisco and specializes in enterprise
software. Acme acquired DataCo in 2015 for $50 million...",
"source_type": "web",
"source_url": "https://example.com/acme"
}
Response:
{
"document_id": "uuid",
"routing": "both",
"triples_extracted": 4,
"chunks_created": 2,
"analysis": {
"routing": "both",
"content_type": "text/plain",
"has_extractable_facts": true,
"estimated_chunks": 2,
"confidence": 0.87,
"reasoning": "Mixed content with facts and narrative"
}
}
Querying the Knowledge Graph
#![allow(unused)] fn main() { // Find all companies founded by a person let companies = repository.query_spo_sp( tenant_id, "John Smith", "founded" ).await?; // Find who founded a specific company let founders = repository.query_spo_po( tenant_id, "founded_by", "Acme Corporation" ).await?; // Semantic search for relevant context let context = vector_index.search( tenant_id, embed("Acme acquisition history"), k: 5 ).await?; }
Combined SPO + Vector Query
#![allow(unused)] fn main() { // 1. SPO gives us structured facts about the entity let facts = repository.query_spo_s(tenant_id, "Acme Corporation").await?; // 2. Vector search gives us narrative context let narrative = vector_index.search( tenant_id, embed("Acme Corporation competitive position"), k: 5 ).await?; // 3. Combine for synthesis let context = format!( "STRUCTURED FACTS:\n{}\n\nNARRATIVE CONTEXT:\n{}", format_triples(&facts), format_chunks(&narrative), ); }
Financial Data Extraction
A specialised sub-agent that queries the SPO index for financial metrics on a named entity. Instead of free-form synthesis, it pulls structured financial facts directly from the knowledge graph and returns them with confidence and provenance.
Recognised predicates:
revenue, ebitda, market_cap, founded_in_year,
headcount, funding_round, valuation
How it works: given an entity (e.g. "Acme Corporation"), the sub-agent looks up SPO triples whose subject matches the entity and whose predicate is one of the financial predicates above. Each match becomes a metric carrying its object value, confidence, and asserter.
#![allow(unused)] fn main() { pub struct FinancialMetric { pub metric: String, // predicate name, e.g. "revenue" pub value: String, // object value, e.g. "$391B" pub confidence: f32, // from the source triple pub asserter: Option<String>, pub low_confidence: bool, // true when confidence < 0.6 } }
Metrics with confidence below 0.6 are flagged low_confidence so a reviewer can treat them with caution rather than excluding them outright.
NATS subject: qadra.{tid}.epistemic.extract_financial_metrics
Request { entity } --> response { entity, metrics: [...], retrieved_at }. Because the extraction is pure SPO lookup over a fixed predicate set, it is fast and deterministic --- no LLM call is needed to surface the figures.
Semantic Verification Cache
When verifying LLM responses against the knowledge graph, we can avoid redundant LLM calls by caching verification results for semantically similar queries.
The Problem
Every LLM response verification requires:
- LLM call to generate/parse the response (~500ms-2s, ~$0.01-0.10)
- SPO lookup to ground facts against triples (~5ms)
For similar queries ("When was Apple founded?" vs "What year did Apple start?"), we repeat the expensive LLM call even though the SPO verification would be identical.
The Solution: Perturbation Cache
Cache verification results keyed by semantic similarity, not exact string match.
Key insight: The SPO lookup is fast (~5ms). Even on cache hit, we re-validate against current facts. We only skip the expensive LLM call.
Cache Schema
Redis Stack with RediSearch provides vector similarity search:
FT.CREATE perturbation_cache ON HASH PREFIX 1 pcache:
SCHEMA
tenant_id TAG
embedding VECTOR HNSW 6
TYPE FLOAT32
DIM 1536
DISTANCE_METRIC COSINE
query_hash TAG
created_at NUMERIC SORTABLE
ttl NUMERIC
Similarity Threshold
| Threshold | Behavior | Use Case |
|---|---|---|
| 0.98+ | Near-exact match only | High-precision, low hit rate |
| 0.92-0.95 | Same semantic meaning | Recommended default |
| 0.85-0.90 | Related queries | Higher hit rate, some risk |
| < 0.85 | Too loose | Wrong results returned |
Start at 0.93, monitor cache hit rate and verification accuracy.
Performance
| Operation | Latency | Cost |
|---|---|---|
| Cache miss (full path) | 500-2000ms | LLM tokens + compute |
| Cache hit (validated) | 10-20ms | Redis + SPO lookup only |
| Cache hit rate (typical) | 15-40% | Depends on query diversity |
Connectors & Content Ingestion
Overview
Connectors are how external content enters Qadra. A connector mirrors documents from a source system (Confluence, Google Drive, SharePoint, Slack, S3, or the gateway), feeds them through a processing pipeline, and loads the result into the vector index and SPO knowledge graph.
The design follows a Moveworks-inspired three-stage pipeline:
MIRROR (Connectors) --> PROCESS (Chunk + Enrich + Embed) --> LOAD (VectorIndex)
Everything is trait-based and swappable. Each connector implements the
ContentConnector trait; the platform composes connectors, a DocumentProcessor, an
EmbeddingProvider, and a VectorIndex behind abstract interfaces, so the ingestion
core knows nothing about any specific source system.
The human-gated approval flow for documents (propose -> review -> approve -> ingest) and the ingestion internals (content analyzer, triple extraction, chunking, provenance) are covered in the Knowledge Graph chapter. This page covers the connectors, sync, and entitlements that sit in front of that pipeline.
The MIRROR -> PROCESS -> LOAD Pipeline
| Stage | Responsibility | Trait |
|---|---|---|
| MIRROR | Pull documents from a source system, detect what changed since last sync | ContentConnector |
| PROCESS | Chunk documents, enrich chunks, generate embeddings | DocumentProcessor, EmbeddingProvider |
| LOAD | Upsert embeddings into tenant-isolated vector storage; route facts to the SPO index | VectorIndex |
The MIRROR stage produces a SyncDelta (added / modified / deleted document IDs). Only
changed documents flow downstream, so an incremental sync never re-processes the entire
corpus.
The ContentConnector Model
A connector is a streaming source of documents. The ContentConnector trait exposes two
core operations:
#![allow(unused)] fn main() { #[async_trait] pub trait ContentConnector: Send + Sync { /// List documents, optionally only those changed since a timestamp (delta sync). async fn list_documents(&self, since: Option<DateTime<Utc>>) -> Result<Vec<DocumentState>>; /// Fetch full document content (streaming, zero-copy where possible). async fn fetch_documents(&self, ids: &[String]) -> Result<DocumentStream>; } }
Connectors are registered in the ServiceContainer via a ConnectorRegistry
(HashMap<String, Arc<dyn ContentConnector>>), keyed by connector type. The supporting
DocumentProcessor (chunk / enrich / embed) and VectorIndex (semantic search) traits
complete the pipeline:
| Trait | Purpose | Key Methods |
|---|---|---|
ContentConnector | Knowledge ingestion | list_documents, fetch_documents |
DocumentProcessor | Chunking / enrichment | chunk, enrich, embed |
EmbeddingProvider | Vectors from text | embed(texts), dimension() |
VectorIndex | Semantic search | upsert, search, delete_by_document, delete_tenant |
SyncStateStore | Delta sync tracking | get_states, upsert_states, mark_deleted, get_last_sync |
Available Connectors
| Connector | Crate | Source System | Source Filter |
|---|---|---|---|
| Gateway | qadra-connector-gateway | Direct uploads via the gateway (default) | -- |
| Confluence | qadra-connector-confluence | Atlassian Confluence wiki | Spaces |
| Google Drive | qadra-connector-gdrive | Google Drive files | Folders |
| SharePoint | qadra-connector-sharepoint | Microsoft SharePoint | Sites / libraries |
| Slack | qadra-connector-slack | Slack messages | Channels |
| S3 | qadra-connector-s3 | S3-compatible object storage | Prefixes |
The gateway connector is always available (it backs user uploads). The rest are opt-in per tenant and gated by entitlements (see below).
Sync Strategy
Each connector can be driven by three sync modes:
| Type | Trigger | Method |
|---|---|---|
| Incremental | Every 15 minutes | list_documents(since=last_sync) -> delta -> fetch only changed docs |
| Full | Daily, 2 AM | list_documents(since=None) -> detect deletions, rebuild sync state |
| Webhook | Real-time | Process a single document immediately |
- Incremental sync is the common path: ask the source for everything changed since the
last successful sync, build a
SyncDelta, and process only the added/modified documents. - Full sync runs daily to catch deletions and repair drift. Because it lists the entire
corpus, it can detect documents that disappeared from the source (which incremental sync
cannot reliably see) and rebuild the
sync_statetable from scratch. - Webhook sync handles real-time updates: a single document is processed immediately on notification, without waiting for the next incremental window.
Sync history is recorded in the sync_runs table; per-document state lives in sync_state
(primary key tenant_id + connector + source_id).
Change Detection
To decide whether a document actually changed, connectors compare source metadata in a strict priority order --- cheapest and most reliable first:
- ETag comparison --- fastest, HTTP-native. If the source returns an ETag and it matches the stored one, the document is unchanged. No content fetch needed.
- Version comparison --- for systems with explicit version numbers (Confluence, SharePoint). A higher version means the document changed.
- Timestamp comparison --- the fallback. Compare
last_modifiedagainst the storedsynced_at.
Each document's tracked state is captured in DocumentState:
#![allow(unused)] fn main() { pub struct DocumentState { pub source_id: String, pub content_hash: String, // BLAKE3 of content pub last_modified: DateTime<Utc>, pub etag: Option<String>, pub version: Option<u64>, pub synced_at: DateTime<Utc>, } }
A BLAKE3 content_hash is the final tiebreaker: even if ETag/version/timestamp suggest a
change, an identical hash means the processed content is unchanged and downstream
re-embedding can be skipped.
Zero-Copy Processing
Ingestion is engineered to avoid unnecessary allocations as content flows from source to vector store:
DocumentContentis an enum ofMmap,Bytes, orBorrowed--- documents can be memory-mapped or borrowed with zero allocation on load.Chunkborrows from the document content (&'doc str) --- no string copies during chunking.EnrichedChunkusesCow<'doc, str>--- it only allocates if a chunk is actually mutated during enrichment.
Supporting crates: memmap2 (memory-mapped files), bytes (zero-copy byte buffers),
bumpalo (arena allocation), blake3 (fast content hashing).
Per-Tenant Connector Configuration
Connector configuration is per-tenant, stored in the tenant_connectors table and managed
through the TenantConnectorStore trait:
| Method | Purpose |
|---|---|
get_enabled | List enabled connectors for a tenant |
get | Fetch a single connector's config |
upsert | Create or update connector config |
disable | Disable a connector |
update_credentials | Rotate credentials |
Each configuration holds:
- Credentials --- one of
OAuth2,ApiKey,Basic, orAws, encrypted at rest. - Sync config --- cron schedule, incremental interval, webhook settings, rate limits.
- Source filter --- connector-specific scoping: Confluence spaces, Google Drive folders, Slack channels, S3 prefixes.
Because credentials and source filters live per-tenant, two tenants can connect to the same source system (e.g. two Confluence instances) with completely isolated configuration and sync state.
Tenant Entitlements
Connectors and LLM providers are gated by service plans. Every tenant has an entitlement record defining what it can use and how much.
Service Plans
Three tiers --- free, pro, enterprise --- each defines:
- Limits:
max_connectors,max_documents,api_requests,llm_tokens,storage,agents,pipelines,concurrent_workloads. - Available services: which connectors and LLM providers are enabled.
Enforcement
Before any connector or service operation, the entitlement is checked:
#![allow(unused)] fn main() { // Before adding a connector / running a service operation: let ent = entitlements.get(tenant_id).await?; ent.can_add_connector(connector_type)?; // checks service availability + limits }
can_add_connector validates both that the connector type is available on the tenant's plan
and that the tenant is under its max_connectors limit.
Override Hierarchy
Entitlements resolve in priority order, most specific first:
explicit service_overrides > limit_overrides > plan defaults
A tenant on the pro plan can be granted a single extra connector via a limit_override,
or have a specific connector enabled via a service_override, without changing its plan.
Entitlement Schema
| Table | Purpose |
|---|---|
service_plans | Plan definitions (id, limits JSONB, available_services JSONB) |
tenant_entitlements | Per-tenant plan + overrides + current usage |
entitlement_audit_log | Who changed what, when, and why |
The TenantEntitlementStore trait exposes get, set_plan, set_limit_override,
enable_service, and disable_service. Admin (Qadra employee) endpoints manage entitlements
via GET/PUT/PATCH /admin/tenants/{id}/entitlements|plan|limits|services|usage|audit.
Feature-Flag Compilation
Beyond runtime entitlement checks, connectors are also gated at compile time via Cargo feature flags. A connector that is not compiled in cannot be enabled regardless of plan.
[features]
default = ["postgres", "openrouter", "redis", "mongo", "pgvector", "connector-gateway"]
minimal = ["sqlite", "ollama", "embed-local", "connector-gateway"] # zero cloud deps
enterprise = ["postgres", "openrouter", "pgvector", "redis", "mongo",
"embed-openai", "connector-confluence", "connector-sharepoint",
"connector-s3", "connector-gateway"]
full = ["..."] # everything
Two gates work together:
- Build-time ---
is_service_compiled(service)checks the active feature flags. Theminimalbuild ships with only the gateway connector and zero cloud dependencies. - Runtime ---
entitlements.get(tenant_id)checks the tenant's plan and overrides.
A connector must pass both gates to run: it must be compiled into the binary and enabled on the tenant's plan.
Agent Evaluation
This section covers the Agent Output Quality Evaluation framework (Story 94)---a deterministic, fixture-driven way to score what each agent produces, used as a CI release gate.
Purpose
LLM-based agents are non-deterministic, but the shape of their output is not. Scarlet emits SPO triples, Ayana filters items, Eliza grounds claims, Reagan writes review sections. The evaluation framework scores these structured outputs against fixed rubrics so we can detect regressions before they ship.
The framework is:
- Deterministic -- no LLM calls during scoring. Same fixtures in, same scores out.
- LLM-free -- rubrics inspect emitted structure (triples, labels, claims, sections), not prose quality.
- Fixture-driven -- every agent ships golden (should-pass) and negative (should-fail) cases.
- A release gate -- CI fails the release build if any agent's golden average drops below its threshold.
Rubrics
Each agent has one primary rubric. A rubric inspects the agent's emitted output and returns a score in [0.0, 1.0].
| Agent | Rubric | What It Measures | Threshold |
|---|---|---|---|
| Scarlet | SourceCoverage | Fraction of emitted {"triples":[…]} carrying a non-empty source | 0.70 |
| Ayana | FilterAccuracy | (TP + TN) / total over labelled items | 0.80 |
| Eliza | GroundingScore | Fraction of claims grounded in the supplied SPO context | 0.70 |
| Reagan | ReviewCompleteness | Fraction of required review sections present | 0.80 |
Reagan's required sections:
- Recommendation
- Conviction
- Key Positives
- Key Risks
- Reviewer Notes
Scoring
A rubric scores a single case. An agent's score aggregates those case scores.
- Per-agent score = average over GOLDEN cases only (
should_pass = true). - Negative cases (
should_pass = false) must score below the threshold---this confirms the rubric actually discriminates. - Coverage -- at least 10 cases per agent (8 golden + 4 negative is the working baseline).
- Release gate --
release_gate_checkfails if any agent's golden average falls below its threshold.
agent_score(agent) = mean(score(case) for case in golden_cases(agent))
release_gate_check:
for agent in agents:
assert agent_score(agent) >= threshold(agent)
# negative cases independently asserted < threshold
Metrics
After an evaluation run, emit_agent_eval_metrics emits a structured tracing event:
- Target:
qadra.eval - Event:
agent_eval_complete - Fields:
eval_agent,eval_score,eval_rubric
These flow through the standard pipeline---OTLP -> OTel Collector -> Loki -> the Eval Quality Grafana dashboard (deploy/otel/dashboards/eval-quality.json)---so per-agent scores can be charted against their thresholds over time. No new exporter or /metrics endpoint is required.
Code Locations
| File | Purpose |
|---|---|
crates/qadra-traits/src/eval.rs | Rubric and case types, scoring trait |
crates/qadra-epistemic-core/src/eval/rubric.rs | Rubric implementations (SourceCoverage, FilterAccuracy, GroundingScore, ReviewCompleteness) |
crates/qadra-epistemic-core/src/eval/harness.rs | Aggregation, release_gate_check, emit_agent_eval_metrics |
crates/qadra-epistemic-core/src/eval/fixtures.rs | Golden + negative cases per agent |
tests/eval_harness.rs | Integration tests (gate check, threshold enforcement, metric emission) |
Constraints
- Rubrics must stay aligned to the live SPO persona output formats. If a persona changes the structure it emits (triple shape, section headings, label schema), the corresponding rubric and fixtures must be updated in lockstep---otherwise the gate either passes blindly or false-fails.
- Scoring stays deterministic and LLM-free: a rubric may only inspect emitted structure, never call a model.
- Every agent keeps both golden and negative fixtures so the gate verifies discrimination, not just a high average.
AI Fluency & Response Calibration
Overview
Qadra adapts how it responds based on each user's AI fluency --- their skill at interacting with an AI system. The system observes interaction patterns, infers a fluency level, and calibrates every response accordingly: terse bullets for experts, thorough walkthroughs for novices.
The core principle: Fluency is about HOW users interact, not WHAT they know.
- A novice investor asking about DCF is still a novice AI user.
- An expert prompter asking about stocks is still an expert AI user.
Domain expertise and jargon are not fluency indicators. A user who fluently discusses discounted cash flows but accepts the first AI answer without refining it is interacting like a novice. Fluency lives in the interaction loop --- iteration, format specification, chaining --- never in the subject matter.
Fluency Detection
The system scores each query for signals that reveal interaction skill, weights them, and feeds them into a rolling window. Never use jargon or topic complexity as a signal.
Expert Indicators
High-weight signals that the user is steering the AI deliberately:
| Signal | Pattern | Weight |
|---|---|---|
| iterates_prompt | Refines query based on the previous response | 0.6 |
| specifies_format | "as JSON", "in a table", "bullet points" | 0.5 |
| uses_examples | Provides input/output examples | 0.5 |
| chains_queries | "now use that to...", "take that and..." | 0.5 |
| provides_context | Background info supplied upfront | 0.4 |
Novice Indicators
Lower-weight signals that the user is accepting output passively:
| Signal | Pattern | Weight |
|---|---|---|
| accepts_first | Moves to a new topic without refining | 0.2 |
| asks_clarification | "What do you mean?", "Can you explain?" | 0.15 |
Pattern Detection
Signals are detected with compiled regular expressions over the raw query text. Format specification, context provision, and query chaining each have their own pattern set:
#![allow(unused)] fn main() { lazy_static! { static ref FORMAT_SPEC_PATTERNS: Vec<Regex> = vec![ Regex::new(r"(?i)\b(in json|as json|json format)\b").unwrap(), Regex::new(r"(?i)\b(as a (list|table)|bullet points)\b").unwrap(), Regex::new(r"(?i)\b(step.?by.?step|break.?(it )?(down|into))\b").unwrap(), ]; } lazy_static! { static ref CONTEXT_PROVISION_PATTERNS: Vec<Regex> = vec![ Regex::new(r"(?i)\b(for context|some background)\b").unwrap(), Regex::new(r"(?i)\b(i'm (working on|trying to|building))\b").unwrap(), Regex::new(r"(?i)\b(the goal is|my goal is)\b").unwrap(), ]; } lazy_static! { static ref CHAINING_PATTERNS: Vec<Regex> = vec![ Regex::new(r"(?i)\b(now|next|then).*\b(that|this|the above)\b").unwrap(), Regex::new(r"(?i)\b(take that|use that|apply that)\b").unwrap(), ]; } }
Iteration and chaining are also detected relationally --- comparing the current query against the previous one --- not just by single-query keywords.
Fluency Levels
Every assessment resolves to one of four levels:
#![allow(unused)] fn main() { pub enum FluencyLevel { Novice, Intermediate, Advanced, Expert, } }
Level Determination
An expert score is computed as a weighted blend of the expert signals, then normalized and compared against thresholds:
#![allow(unused)] fn main() { // Expert: iteration + format specs + examples + chaining let expert_score = counts.iterates_prompt * 0.35 + counts.specifies_format * 0.25 + counts.uses_examples * 0.2 + counts.chains_queries * 0.2; // Thresholds if norm_expert > 0.4 { FluencyLevel::Expert } else if norm_expert > 0.25 || norm_intermediate > 0.5 { FluencyLevel::Advanced } else if norm_novice > 0.4 { FluencyLevel::Novice } else { FluencyLevel::Intermediate } }
| Level | Resolved When |
|---|---|
| Expert | Normalized expert score > 0.4 |
| Advanced | Normalized expert > 0.25 or intermediate > 0.5 |
| Novice | Normalized novice > 0.4 |
| Intermediate | Default / no strong signal in any direction |
Rolling Window & Confidence
Fluency is not judged from a single query. The assessment maintains a rolling window of recent signals:
- Tracks the last 50 signals (configurable window size).
- Uses an exponential moving average for complexity, so recent behavior weighs more.
- Confidence increases with signal count, reaching its maximum at ~15 signals --- early in a session, confidence is low and the system stays cautious.
#![allow(unused)] fn main() { pub struct FluencyAssessment { signals: VecDeque<FluencySignal>, window_size: usize, profile: FluencyProfile, } impl FluencyAssessment { pub fn process_query( &mut self, query: &str, previous_query: Option<&str>, ) -> &FluencyProfile { let signals = self.analyze_query(query); let is_iteration = self.detect_iteration(query, previous_query); let is_chaining = self.detect_chaining(query); // Add signals to rolling window // Update profile // Return updated profile } pub fn needs_more_guidance(&self) -> bool { matches!(self.profile.level, FluencyLevel::Novice) || (matches!(self.profile.level, FluencyLevel::Intermediate) && self.profile.confidence < 0.5) } pub fn prefers_brevity(&self) -> bool { matches!(self.profile.level, FluencyLevel::Expert) || (matches!(self.profile.level, FluencyLevel::Advanced) && self.profile.confidence > 0.6) } } }
Both level and confidence are always checked together --- a high-fluency guess with low confidence should not produce terse expert-style output.
Response Calibration
Once a fluency level is established, the response is tuned across several dimensions:
#![allow(unused)] fn main() { pub struct ResponseCalibration { pub verbosity: Verbosity, pub use_headers: bool, pub use_bullets: bool, pub technical_level: TechnicalLevel, pub anticipate_followups: bool, pub should_simplify: bool, pub should_elaborate: bool, } pub enum Verbosity { Minimal, Concise, Detailed, Comprehensive, } pub enum TechnicalLevel { Layman, Intermediate, Technical, Expert, } }
Calibration by Level
| Dimension | Novice | Intermediate | Advanced | Expert |
|---|---|---|---|---|
| Verbosity | Comprehensive | Detailed | Concise | Concise |
| Headers | Yes | Yes | Yes | No |
| Bullets | Yes | Yes | Yes | Yes |
| Technical level | Layman | Intermediate | Technical | Expert |
| Anticipate follow-ups | Yes | Yes | No | No |
| Simplify | Yes | No | No | No |
| Elaborate | Yes | Yes | No | No |
Expert users get dense, concise delivery --- no headers (they know what they're looking for), no hand-holding, no anticipated follow-ups (they'll ask if needed). Novice users get comprehensive, layman-level explanations with headers, bullets, simplification, and suggested next steps.
Confidence Blending
When confidence is low, the calibration blends toward Intermediate rather than committing to an uncertain extreme:
#![allow(unused)] fn main() { impl ResponseCalibrator { pub fn calibrate( &self, fluency_level: FluencyLevel, confidence: f32, ) -> ResponseCalibration { // Low confidence = blend toward intermediate if confidence < 0.5 { self.blend_calibrations( &self.get_calibration(fluency_level), &self.get_calibration(FluencyLevel::Intermediate), confidence, ) } else { self.get_calibration(fluency_level) } } } }
Calibration → Synthesis Prompt
Calibration is not cosmetic post-processing --- it is injected directly into the synthesis prompt so the model generates the right shape of answer from the start:
#![allow(unused)] fn main() { fn build_prompt( query: &str, context: &str, calibration: &ResponseCalibration, ) -> String { let mut instructions = String::new(); match calibration.verbosity { Verbosity::Minimal => instructions.push_str("Be extremely brief. "), Verbosity::Comprehensive => { instructions.push_str("Provide thorough explanation with examples. ") } _ => {} } if calibration.should_simplify { instructions.push_str("Use simple language, avoid jargon. "); } if calibration.anticipate_followups { instructions.push_str("Suggest what the user might want to explore next. "); } format!("{}\n\nContext:\n{}\n\nQuestion: {}", instructions, context, query) } }
Real-Time Adaptation
Because the rolling window updates on every interaction, fluency --- and therefore calibration --- shifts within a single session. As the user demonstrates more sophisticated interaction patterns, the responses tighten accordingly:
Query 1: "What is NVIDIA worth?"
-> Fluency: intermediate (simple question)
-> Response: Detailed with explanation
Query 2: "Break it down as a table"
-> Fluency: advanced (format spec detected!)
-> Response: Concise table format
Query 3: "Now compare to AMD, bullet points only"
-> Fluency: expert (chaining + constraints)
-> Response: Minimal bullet comparison
Note that none of these shifts depend on the topic (NVIDIA, AMD valuation) --- they are driven entirely by the interaction patterns: a bare question, then a format specification, then chaining plus an explicit constraint.
Constraints
- Never use jargon or keywords as fluency indicators --- domain expertise is not AI fluency.
- Update the profile on every interaction.
- Confidence requires sufficient signal history; blend toward Intermediate when confidence is low.
- Always check both
levelandconfidence. - Respect explicit format requests --- they override calibration defaults.
- Don't over-explain to experts; don't under-explain to novices.
Flows
Overview
A flow is a visual, graph-based definition of agent work. Where a conversation is an ad-hoc chat and a pipeline is a fixed sequence of stages, a flow is an explicit directed graph --- nodes are steps, edges are control flow --- authored in a ReactFlow canvas and executed by an event-driven engine in the Rust Core.
Flows are how Qadra expresses branching, conditions, human-in-the-loop gates, loops, sub-flows, and knowledge operations as a single composable artifact. An agent can trigger a flow from a conversation (via LLM tool-use), or a flow can be executed directly through the API.
The core idea: agents are programs that sometimes call models. A flow makes the program explicit --- you can see the control flow, the branch points, and where a human has to sign off.
| Concept | What it is |
|---|---|
| AgentFlow | The graph definition ({nodes, edges, viewport}). Tenant-scoped, reusable. |
| FlowExecution | A single run of a flow. Has its own state machine and per-node state. |
| Flow Node | One step in the graph. 17 node types, each with its own config and behavior. |
| State (blackboard) | A JSONB dictionary that flows between nodes --- each node reads inputs, writes outputs. |
The Flow Data Model
A flow is stored in two pieces: the editable definition and an immutable snapshot taken at execution time.
flow_data --- the editable definition
agent_flows.flow_data holds the ReactFlow graph verbatim as JSONB:
{
"nodes": [ { "id": "start", "type": "Start", "data": {} }, ... ],
"edges": [ { "id": "e1", "source": "start", "target": "agent-1" }, ... ],
"viewport": { "x": 0, "y": 0, "zoom": 1 }
}
ReactFlow serializes to a single object, so JSONB stores it with no impedance mismatch. The executor always loads the full graph --- it never queries individual nodes --- so there is no benefit to normalizing nodes and edges into separate tables.
CREATE TABLE agent_flows (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
flow_data JSONB NOT NULL DEFAULT '{"nodes":[],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}',
node_count INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT true,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(tenant_id, name)
);
flow_snapshot --- the immutable run copy
When an execution starts, the current flow_data is copied into flow_executions.flow_snapshot. The execution runs entirely against this snapshot, so editing the flow definition mid-run --- or after a run completes --- never changes what an in-flight or historical execution did. This is the same immutability pattern as workloads.pipeline_snapshot.
CREATE TYPE flow_execution_status AS ENUM
('pending','running','paused','completed','failed','cancelled');
CREATE TABLE flow_executions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
flow_id UUID NOT NULL REFERENCES agent_flows(id) ON DELETE CASCADE,
flow_snapshot JSONB NOT NULL,
status flow_execution_status NOT NULL DEFAULT 'pending',
state JSONB NOT NULL DEFAULT '{}',
node_states JSONB NOT NULL DEFAULT '{}',
execution_queue JSONB NOT NULL DEFAULT '[]',
current_node_id TEXT,
error TEXT,
triggered_by UUID REFERENCES users(id) ON DELETE SET NULL,
input JSONB NOT NULL DEFAULT '{}',
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
| Column | Role |
|---|---|
flow_snapshot | Immutable copy of flow_data captured at run start |
state | The blackboard dictionary flowing between nodes |
node_states | Per-node {status, input, output, error, started_at, completed_at} |
execution_queue | Ordered node IDs ready to run next |
current_node_id | Node currently executing (or paused at) |
Execution State Machine
Every execution moves through a fixed set of states:
+-----------+
create -----> | pending |
+-----+-----+
|
v
+-----------+ HumanInput node
| running | <----------------------+
+-----+-----+ |
| |
+---------------+---------------+ |
| | | user submits
v v v input
+-----------+ +-----------+ +-----------+ |
| completed | | failed | | paused | -------+
+-----------+ +-----------+ +-----------+
|
cancel ---------+--> +-----------+
| cancelled |
+-----------+
| Status | Meaning |
|---|---|
pending | Execution created, not yet started |
running | Executor is processing the queue |
paused | Stopped at a HumanInput node, awaiting user response |
completed | An End node was reached |
failed | A node errored or a safety rail was hit |
cancelled | Explicitly cancelled via the API |
The Blackboard State
The state column is a blackboard --- a shared JSONB dictionary that every node can read from and write to. There is no per-node message passing; instead, nodes communicate through named keys in this dictionary.
- A node reads its inputs from state keys (e.g. an Extract node reads
state[input_key]). - A node writes its outputs back to state under a configured
output_key. - Downstream nodes read those keys as their own inputs.
Most nodes use {{variable}} templating in their config to interpolate state values --- for example a KnowledgeLookup node's query can be "financials for {{company}}", where company was written to state by an earlier Extract node.
The execution input (an initial JSONB object) seeds the blackboard at run start --- this is how a conversation passes user_message into a flow it triggers.
The Executor Loop
The engine lives in src/flow_executor.rs. It is an event-driven queue processor, not a recursive tree-walker.
- Start --- Parse the flow snapshot graph, find the Start node, seed the execution queue.
- Loop --- Pop a node from the queue, check that all its predecessors are complete, execute it, store its output to state, enqueue its successors, and persist the full execution row.
- Pause --- A HumanInput node sets the execution to
pausedand stops the loop, waiting for user input. - Resume --- When the user submits input, it is merged into state, the node's successors are re-enqueued, and the loop continues.
- End --- Reaching an End node sets the execution status to
completed.
State is persisted after each step (the executor reads/writes the full node_states and state as a batch, the same pattern as stage_results on workloads), so an execution is always recoverable from its database row.
Safety Rails
Because flows can branch and loop, the executor enforces hard limits to prevent runaway or recursive executions:
| Rail | Limit | Purpose |
|---|---|---|
MAX_TOTAL_STEPS | 500 | Cap on total node executions in one run |
| Max Loop iterations | 100 | Per-Loop-node iteration cap |
| Max Delay | 300s | Longest a Delay node may wait |
Hitting a rail transitions the execution to failed with an explanatory error rather than spinning indefinitely.
HumanInput: Pause and Resume
A HumanInput node is the human-in-the-loop gate. When the executor reaches one:
- The execution status becomes
paused. - The node status becomes
waiting_for_input. - The executor loop stops and persists state.
The execution stays paused --- indefinitely, durably --- until a user submits input via POST /flows/executions/:execId/input (NATS flow.do.human_input). The submitted input is merged into the blackboard state, the node's successors are re-enqueued, and the loop resumes from where it stopped.
The resume path is guarded against a time-of-check/time-of-use race: the resume SQL only updates when the row is still
paused, so two concurrent input submissions cannot both resume the same execution.
Node Types
A flow supports 17 node types. Each has its own data config and execution behavior.
| Node | Purpose | Key config |
|---|---|---|
| Start | Entry point. Seeds the queue. | --- |
| End | Terminal node. Marks the execution completed. | --- |
| Agent | Runs an agent persona via an LLM call, using state as context. | agent selection, prompt context |
| Condition | Branches control flow by evaluating a predicate against state. | condition expression, true/false edges |
| HumanInput | Pauses execution and waits for user input. | prompt, fields |
| Loop | Repeats a sub-section of the graph (capped at 100 iterations). | iteration source, max iterations |
| Http | Makes an outbound HTTP request. | url, method, headers, body |
| ExecuteFlow | Invokes another flow as a sub-flow. | target flow id, input mapping |
| Transform | Reshapes / maps values in the blackboard state. | transform expression, output_key |
| Delay | Waits for a fixed duration (capped at 300s). | duration |
| KnowledgeLookup | Queries the Knowledge Core (SPO/KG), Corpus (vectors), or both. | query (templated), source (knowledge_core | corpus | both), output_key, limit |
| Research | Autonomous research via external neural search (Exa). | query (templated), max_results, output_key |
| Artifact | Produces an artifact from an artifact template. | template_id, artifact_name (templated), content_key, output_key |
| Extract | Uses an LLM to parse structured parameters out of unstructured text. | input_key (default user_message), output_variables (comma-separated), instructions |
| SpoFilter | Reads SPO triples (optionally filtered by asserter) into state for downstream nodes. | asserter filter, output_key |
| SpoWrite | Persists an agent's output as SPO triples with provenance. | content_key, asserter, source, output_key |
| ClaimValidation | Grounds prior-stage agent claims against SPO triples in the Knowledge Core. | input_keys (default scarlet_output,ayana_output), output_key, asserter_filter, query_limit |
Notes on specific nodes
- Extract is the bridge between unstructured conversation and structured flow inputs. It reads
state[input_key], prompts the LLM to return JSON for the requestedoutput_variables, and merges the result into state. If the LLM returns non-JSON, it gracefully sets each variable tonullrather than failing the run. - KnowledgeLookup is the read side of the Knowledge Core.
sourcechooses SPO triples, vector chunks, or both; results land in state underoutput_key. - SpoFilter / SpoWrite are the SPO read/write pair --- a flow can pull facts into state, run agents over them, then write new claims back to the graph with provenance (asserter + source).
- ClaimValidation is the cross-stage groundedness checkpoint: it labels each prior-stage claim SUPPORTED, CONTRADICTED, or NOT_MENTIONED before unverified claims propagate downstream (e.g. into an investment memo). See the Knowledge Graph page for the grounding mechanics.
- Research currently runs as a placeholder pending full Exa integration; the node and config exist so flows authored against it remain valid.
SSE Streaming of Execution Progress
Long-running executions stream progress to the frontend over Server-Sent Events, so a user watching a flow run sees nodes start and complete in real time rather than waiting for one final response.
Because a NATS request-reply only allows a single response, progress cannot be streamed over the reply itself. Instead, a dedicated NATS progress subject is used:
React (SSE consumer) <--SSE-- Gateway <--NATS pub/sub-- Rust Core (FlowExecutor)
- The Gateway creates a unique NATS subject
_PROGRESS.{uuid}and subscribes to it. - It sends the execution request with that subject in the payload.
- The Rust Core's executor publishes
FlowProgressEvent { event, data, seq }messages to the subject as it runs --- fire-and-forget, with a monotonically increasing sequence counter for ordering. - The Gateway forwards each as an SSE event (
event: {type}\ndata: {json}\n\n) and closes the stream ondoneor client disconnect.
Emitted event types include flow_started, node_started, node_completed, node_failed, flow_completed, flow_failed, and done. The same machinery powers streaming flow execution from within a conversation.
NATS Operations and REST Endpoints
Flow subjects are tenant-scoped under qadra.{tid}.flow.do.>, handled by the Rust Core.
| Operation | Subject suffix | Description |
|---|---|---|
create | flow.do.create | Create a flow definition |
get | flow.do.get | Get a flow by ID |
list | flow.do.list | List flows (paginated) |
update | flow.do.update | Update name, description, flow_data, or is_active |
delete | flow.do.delete | Delete a flow |
execute | flow.do.execute | Start an execution (snapshots the flow, runs the graph) |
execution_get | flow.do.execution_get | Get execution state |
execution_list | flow.do.execution_list | List executions for a flow |
execution_cancel | flow.do.execution_cancel | Cancel a running execution |
human_input | flow.do.human_input | Submit human input to a paused execution |
generate | flow.do.generate | AI-generate a flow graph from a natural-language prompt |
These are reached over HTTP through the Gateway at /flows and /flows/executions/... (including POST /flows/:flowId/execute, POST /flows/executions/:execId/input, and the SSE-friendly execution endpoints). See the API Reference for the full request/response shapes --- the flow routes there map one-to-one onto the NATS operations above.
Conversations
Overview
Conversations are ad-hoc chat threads between a user and one or more agents, outside of flow executions and pipeline workloads.
Qadra is a workload orchestration platform, not a chat app --- but conversations exist as a supplementary, informal interaction mode. Where a workload moves through pipeline stages and a flow executes a structured graph, a conversation is a free-form thread. There is no pipeline, no stage progression, no immutable snapshot.
| Surface | Formal? | Structure | Best For |
|---|---|---|---|
| Workload | Yes | Pipeline stages, tasks, artifacts | Multi-stage deals (Research --> Due Diligence --> Memo) |
| Flow | Yes | ReactFlow graph, node state machine | Repeatable, branching workflows |
| Conversation | No | Flat message thread | Informal, direct interaction with an agent |
A conversation can still reach into the formal machinery --- an agent can autonomously trigger a flow from inside a conversation (see Flow Tool-Use) --- but the conversation itself stays informal.
Data Model
Two tables (migration 029): conversations (the thread) and conversation_messages (the turns).
CREATE TABLE conversations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE SET NULL,
title TEXT,
is_archived BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE conversation_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
agent_id UUID REFERENCES agents(id) ON DELETE SET NULL,
role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'system')),
content TEXT NOT NULL,
model TEXT,
token_count INTEGER,
latency_ms INTEGER,
finish_reason TEXT,
rating SMALLINT CHECK (rating IN (-1, 1)),
flow_execution_id UUID REFERENCES flow_executions(id) ON DELETE SET NULL,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Key design choices:
- Soft delete: Conversations carry a
deleted_attimestamp. All queries filterWHERE deleted_at IS NULL--- threads are never hard-deleted. - Analytics columns:
model,token_count,latency_ms, andfinish_reasonlive inline on each message (not in a side table) so training-data export, cost tracking, and latency monitoring are direct SQL queries with no joins. - Rating: Each assistant message can be rated thumbs up (
+1) or thumbs down (-1). - Flow linkage:
flow_execution_idis a real FK linking a message to a flow execution the agent triggered. - Auto-title: The first exchange auto-generates a conversation title from the user's message (truncated to ~60 characters).
Per-Message Agent Switching
The agent_id lives on the message, not the conversation. A user can talk to the Research Analyst, then pivot to the Memo Writer mid-thread --- all in one conversation. User messages have agent_id = NULL; assistant messages carry the agent that produced them.
This is why there is no agent_id column on conversations: locking the whole thread to one agent would force a new conversation just to switch specialists.
The send_message Pipeline
POST /conversations/:id/messages maps to the NATS subject qadra.{tid}.chat.do.send_message, handled in src/nats_service.rs. This is the heart of a conversation --- it runs an end-to-end pipeline for every user turn.
User message
|
v
1. Persist user message (agent_id = NULL)
|
v
2. Epistemic grounding: handle_query() on message content
(SPO lookup, curvature ranking, contradiction + gap detection)
--> Competence Gate runs inside handle_query
|
v
3. Load agent (tenant-scoped) + active flows
|
v
4. Build LLM context:
system_prompt + epistemic grounding (System msg) + flow catalog + history (cap 50)
|
v
5. Call LLM with tools: generate_chart (always) + run_flow (if active flows)
|
+-- generate_chart tool call --> serialize ECharts artifact --> re-call LLM for narration
+-- run_flow tool call --> execute flow inline (45s) --> re-call LLM with outputs
|
v
6. Verification gates (post-synthesis, both LLM-free, both no-op on empty context):
6a. verify_response_rule_based(response, triples) --> metadata["verification"]
6b. verify_with_belnap(parser, response, triples) --> metadata["belnap"]
|
v
7. Persist assistant message (model, latency_ms, token_count, finish_reason, flow_execution_id)
--> auto-title on first exchange
Epistemic Grounding Injection
Before the LLM is called, the user's message is run through the existing epistemic pipeline via handle_query() --- the same machinery behind qadra.{tid}.epistemic.query. This performs SPO triple lookup, curvature-based triple ranking, contradiction detection, and gap detection.
The retrieved facts, contradictions, and knowledge gaps are formatted as a System message and injected after the agent's system prompt, before the conversation history:
FACTS:
(Apple, founded_in_year, 1976) [fact]
CONTRADICTIONS:
(Acme Corp, ceo, John Smith) vs (Acme Corp, ceo, Jane Doe)
KNOWLEDGE GAPS:
No triples found for "discount rate methodology"
Graceful degradation: If the epistemic query fails or returns no triples, the LLM call proceeds without grounding context. No grounding never blocks a response.
LLM Tools
Two tools are injected into the LlmProvider::complete() call:
| Tool | When Injected | Purpose |
|---|---|---|
generate_chart | Always | LLM decides a visualization is appropriate and returns an Apache ECharts option object |
run_flow | Only when the tenant has active flows | LLM maps the user's intent to a flow and triggers its execution |
Both follow a two-call pattern: the first LLM call returns a tool call, the handler executes it, then the handler re-calls the LLM with the result so the agent can narrate what happened rather than dumping raw data.
generate_chart
When the LLM returns a generate_chart tool call (with chart_name and chart_config), the handler serializes the ECharts config as a chart artifact inside metadata.artifacts with type: "chart" and content as the JSON-serialized option. It then makes a follow-up LLM call for narration. No new DB columns or API endpoints --- charts travel in the existing metadata JSONB.
Flow Tool-Use
When the LLM returns a run_flow tool call, the handler:
- Creates a flow execution
- Runs the executor inline (45s timeout) so results appear in the conversation
- Fetches the completed execution state
- Re-calls the LLM with the actual node outputs so the agent narrates real results, not just "started"
- Persists the assistant message with
flow_execution_idset
Flow IDs are constrained via a JSON Schema enum on the tool definition, and the flow catalog is included in the system prompt so the LLM has full context. If the flow exceeds the 45s timeout, the handler falls back to a status message.
KG-SMILE Verification Gate
After synthesis, the response passes through the post-synthesis Verification Gate --- verify_response_rule_based(response, triples, config). It extracts the implied triples from the assistant response and checks whether each is grounded in the context triples returned by handle_query.
The result is a VerificationGateResult stored in the message's metadata["verification"]; ungrounded claims are logged as warnings (and surfaced, never silently suppressed).
#![allow(unused)] fn main() { pub struct VerificationGateResult { pub passed: bool, pub fidelity: f32, pub faithfulness: f32, pub attributions: Vec<AttributedEdge>, pub ungrounded_claims: Vec<UngroundedClaim>, pub reason: Option<String>, } }
No-op when context is empty: If handle_query returned no triples, the gate is skipped and no verification metadata is written. This prevents false negatives when the knowledge graph simply has nothing relevant for the query.
Belnap Four-Valued Gate
Immediately after the rule-based gate, a second, complementary verifier runs --- verify_with_belnap(shared_parser(), response, triples) from the qadra-belnap crate. It UD-parses the response into claims, grounds each against a polarity-tagged SPO graph, and reconciles the claim over Belnap four-valued logic: Grounded (True), Contradicted (False), Contested (Both), or Ungrounded (Neither). The gate fails only when the graph contradicts a claim; contradicted claims are logged as warnings. The BelnapVerification result is stored in metadata["belnap"] (only when the response yields at least one claim).
The parser comes from a process-wide shared_parser(): under the onnx-ort Cargo feature it loads the real ONNX goeswith UD model; otherwise it is a StubParser that makes the gate a passing no-op, so the default build is unchanged. See the Knowledge Graph chapter for the full four-valued model and the perturbation-based attribution path.
Persistence and Analytics
The assistant message is persisted with its analytics columns populated --- model, latency_ms, token_count, finish_reason --- plus flow_execution_id when a flow ran. On the first exchange, the conversation title is auto-generated from the user's message. Because the analytics live inline, cost and latency reporting are plain aggregate queries.
SSE Streaming
The standard send_message is NATS request-reply --- one request, one response, no progress. For real-time progress, POST /conversations/:id/messages/stream streams Server-Sent Events.
The gateway creates a unique NATS progress subject (_PROGRESS.{uuid}), subscribes to it, and passes the subject in the send_message request payload. Rust Core publishes FlowProgressEvent messages to that subject during execution; the gateway forwards each as an SSE event (event: {type}\ndata: {json}\n\n) and closes the stream on done.
React (SSE consumer) <--SSE-- Gateway <--NATS pub/sub-- Rust Core
subscribes to creates _PROGRESS.{uuid} FlowExecutor +
text/event-stream subscribes, forwards nats_service emit
closes on "done" progress events
Events
| Event | Emitted By | Meaning |
|---|---|---|
user_saved | nats_service | User message persisted |
thinking | nats_service | Epistemic grounding + LLM call starting |
flow_started | nats_service | A run_flow tool call kicked off a flow execution |
node_started | FlowExecutor | A flow node began executing |
node_completed | FlowExecutor | A flow node finished |
node_failed | FlowExecutor | A flow node errored |
flow_completed | nats_service | The triggered flow finished |
assistant_message | nats_service | Final assistant message (with any artifacts) |
done | nats_service | Stream complete --- gateway closes the SSE connection |
FlowProgressEvent is { event: string, data: Value, seq: u32 }, defined in crates/qadra-nats/src/messages.rs. A monotonically increasing AtomicU32 sequence counter guarantees ordering. Non-flow messages still emit the subset: user_saved --> thinking --> assistant_message --> done.
Artifacts
When an assistant message carries metadata.artifacts, the frontend renders compact, clickable cards inside the message bubble.
| Component | Role |
|---|---|
| ArtifactCard | Compact card inside an assistant bubble. Shows artifact name, a type icon, and a "Click to open" subtitle. |
| ArtifactPanel | Claude-style 480px slide-out side panel. Renders full artifact content as markdown, supports tab switching for multiple artifacts and copy-to-clipboard. Animated via framer-motion. |
| ChartRenderer | Renders chart artifacts (type: "chart") via Apache ECharts (echarts-for-react), using the Qadra dark theme. SVG renderer with getDataURL() for PNG export. |
Because the full artifact content lives in the side panel, the LLM's follow-up narration prompt asks for a brief summary highlighting key findings rather than dumping the full content into the chat. Chart artifacts travel inside the existing metadata.artifacts JSONB --- no separate artifact API call is needed.
API and NATS Surface
All routes require requireAuth. The gateway translates HTTP to tenant-scoped NATS subjects (qadra.{tid}.chat.do.*).
| Method | Path | NATS Subject | Description |
|---|---|---|---|
POST | /conversations | chat.do.create | Create a conversation |
GET | /conversations | chat.do.list | List conversations (paginated, user-scoped) |
GET | /conversations/:id | chat.do.get | Get conversation by ID |
PATCH | /conversations/:id | chat.do.update_title | Update title |
DELETE | /conversations/:id | chat.do.delete | Soft-delete (sets deleted_at) |
POST | /conversations/:id/messages | chat.do.send_message | Send message + get LLM response (60s) |
GET | /conversations/:id/messages | chat.do.list_messages | List messages (paginated) |
POST | /conversations/:id/messages/:messageId/rate | chat.do.rate_message | Rate a message (+1 / -1) |
POST | /conversations/:id/messages/stream | chat.do.send_message | SSE streaming send (65s) |
Both list_messages and the underlying queries enforce tenant isolation --- messages are scoped to conversations owned by the requesting tenant (WHERE c.tenant_id = $1 AND c.deleted_at IS NULL).
Plugins
Overview
Plugins are lightweight, versioned QuickJS programs that perform data transformation without model calls. They complement agents --- where agents call models to reason, plugins deterministically reshape, check, and score data in the processing pipeline.
The philosophy: write plugins in the Workbench, execute them in the platform. The Workbench (the authoring UI) handles writing, bundling, and testing. The platform only runs pre-validated code.
| Property | Plugin | Agent |
|---|---|---|
| Calls a model? | No | Yes |
| Execution | QuickJS sandbox | LLM inference |
| Purpose | Transform / check / score data | Reason and synthesize |
| Determinism | Deterministic | Probabilistic |
Plugin Types
There are three plugin types, each with a fixed input/output contract:
| Type | Purpose | Contract |
|---|---|---|
| Extractor | Transform unstructured → structured | extract(input) → { data, confidence } |
| Validator | Check data against rules | validate(input) → { valid, errors } |
| Evaluator | Score content quality | evaluate(input) → { score, passed, feedback } |
Extractor Contract
An extractor takes structured data plus configuration and returns extracted data with a confidence score (0.0--1.0).
// Input: any structured data + configuration
// Output: extracted data with confidence score
function extract(input) {
const { data, config } = input;
// Process data...
return {
data: { /* extracted fields */ },
confidence: 0.95 // 0.0-1.0, how confident in extraction
};
}
Validator Contract
A validator checks data against rules and returns a pass/fail flag with a list of error strings.
// Input: data to validate + rules
// Output: pass/fail with error list
function validate(input) {
const { data, rules } = input;
const errors = [];
// Check rules...
if (!data.required_field) {
errors.push("Missing required field");
}
return {
valid: errors.length === 0,
errors: errors // String array
};
}
Evaluator Contract
An evaluator scores content against criteria and returns a score, a pass/fail decision against a threshold, and human-readable feedback.
// Input: content to evaluate + criteria
// Output: score, pass/fail, feedback
function evaluate(input) {
const { content, criteria, threshold = 0.7 } = input;
// Score content...
const score = calculateScore(content, criteria);
return {
score: score, // 0.0-1.0
passed: score >= threshold,
feedback: "Content meets 85% of criteria"
};
}
Unified Schema
All three plugin types share a single plugins table with a plugin_type discriminator column, rather than separate tables per type.
CREATE TYPE plugin_type AS ENUM ('extractor', 'validator', 'evaluator');
CREATE TABLE plugins (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id),
-- Identity (versioned by name within type)
plugin_type plugin_type NOT NULL,
name TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
description TEXT,
-- Code storage
code TEXT NOT NULL, -- Source JavaScript
bundled_code TEXT, -- esbuild output (optional)
-- NPM dependencies (for Workbench bundling)
dependencies JSONB DEFAULT '{}',
-- Schema definitions
input_schema JSONB, -- JSON Schema for input
output_schema JSONB, -- JSON Schema for output
-- Metadata
is_active BOOLEAN DEFAULT true,
is_global BOOLEAN DEFAULT false,
UNIQUE(tenant_id, plugin_type, name, version)
);
Why a Single Table
A single table with a discriminator wins over separate extractors / validators / evaluators tables because the three types are structurally identical:
- Same storage needs --- code, bundled_code, dependencies, input/output schemas
- Same execution model --- all run in QuickJS
- Same API patterns --- CRUD plus execute
- Extensible --- adding a new plugin type later is trivial; no new table, no new migration shape
The is_global flag and the (tenant_id, plugin_type, name, version) uniqueness constraint do the rest of the work that separate tables would otherwise require.
Execution Model
Plugins run in isolated QuickJS contexts. Each execution gets a fresh, fully isolated context --- no shared state, no escape hatches.
Sandbox Limits
| Limit | Value |
|---|---|
| Memory | 4MB (lighter than renderers) |
| Stack | 256KB |
| Filesystem access | None |
| Network access | None |
| Module imports | None (use bundled_code for dependencies) |
Because there is no module system in the sandbox, NPM dependencies must be pre-bundled into self-contained JavaScript (bundled_code) by the Workbench before publishing.
Execution Functions
#![allow(unused)] fn main() { // Execution functions (source code) execute_extractor(code, input, tenant_id) → ExtractResult execute_validator(code, input, tenant_id) → ValidateResult execute_evaluator(code, input, tenant_id) → EvaluateResult // Bundled variants (esbuild output) execute_bundled_extractor(bundled_code, input, tenant_id) execute_bundled_validator(bundled_code, input, tenant_id) execute_bundled_evaluator(bundled_code, input, tenant_id) }
Built-in Plugins
A set of global plugins is seeded in migration and available to all tenants out of the box:
| Type | Plugin | Purpose |
|---|---|---|
| Extractor | json-path | Extract fields using dot-notation paths |
| Extractor | regex-extract | Extract data using regex patterns |
| Validator | required-fields | Check required fields are present |
| Validator | type-check | Validate field types |
| Validator | range-check | Validate numeric ranges |
| Evaluator | completeness | Score based on filled vs. expected fields |
| Evaluator | length-check | Evaluate content length |
| Evaluator | keyword-coverage | Check presence of expected keywords |
Global vs. Tenant Plugins
Plugins are either global (available to every tenant) or tenant-specific.
| Attribute | Global | Tenant-specific |
|---|---|---|
| Created by | Admins only | Any authorized user |
| Visible to | All tenants | Single tenant |
| Stored with | Global tenant UUID | User's tenant UUID |
| Modify / Delete | Admins only | Tenant users |
Precedence: when a tenant-specific plugin and a global plugin share the same name, the tenant-specific plugin wins. This lets a tenant override a built-in with its own implementation without affecting other tenants.
Versioning
Plugins are versioned by the tuple (tenant_id, plugin_type, name, version):
- Creating a plugin with an existing name bumps the version rather than overwriting
get_plugin_by_namereturns the latest active version- Old versions remain in place for audit and rollback
Error Handling
Plugin code returns structured results, not exceptions. A plugin should never rely on throwing to signal failure --- it should return a result that conveys the failure.
// BAD: throws and crashes
function extract(input) {
throw new Error("Failed");
}
// GOOD: returns error in result (handled by wrapper)
function extract(input) {
if (!input.data) {
return { data: null, confidence: 0 };
}
// ...
}
The Rust execution wrapper still catches any JavaScript exceptions that do escape and converts them into typed Rust errors --- but the contract is to fail gracefully through the return value.
REST Endpoints
| Method | Path | Description |
|---|---|---|
GET | /v1/plugins | List all plugins |
GET | /v1/plugins/type/:type | List plugins by type |
GET | /v1/plugins/:type/:name | Get a specific plugin |
POST | /v1/plugins | Create a plugin |
POST | /v1/plugins/:type/:name/execute | Execute a plugin |
PATCH | /v1/plugins/:type/:name/bundle | Update bundled code |
DELETE | /v1/plugins/:type/:name | Deactivate (soft delete) |
Workbench Integration
The Workbench (authoring UI) owns the full plugin lifecycle up to publishing:
- Plugin authoring with syntax highlighting
- NPM dependency resolution
- esbuild bundling into self-contained JavaScript
- Testing against sample inputs
- Publishing to the platform via
POST /v1/plugins
The platform executes only pre-validated, pre-bundled code --- keeping the runtime simple, sandboxed, and fast.
Implementation
| File | Purpose |
|---|---|
migrations/009_plugins.sql | Database schema + seeded built-in plugins |
crates/qadra-traits/src/lib.rs | Plugin types, PluginRepository trait |
crates/qadra-db-postgres/src/lib.rs | PluginRepository implementation |
src/runtime/plugin.rs | QuickJS execution functions |
src/api/routes/v1/plugins.rs | REST API handlers |
JavaScript Runtime (QuickJS)
Qadra executes sandboxed JavaScript inside an embedded QuickJS runtime. This is the engine that powers the Plugins system---extractors, validators, and evaluators are all lightweight QuickJS programs that transform data without calling models. Agents call models; plugins transform data; both rely on isolation, but only the runtime makes per-plugin isolation cheap enough to be practical.
Why QuickJS
The plugin pattern requires instant-on, isolated runtimes. Every plugin execution should get a fresh, sandboxed context with no shared state---and that has to be cheap, because the platform spins up thousands of them.
| Runtime | Memory | Cold start | Instances/GB |
|---|---|---|---|
| V8/Node | 30MB | 50ms | ~30 |
| QuickJS | 500KB | <1ms | ~2,000 |
The numbers are the whole argument. V8/Node gives you a fast, mature engine, but at ~30MB and ~50ms per instance you can only afford a few dozen per gigabyte. At that cost, the natural workaround is to stop isolating---batch many transformations into one big compound prompt or one shared context. That collapses the plugin model.
QuickJS is ~60x lighter and starts sub-millisecond, so isolation stays affordable. Plugins need isolation, not raw throughput, and that is exactly the trade QuickJS makes.
Rust integrates QuickJS via the rquickjs crate:
[dependencies]
rquickjs = { version = "0.4", features = ["full", "parallel"] }
Instance Pool
Runtimes are pre-created and pooled rather than spun up on demand. A semaphore bounds concurrency, each pooled runtime carries a memory limit, and executions are capped by a wall-clock timeout.
#![allow(unused)] fn main() { pub struct QuickJSPool { runtimes: Vec<Arc<Mutex<Runtime>>>, available: Semaphore, max_memory: usize, max_execution_time: Duration, } impl QuickJSPool { pub fn new(size: usize) -> Self { let runtimes = (0..size) .map(|_| { let rt = Runtime::new().expect("QuickJS runtime creation"); rt.set_memory_limit(1024 * 1024); // 1MB per instance Arc::new(Mutex::new(rt)) }) .collect(); Self { runtimes, available: Semaphore::new(size), max_memory: 1024 * 1024, max_execution_time: Duration::from_secs(5), } } } }
The pool size is configurable via QUICKJS_POOL_SIZE (default 100).
Execution Contract
Executing a plugin acquires a permit, takes an available runtime, builds a full context, injects globals, and runs the code under a timeout. The result is deserialized back into a Rust value.
#![allow(unused)] fn main() { pub async fn execute_atomic( pool: &QuickJSPool, code: &str, context: &ResolvedContext, ) -> Result<AtomicResult, RuntimeError> { let _permit = pool.available.acquire().await?; let runtime = pool.get_available(); let ctx = Context::full(&runtime)?; ctx.with(|ctx| { // Inject context as global let global = ctx.globals(); global.set("context", serialize_context(context)?)?; // Inject inference bridge (calls back to Rust) global.set("inference", create_inference_bridge())?; // Execute with timeout let result = tokio::time::timeout( pool.max_execution_time, async { ctx.eval::<Value, _>(code) } ).await??; // Parse result Ok(deserialize_result(&result)?) }) } }
Two things are always injected into the global scope before execution:
context--- the resolved input data the plugin operates oninference--- a bridge function that calls back into Rust for model APIs
Memory and Stack Limits
Each QuickJS instance runs under hard limits. There is no shared memory between instances---an allocation in one cannot affect another.
| Limit | Value |
|---|---|
| Base memory | 500KB |
| Execution context | up to 1MB |
| Max stack size | 256KB |
#![allow(unused)] fn main() { // Set memory limit runtime.set_memory_limit(1024 * 1024); // 1MB // Set max stack size runtime.set_max_stack_size(256 * 1024); // 256KB stack }
Plugins are even more tightly bounded than general atomics: the plugin execution model caps memory at 4MB with a 256KB stack, reflecting that data transformation is lighter work than model-driven reasoning.
What Sandboxed JavaScript Can and Cannot Do
The runtime is a sealed box. Code gets standard ECMAScript and the injected globals---nothing else.
CAN:
- Use standard ECMAScript (ES2020)
- Access the provided
contextobject - Call
inference()for model APIs (via the bridge) - Return structured results via
return
CANNOT:
- Access the filesystem (
fsis not available) - Make arbitrary network calls (only through the bridge)
- Import npm packages (no module system---dependencies must be pre-bundled into self-contained JavaScript via esbuild)
- Share state with any other instance
This is what makes plugins safe to run untrusted: there is no I/O surface a plugin can reach except the explicit bridge functions Rust hands it.
Bridge Functions
The only way sandboxed code reaches the outside world is through bridge functions injected by Rust. The primary one is inference(), which lets a plugin call the LLM provider:
#![allow(unused)] fn main() { fn create_inference_bridge() -> impl Fn(Value) -> Promise { |request: Value| async move { // Parse request from JS let req: InferenceRequest = serde_json::from_value(request)?; // Call Rust LLM provider let response = llm_provider.complete(req).await?; // Return to JS Ok(serde_json::to_value(response)?) } } }
From inside the plugin, the bridge looks like an ordinary async function:
async function execute(context) {
const response = await inference({
model: 'haiku',
prompt: buildPrompt(context),
maxTokens: 500,
});
return {
success: true,
output: response.content,
};
}
Every bridge is a deliberate hole in the sandbox. If a capability is not bridged, plugin code simply cannot do it.
Error Boundaries
QuickJS instances are fully isolated, so a misbehaving plugin is contained to its own execution.
| Boundary | Mechanism |
|---|---|
| Timeout | tokio::time::timeout aborts runaway execution (default 5s) |
| Memory | QuickJS enforces the per-instance memory limit natively |
| Isolation | A crash in one instance never affects others---each execution gets a fresh context |
#![allow(unused)] fn main() { // Timeout protection match tokio::time::timeout(Duration::from_secs(5), execution).await { Ok(result) => result, Err(_) => Err(RuntimeError::Timeout), } // Memory protection (built into QuickJS) runtime.set_memory_limit(max_memory); // Instance crash doesn't affect others // Each execution gets fresh context }
Plugin code should return errors as structured results rather than throwing; the execution wrapper also catches JavaScript exceptions and converts them into typed Rust errors.
Performance Targets
| Metric | Target |
|---|---|
| Instance spawn | <100us |
| Context creation | <500us |
| Cold start | <1ms |
| Memory per instance | <1MB |
| Max instances/GB | >1,000 |
The total overhead budget is <2ms per execution, excluding the plugin's own work.
Instance Lifecycle
A single execution flows through six steps, each with a tight time budget:
acquire() -> create_context() -> inject_globals() -> execute() -> collect() -> release()
| | | | | |
<10us <500us <100us varies <10us <10us
- acquire --- take a permit + runtime from the pool
- create_context --- build a fresh full context
- inject_globals --- set
contextandinference - execute --- run the code under the timeout (the only variable-duration step)
- collect --- deserialize the return value
- release --- return the permit to the pool
Monitoring
The pool exposes counters for observability:
#![allow(unused)] fn main() { pub struct PoolMetrics { pub total_executions: AtomicU64, pub active_instances: AtomicU32, pub timeouts: AtomicU64, pub memory_exceeded: AtomicU64, pub avg_execution_time: AtomicU64, } }
Watching timeouts and memory_exceeded surfaces plugins that are hitting their boundaries; active_instances against the pool size shows saturation.
Infrastructure
This section covers how to run Qadra---the Docker services, messaging, configuration, and operational concerns.
Docker Stack
Qadra runs as a Docker Compose stack. All services are containerized for consistency across development, staging, and production.
Core Services
| Service | Container | Port | Purpose |
|---|---|---|---|
| nats | qadra-nats | 4222, 8222 | NATS JetStream message bus (Core <-> Agent <-> Gateway) |
| postgres | qadra-postgres | 5432 | PostgreSQL 16 + pgvector for tenant data, embeddings, SPO index |
| mongodb | qadra-mongodb | 27017 | Audit traces, NATS audit log |
| qadra-core | qadra-core | 3000 (internal) | Rust truth layer -- NATS handlers for epistemic, workload, auth, flows |
| qadra-agent | qadra-agent | 8080 (internal) | Python orchestration layer -- LLM calls, research, agentic loops |
| qadra-gateway | qadra-gateway | 4000 | Node.js HTTP gateway -- JWT, CORS, file uploads, routes to NATS |
| qadra-ui | qadra-ui | 8080 (internal) | Open WebUI fork |
| kong | qadra-kong | 8000, 8001 | API Gateway -- JWT validation, rate limiting, routing |
| minio | qadra-minio | 9000, 9001 | S3-compatible object storage (file uploads, avatars, tenant logos) |
| imaginary | qadra-imaginary | 9002 | HTTP-based image processing (resize, crop, format conversion) |
Service Responsibilities:
- nats: The message bus. All inter-service communication flows through NATS request-reply. JetStream enabled for durable streams. Monitoring at
:8222. - postgres: The source of truth. All authoritative state, embeddings (pgvector HNSW), and SPO triples.
- mongodb: Append-only audit logs. Decision trees, request traces, NATS audit trail (
nats_auditcollection). - qadra-core: The truth layer. Stateless Rust application that handles all NATS subjects (
qadra.*). Eight handler groups: epistemic, workload, auth, email, audit, observer, admin, flow. - qadra-agent: The intelligence layer. Python service that handles LLM-driven orchestration, workload execution, agent queries, and document verification.
- qadra-gateway: The HTTP surface. Express server that translates HTTP requests into NATS messages. Handles JWT creation/validation, CORS, file uploads to MinIO, and image processing via Imaginary. Never touches PostgreSQL directly.
- qadra-ui: The legacy frontend. Open WebUI fork with Qadra branding.
- kong: The single entry point for external traffic. JWT validation, rate limiting, routing, SSL termination.
- minio: S3-compatible file storage. Stores avatars, tenant logos, uploaded documents. Gateway uploads directly; metadata recorded in PostgreSQL via NATS.
- imaginary: Image processing microservice. Resizes avatar uploads to 256x256 WebP. Falls back gracefully if unavailable.
Observability Stack
| Service | Container | Port | Purpose |
|---|---|---|---|
| otel-collector | qadra-otel-collector | 4317, 4318, 8889 | OpenTelemetry Collector |
| loki | qadra-loki | 3100 | Log aggregation |
| prometheus | qadra-prometheus | 9090 | Metrics storage (30d retention, 5GB max) |
| grafana | qadra-grafana | 3200 | Visualization and alerting |
| cadvisor | qadra-cadvisor | 8088 | Container metrics |
Why a full observability stack?
AI workloads are opaque by nature. When a task takes 30 seconds, you need to know: Is it waiting for an LLM? Database query? NATS timeout? The observability stack answers these questions:
- Traces (OTel): See the full request path, including LLM calls and database queries
- Metrics (Prometheus): Track throughput, latency percentiles, error rates
- Logs (Loki): Search across all services for debugging
- Dashboards (Grafana): Visualize everything in one place
- Container Metrics (cAdvisor): CPU, memory, network, disk per container
Starting the Stack
# Clone and enter directory
cd qadra
# Copy environment template
cp .env.example .env
# Edit environment variables
vim .env
# Start all services
docker compose up -d
# View logs for a specific service
docker compose logs -f qadra-core
docker compose logs -f qadra-gateway
# Stop services
docker compose down
Environment Variables
Required Variables
# Database
DATABASE_URL=postgres://qadra:password@postgres:5432/qadra
POSTGRES_PASSWORD=your_secure_password
# Authentication
JWT_SECRET=your_jwt_secret_min_32_chars
JWT_REFRESH_SECRET=your_refresh_secret_min_32_chars
# LLM Provider
OPENROUTER_API_KEY=sk-or-v1-...
Optional Variables
# MongoDB (for audit traces)
MONGO_PASSWORD=qadra_traces_dev
# Email (SMTP2GO)
SMTP2GO_API_KEY=api-...
SMTP2GO_SENDER=noreply@qadra.dev
EMAIL_VERIFICATION_URL=http://localhost:5173/verify-email
# File Storage (MinIO)
MINIO_ROOT_USER=qadra
MINIO_ROOT_PASSWORD=qadra_minio_dev
MINIO_ENDPOINT=http://localhost:9000
MINIO_BUCKET=qadra-files
# Image Processing
IMAGINARY_URL=http://localhost:9002
# LLM (additional)
CEREBRAS_API_KEY=csk-...
# Python Agent
EXA_API_KEY=... # Exa neural search for autonomous research
QADRA_LLM_MODEL=google/gemini-3-flash-preview
# Observability
OTEL_ENABLED=true
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
GRAFANA_PASSWORD=your_grafana_password
# Server
PORT=3000
RUST_LOG=info
Resource Limits
All containers have memory limits to prevent runaway processes from affecting other services.
| Service | Memory Limit | Memory Reserved | Why This Limit |
|---|---|---|---|
| postgres | 2GB | 512MB | Handles embeddings (vector ops are memory-intensive) |
| mongodb | 1GB | 256MB | Audit writes are small; WiredTiger manages its cache |
| qadra-core | 1GB | 256MB | Rust is memory-efficient; mostly handles async I/O |
| qadra-agent | 512MB | 128MB | Python orchestrator; LLM calls are I/O-bound |
| qadra-gateway | 256MB | 64MB | Thin HTTP layer; file uploads stream to MinIO |
| minio | 512MB | 128MB | Object storage; mostly disk I/O |
| imaginary | 256MB | 64MB | Image processing; handles one image at a time |
| nats | 256MB | 64MB | Message bus; JetStream persistence to disk |
| prometheus | 1GB | 256MB | Stores 30 days of metrics locally |
| grafana | 512MB | 128MB | Dashboard rendering is lightweight |
What happens when limits are hit?
- Soft limit (reservation): Guaranteed minimum; service will not be evicted below this
- Hard limit: If exceeded, container is killed and restarted
- Postgres shared_buffers: Set to 25% of memory limit (512MB)
Network Configuration
All services run on the qadra-network Docker network:
networks:
default:
name: qadra-network
Internal service discovery uses container names:
postgres:5432mongodb:27017nats:4222minio:9000imaginary:9000(internal port; host-mapped to 9002)loki:3100otel-collector:4317
NATS Message Bus
NATS is the backbone of Qadra's inter-service communication. All three application containers (Core, Agent, Gateway) connect to NATS.
Architecture
React SPA (:5173)
|
v HTTP
Node.js Gateway (:4000)
|
v NATS request-reply
Rust Core (:3000 internal) <--> Python Agent (:8080 internal)
| |
v v
PostgreSQL / MongoDB LLM Providers (OpenRouter)
Subject Hierarchy
| Subject Pattern | Handler | Purpose |
|---|---|---|
qadra.{tenant}.epistemic.> | Rust Core | KG queries, triples, attribution, SPO |
qadra.{tenant}.workload.do.> | Rust Core | Workload CRUD and lifecycle |
qadra.{tenant}.flow.do.> | Rust Core | Flow CRUD and execution |
qadra.{tenant}.chat.do.> | Rust Core | Conversation CRUD and messaging |
qadra.auth.> | Rust Core | Authentication (login, register, tokens) |
qadra.email.> | Rust Core | Email operations (templates, delivery) |
qadra.audit.> | Rust Core | Audit log queries |
qadra.admin.> | Rust Core | Super admin operations |
qadra.> | Rust Core (observer) | Catch-all to MongoDB nats_audit |
qadra.{tenant}.workload.execute | Python Agent | Full pipeline orchestration |
qadra.{tenant}.agent.query | Python Agent | Agent loop (synthesis, research) |
Monitoring
NATS monitoring is available at http://localhost:8222:
/healthz-- health check/connz-- active connections/subsz-- subscription details/jsz-- JetStream status
Kong API Gateway
Kong is the single entry point for all external traffic. It sits in front of the Gateway and UI.
Why Kong?
- JWT validation: Verify tokens before requests reach the application
- Rate limiting: Protect against abuse without application logic
- Routing: Direct traffic to API vs. UI based on path
- SSL termination: Handle HTTPS at the edge
- Load balancing: Distribute requests across multiple instances
Kong runs in DB-less mode with declarative configuration (no database needed):
Configuration (kong/kong.yml)
_format_version: "3.0"
services:
- name: qadra-gateway
url: http://qadra-gateway:4000
routes:
- name: api-route
paths:
- /api
strip_path: true
- name: qadra-ui
url: http://qadra-ui:8080
routes:
- name: ui-route
paths:
- /
plugins:
- name: jwt
config:
secret_is_base64: false
claims_to_verify:
- exp
- name: rate-limiting
config:
minute: 100
policy: local
Header Injection
Kong validates JWTs and injects headers downstream:
X-User-Email: user@example.com
X-Tenant-Id: uuid
Authorization: Bearer <validated-token>
Observability
Observability is not optional for AI systems. When an agent takes 45 seconds to produce a report, you need to understand where time is spent.
OpenTelemetry Pipeline
Services (Rust Core, Gateway, Agent)
|
v OTLP (gRPC :4317 / HTTP :4318)
OTel Collector
|
+---> Loki (logs)
+---> Prometheus (metrics, :8889)
|
v
Grafana (visualization, :3200)
Configuration files:
deploy/otel/
├── otel-collector-config.yaml # OTLP receivers, processors, exporters
├── prometheus.yml # Scrape targets (otel-collector, cadvisor)
├── grafana-datasources.yml # Loki + Prometheus datasource provisioning
├── grafana-alerting.yml # Alert rules, contact points, notification policies
└── dashboards/
├── qadra-overview.json # OTel pipeline metrics, logs, collector health
├── container-monitoring.json # cAdvisor container CPU/memory/network/disk
├── kg-smile.json # KG-SMILE gate pass/fail + coverage/connectivity, Story 104
└── eval-quality.json # Agent output-quality eval scores per agent vs threshold, Story 94
Accessing Grafana
URL: http://localhost:3200
Username: admin
Password: (from GRAFANA_PASSWORD, default: qadra-grafana)
Pre-configured Dashboards:
- Qadra Overview (
/d/qadra-overview) -- Request rates, error rates, OTel pipeline health - Container Monitoring (
/d/qadra-containers) -- CPU, memory, network, disk per container - KG-SMILE -- KG-SMILE gate pass/fail + coverage/connectivity, fed by
kg_smile.gate_runtracing events (Story 104) - Eval Quality -- Agent output-quality eval scores per agent vs threshold (Story 94), fed by
qadra.evaltracing events
Alert Rules
8 provisioned alerts in Grafana:
| Alert | Condition | Severity |
|---|---|---|
| High Container CPU Usage | >80% for 5m | warning |
| High Container Memory Usage | >1GB for 5m | warning |
| Critical Container Memory Usage | >2GB for 2m | critical |
| Prometheus Scrape Target Down | down for 2m | critical |
| OTel Collector Export Errors | failures for 5m | warning |
| Container Restarted | restart detected | warning |
| High Error Rate in Logs | >10 errors in 5m | warning |
| Low Disk Space | >85% filesystem | warning |
Database Migrations
Migrations are stored in migrations/ and run via sqlx. There are currently 30 migration files (001 through 030).
# Install sqlx-cli if needed
cargo install sqlx-cli
# Run migrations
DATABASE_URL=postgres://qadra:password@localhost:5432/qadra sqlx migrate run
# Create new migration
sqlx migrate add <name>
# Revert last migration
sqlx migrate revert
# Generate offline query data for CI
cargo sqlx prepare
Key Migration Groups:
| Migrations | Domain |
|---|---|
| 001-005 | Foundation: tenants, agents, graphs, SPO index, memory |
| 006-007 | Users and workloads (pipelines, tasks, artifacts) |
| 008-009 | Plugins and renderers |
| 010-016 | Attribution, documents, epistemic metadata, entity aliases, indexes |
| 017-018 | Orchestration patterns, pipeline data forwarding |
| 019-024 | Multi-tenant users, email templates, team invites, file storage, notifications, super admin |
| 025 | Agent flows (visual workflows with execution engine) |
| 026-028 | Agent persona fields, artifact templates, agent persona refactor (drop atomics model) |
| 029 | Conversations (direct agent chat) |
| 030 | Document proposals (human-gated ingestion) |
Health Checks
Gateway Health
# Liveness
curl http://localhost:4000/health
# NATS connectivity
curl http://localhost:4000/health/ready
Container Health
docker compose ps
All containers should show healthy status. Each service has a configured healthcheck in docker-compose.yml.
Backup and Recovery
PostgreSQL Backup
# Backup
docker exec qadra-postgres pg_dump -U qadra qadra > backup.sql
# Restore
docker exec -i qadra-postgres psql -U qadra qadra < backup.sql
MongoDB Backup
# Backup
docker exec qadra-mongodb mongodump --out /backup
# Restore
docker exec qadra-mongodb mongorestore /backup
MinIO Backup
MinIO data is stored in the minio-data Docker volume. For backup, use the mc (MinIO Client) tool or mount the volume externally.
Scaling
Horizontal Scaling
Qadra Core and Gateway are stateless and can be horizontally scaled:
services:
qadra-core:
deploy:
replicas: 3
NATS distributes messages across subscribers automatically.
Database Scaling
For production:
- Use managed PostgreSQL (RDS, Cloud SQL)
- Enable read replicas for query distribution
- Use connection pooling (PgBouncer)
Observability & Audit Tracing
Qadra runs two complementary observability systems. They answer different questions and are stored in different backends:
| System | Backend | Answers | Shape |
|---|---|---|---|
| Structured logs & metrics | OTel Collector -> Loki / Prometheus -> Grafana | "What happened across the fleet, and when?" | Flat, time-ordered events |
| Audit decision traces | MongoDB | "Why did this request produce that output?" | Hierarchical per-request decision trees |
Both reference the same trace_id, so a log line in Loki and its full decision tree in MongoDB can be correlated. Logs are for operational troubleshooting; audit traces are for compliance, debugging, and post-hoc analysis of agent behaviour.
The Grafana dashboards (Qadra Overview, Container Monitoring, KG-SMILE, Eval Quality) and alert rules are documented in Infrastructure. This page covers the observability model — how data is produced, correlated, and retained.
System 1: Structured Logs & Metrics (OpenTelemetry)
Pipeline
Every service emits over OTLP to a central collector, which fans out to log and metric stores:
Services (Rust Core, Gateway, Agent)
|
v OTLP (gRPC :4317 / HTTP :4318)
OTel Collector
|
+---> Loki (logs)
+---> Prometheus (metrics, :8889)
| |
| v
+------> Grafana (visualization, :3200)
The collector receives, batches, and routes telemetry. No service talks to Loki or Prometheus directly — everything flows through the collector so the export targets can change without touching service code.
OTel Collector Config
# otel-collector-config.yaml
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
batch:
timeout: 1s
send_batch_size: 1024
exporters:
prometheus:
endpoint: "0.0.0.0:8889"
# Loki accepts OTLP natively via /otlp (the legacy `loki` exporter was removed from contrib)
otlp_http/loki:
endpoint: http://loki:3100/otlp
service:
pipelines:
logs:
receivers: [otlp]
processors: [batch]
exporters: [otlp_http/loki]
metrics:
receivers: [otlp]
processors: [batch]
exporters: [prometheus]
Structured Log Format
All services emit structured JSON to stdout. The collector ingests it; nobody parses free-form text.
{
"timestamp": "2026-01-20T12:00:00.000Z",
"level": "info",
"message": "Task completed",
"service": "qadra-core",
"trace_id": "abc123",
"span_id": "def456",
"tenant_id": "00000000-0000-0000-0000-000000000001",
"fields": {
"task_id": "...",
"duration_ms": 152
}
}
Required Fields
Every log entry MUST carry these for correlation and tenant scoping:
| Field | Source |
|---|---|
service | Service name (qadra-core, qadra-gateway, etc.) |
trace_id | From OTel context or X-Request-Id |
tenant_id | From auth context (when authenticated) |
Correlation
Every request gets a trace_id propagated across service boundaries, so a single user action can be reassembled from logs emitted by Gateway, Core, and Agent:
| Header | Purpose |
|---|---|
traceparent | W3C Trace Context (standard) |
X-Request-Id | Fallback for non-OTel clients |
Log Levels
| Level | Use |
|---|---|
error | Unrecoverable failures, requires attention |
warn | Degraded but functional, potential issues |
info | Key business events (request start/end, task complete) |
debug | Detailed flow for troubleshooting |
trace | Everything (development only) |
KG-SMILE Gate-Run Metrics
KG-SMILE attribution sessions are persisted to PostgreSQL (attribution_sessions, triple_attributions) and emitted as structured tracing::info events so they can be dashboarded without a dedicated metrics endpoint. The events use the target kg_smile.gate_run and carry fields gate, result, coverage, connectivity (plus fidelity, faithfulness, stability once the verification gate is wired).
These flow through the same OTLP -> Collector -> Loki pipeline as everything else — no new exporter, no /metrics scrape. The KG-SMILE Grafana dashboard computes pass/fail rates and average gate scores via LogQL over these log lines:
# Competence-gate pass/fail rate
sum by (result) (
rate({container="qadra-core"} |= "kg_smile.gate_run" | json | gate="competence" [5m])
)
# Average coverage over time
avg_over_time({container="qadra-core"} |= "kg_smile.gate_run" | json | unwrap coverage [5m])
The agent output-quality eval signal follows the same pattern: qadra.eval tracing events feed the Eval Quality dashboard with per-agent scores versus threshold. Both signals are log-derived metrics — emit a structured event, query it with LogQL.
System 2: Audit Decision Traces (MongoDB)
Where logs are flat, audit traces are hierarchical. Every request produces a single trace document in MongoDB capturing the full decision tree — which agent ran, which atomics it spawned, which requirements resolved, which SPO/LLM calls fired, and how they nested.
MongoDB Connection
MONGODB_URL=mongodb://qadra:qadra_traces_dev@localhost:27017/qadra_traces?authSource=admin
Trace Structure
Trace (root document)
├── trace_id (UUIDv7 — time-sortable)
├── tenant_id
├── request_id
├── method, path
├── input (sanitized request body)
├── output (response body)
├── duration_us
├── status (success | error | timeout | rate_limited)
├── decisions[] (hierarchical tree)
│ └── agent_invoke
│ ├── input, output, metadata
│ └── children[]
│ └── atomic_spawn
│ └── children[]
│ └── requirement_resolve
│ └── children[]
│ ├── capability_match → spo_query
│ ├── embedding_generate
│ └── memory_search
└── metrics (aggregated)
├── total_tokens_in, total_tokens_out
├── llm_call_count, spo_query_count
└── cache_hits / misses
Decision Types
Each node in the tree is typed by the layer that produced it:
| Type | Layer | Description |
|---|---|---|
spo_query | SPO/Retrieval | Subject-Predicate-Object index lookup |
curvature_compute | SPO/Retrieval | Ricci curvature calculation |
radius_adapt | SPO/Retrieval | Adaptive radius based on topology |
requirement_resolve | Resolution | Semantic requirement matching |
capability_match | Resolution | Agent/tool capability search |
memory_search | Resolution | Memory/RAG retrieval |
embedding_generate | Resolution | Embedding generation |
atomic_spawn | Execution | QuickJS context acquisition |
agent_invoke | Execution | Agent execution |
tool_call | Execution | Tool invocation |
llm_call | Execution | LLM inference call |
task_start | Workloads | Task execution started |
task_progress | Workloads | Task progress update |
artifact_create | Workloads | Artifact produced |
Recording Decisions
Decisions are opened and closed explicitly; nested decisions automatically parent to the open one:
#![allow(unused)] fn main() { // Start a decision let decision_id = trace_ctx.start_decision(DecisionType::AgentInvoke); // Update with metadata trace_ctx.update_decision(decision_id, |d| { d.input = json!({ "agent_name": &agent_name }); d.metadata.agent_name = Some(agent_name.clone()); }); // Nested decisions automatically parent to the open one let child_id = trace_ctx.start_decision(DecisionType::AtomicSpawn); // ... do work ... trace_ctx.end_decision(child_id); trace_ctx.end_decision(decision_id); }
RAII Guards
For automatic cleanup, use DecisionGuard — the decision ends when the guard drops, even on early return or error:
#![allow(unused)] fn main() { let _guard = trace_ctx.decision_guard(DecisionType::LlmCall); // Decision automatically ends when guard drops }
Fire-and-Forget Persistence
Trace persistence must never block the request. Finalization hands the trace to a detached tokio::spawn, so a slow or failing MongoDB write has no effect on response latency:
#![allow(unused)] fn main() { impl AuditTrace { pub fn finalize_and_persist(self, output, status, error) { self.ctx.finalize(output, status, error); if let Some(repo) = self.repo { if let Some(trace) = self.ctx.take_trace() { tokio::spawn(async move { repo.insert(trace).await }); } } } } }
Per-Request Metrics Aggregation
Each trace carries pre-aggregated counters, so you can answer "how many tokens / LLM calls / SPO queries did this request cost?" without walking the tree:
{
"metrics": {
"total_tokens_in": 1250,
"total_tokens_out": 340,
"llm_call_count": 2,
"spo_query_count": 5,
"agent_invoke_count": 1,
"tool_call_count": 0,
"cache_hits": 2,
"cache_misses": 1
}
}
Querying Traces
# Latest trace
docker exec qadra-mongodb mongosh \
"mongodb://qadra:qadra_traces_dev@localhost:27017/qadra_traces?authSource=admin" \
--quiet --eval "db.traces.find().sort({timestamp: -1}).limit(1).toArray()"
// Traces with errors
db.traces.find({ status: "error" })
// Traces containing a specific decision type
db.traces.find({ "decisions.decision_type": "llm_call" })
// Slow traces (>1s)
db.traces.find({ duration_us: { $gt: 1000000 } })
Graceful Disabled Mode
When MongoDB is unavailable, tracing degrades to a no-op. Requests continue to work; traces are simply not recorded:
#![allow(unused)] fn main() { let ctx = TraceContext::disabled(); // No-op implementation }
Audit Traces vs Logs
The two systems overlap intentionally but serve distinct purposes:
| Audit Traces (MongoDB) | Logs (Loki) |
|---|---|
| Full decision trees | Operational events |
| Compliance / debugging | Troubleshooting |
| Per-request hierarchies | Flat entries |
| Long retention | Short retention |
| Queryable by structure | Queryable by text / labels |
Both reference the same trace_id for correlation — start from a log line, jump to the full decision tree.
Retention
Audit-trace retention is driven by compliance requirements, not disk economics:
| Class | Retention |
|---|---|
| Default | 90 days |
| Errors | 1 year |
| Compliance audits | As required |
Enforce the default via a MongoDB TTL index:
db.traces.createIndex(
{ "timestamp": 1 },
{ expireAfterSeconds: 7776000 } // 90 days
)
Prometheus retains metrics for 30 days (5GB max); Loki log retention is configured for short-lived operational troubleshooting. The authoritative, long-lived record of why a decision was made lives in MongoDB.
Environment Variables
OTEL_ENABLED=true
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
OTEL_SERVICE_NAME=qadra-core
RUST_LOG=info # Log level
MONGODB_URL=mongodb://qadra:...@mongodb:27017/qadra_traces?authSource=admin
GRAFANA_PASSWORD=your_grafana_password
Operations & Deployment
This guide covers running Qadra in production: environment configuration, deployment, the CI/CD pipeline, backups, scaling, health checks, and the protocol for making infrastructure changes. For local development setup, see the Development Guide.
Environment Variables
Configuration is supplied via .env or directly through docker-compose.
Required
DATABASE_URL=postgres://user:pass@localhost/qadra # PostgreSQL (source of truth)
REDIS_URL=redis://localhost:6379 # Streams, cache, pub/sub
OPENROUTER_API_KEY=sk-or-v1-... # LLM inference
Optional
MONGODB_URL=mongodb://... # Audit traces (degrades gracefully if absent)
CEREBRAS_API_KEY=csk-... # Nitro mode (fast inference)
PORT=3000 # Server port (default: 3000)
HOST=0.0.0.0 # Bind address
RUST_LOG=info # Log level (debug, info, warn, error)
QUICKJS_POOL_SIZE=100 # JS runtime pool size
REDIS_POOL_SIZE=10 # Redis connection pool size
Platform Services
POSTGRES_PASSWORD=qadra_dev_password
MONGO_PASSWORD=qadra_traces_dev
GRAFANA_PASSWORD=qadra-grafana
JWT_SECRET=qadra-dev-secret-change-in-production
SMTP2GO_API_KEY=api-... # Email delivery (Rust Core)
SMTP2GO_SENDER=noreply@qadra.dev # From address (default: noreply@qadra.dev)
EMAIL_VERIFICATION_URL=http://localhost:5173/verify-email # Verification link base URL
MINIO_ROOT_USER=qadra # MinIO access key (Gateway)
MINIO_ROOT_PASSWORD=qadra_minio_dev # MinIO secret key (Gateway)
MINIO_ENDPOINT=http://localhost:9000 # MinIO S3 API endpoint
MINIO_BUCKET=qadra-files # Default bucket name
IMAGINARY_URL=http://localhost:9002 # Imaginary image processing service
Production note:
JWT_SECRETand all default passwords MUST be replaced before deploying. Credentials currently live indocker-compose.ymlfor local development only (tech debt TD-004) — use Docker secrets or a vault in production.
Docker Deployment
The full stack runs via docker compose:
# Build the Rust Core image
docker build -t qadra .
# Run the full stack
docker compose up -d
# Restart a service cleanly (use force-recreate on WSL to avoid OCI errors)
docker compose up -d --force-recreate qadra-core
Containers are published to GitHub Container Registry on every push to main:
| Image | Registry |
|---|---|
ghcr.io/$REPO-core | GHCR |
ghcr.io/$REPO-gateway | GHCR |
ghcr.io/$REPO-agent | GHCR |
All services share the qadra-network Docker network; internal discovery uses container names (e.g. postgres:5432, loki:3100). External traffic enters through Kong, which validates Qadra-issued JWTs and injects the X-User-Email, X-Tenant-Id, and Authorization headers.
Running Migrations
Migrations run with sqlx against the target database before the service starts:
DATABASE_URL=postgres://... sqlx migrate run
# Generate offline query data (required for CI builds without a DB)
cargo sqlx prepare
CI/CD Pipeline
GitHub Actions at .github/workflows/ci.yml runs on push to main/develop and on pull requests targeting main. Duplicate runs on the same branch are cancelled automatically (cancel-in-progress: true).
Jobs
| Job | Description | Depends On |
|---|---|---|
secret-scan | Gitleaks secret detection (full history) | — |
dependency-review | Flags vulnerable/GPL dependencies (PR only) | — |
rust-format | cargo fmt --check (no compilation) | — |
rust-check-test | clippy + unit + doc tests (one shared compile) | — |
test-integration | sqlx migrations + DB/API tests (pgvector) | rust-check-test |
coverage-rust | cargo llvm-cov → lcov + Codecov | rust-check-test |
build-rust | cargo build --release → binary artifact | rust-check-test |
gateway | tsc --noEmit + hadolint gateway/Dockerfile | — |
web | biome + tsc + vitest + coverage + yarn build | — |
agent | ruff + mypy + pytest + hadolint agent/Dockerfile | — |
cypress-component | Cypress component tests, no backend | web |
cypress-e2e | Cypress E2E (cy.intercept() API mocking) | web |
docker | Buildx × 3 images + Trivy + Syft SBOM + cosign | build-rust, web, test-integration, gateway, agent |
ci-pass | Aggregate gate for branch protection | all jobs |
Path Gating
A leading changes job inspects the diff and emits per-area flags. Downstream jobs run only when their paths changed:
web/cypress-*— gated on changes underweb/rust-*/test-integration/coverage-rust— gated on Rust sources,crates/,migrations/,Cargo.*gateway— gated ongateway/agent— gated onagent/
Two jobs run only on push to main, never on PRs or feature branches:
build-rust— the release binary builddocker— multi-image build, scan, sign, and push to GHCR
Skipped jobs report success, so ci-pass treats a gated-out job as passing. This keeps PR feedback fast — a docs-only or web-only change does not trigger a full Rust compile.
Branch Protection
Set the single required status check to CI Report (the ci-pass aggregate job). It gates on all upstream jobs and renders a unified report to the workflow run summary covering security scans, code quality, test results, build artifact sizes, and (on main) Docker image tags, digests, and signing status.
Supply-Chain Security
| Mechanism | What it does | When |
|---|---|---|
| Gitleaks | Scans full git history for committed secrets | every run + scheduled |
| Dependency review | Blocks PRs introducing HIGH+ vulns or GPL licenses | PR only |
| Trivy | Scans container images (CRITICAL, HIGH), SARIF → Security tab | docker job |
| Syft SBOM | SPDX JSON per image, 90-day artifact retention | docker job |
| Cosign | Keyless image signing via Sigstore/Fulcio | main push |
| BuildKit SBOM + Provenance | Attached to image manifests | docker job |
| GitHub SBOM Attestation | actions/attest-sbom provenance chain | docker push |
A scheduled workflow (.github/workflows/security.yml, Monday 06:00 UTC) runs Gitleaks full-history, cargo audit, and cargo deny.
Health Checks
Both the Rust Core and the Gateway expose liveness and readiness endpoints:
GET /health # Basic liveness
GET /health/ready # Full readiness — Rust Core: DB + services; Gateway: NATS connectivity
Use /health for orchestrator liveness probes and /health/ready for readiness gating before routing traffic to a new instance.
Scaling
The Rust Core (qadra-core) is stateless and horizontally scalable — all authoritative state lives in PostgreSQL, with Redis as the hot layer. Scale out by running more replicas behind Kong.
Constraints that make this safe:
- All database access is scoped by
tenant_id(row-level isolation). - Writes go to PostgreSQL first, then publish events to Redis Streams.
- Event consumers are idempotent (at-least-once delivery).
- QuickJS instances are stateless and isolated.
Per-container memory limits are configured in docker-compose.yml:
| Service | Memory Limit | Reserved |
|---|---|---|
| postgres | 2GB | 512MB |
| mongodb | 1GB | 256MB |
| qadra-core | 1GB | 256MB |
| qadra-gateway | 256MB | 64MB |
| minio | 512MB | 128MB |
| prometheus | 1GB | 256MB |
| grafana | 512MB | 128MB |
Performance Targets
| Metric | Target |
|---|---|
| QuickJS spawn | <100μs |
| Memory per instance | <500KB |
| API P50 | <100ms |
| API P99 | <500ms |
Backups
Current gap (tech debt TD-005): There is no automated backup strategy yet. The platform is in an early development phase. Do not treat the running database as durable.
Planned resolution: a pg_dump cron job shipping snapshots to S3, to be implemented before production launch. MongoDB audit traces use TTL-based retention (default 90 days) and are not currently backed up — they are diagnostic, not authoritative.
Infrastructure Change Protocol
When modifying files in deploy/, docker-compose.yml, kong/, or CI/CD workflows, you MUST update the corresponding rules documentation. Stale docs cause hallucinated assumptions.
Required Documentation Updates
- Adding/modifying a Docker service — update the service and resource-limit tables in
.claude/rules/infrastructure.md. - New observability component — update the observability config section, document any new Grafana dashboard, add new alert rules to the alerts table.
- Changing environment variables — update the Environment Variables section in
.claude/rules/infrastructure.md. - Modifying alerting — update the Alert Rules table; document threshold changes and rationale in the commit message.
- Kong/Gateway changes — document route changes and JWT/header injection in
.claude/rules/infrastructure.md.
Commit Convention
Tag infrastructure changes with [infra]:
[infra] Add cAdvisor for container metrics
- Added cadvisor service to docker-compose
- Created container-monitoring dashboard
- Added prometheus scrape target
Validation Checklist
Before committing infrastructure changes:
-
docker compose configpasses (syntax validation) -
Services start cleanly with
docker compose up -d - Grafana loads without provisioning errors
-
Prometheus targets show as "up" at
http://localhost:9090/targets -
Rules documentation updated in
.claude/rules/infrastructure.md
Security & Multitenancy
Qadra is a multitenant platform: every organization's data, agents, and pipelines are isolated from every other. Security is layered — authentication at the edge, authorization at the boundary, and tenant scoping enforced on every database query.
Authentication Model
Authentication is split cleanly between containers. The Node.js Gateway owns the entire JWT lifecycle; Rust Core is NATS-only and never sees a raw HTTP authorization header.
Browser (:5173)
|
v
Kong (:8000) ── validates JWT signature + expiry, injects headers
|
v
Node.js Gateway (:4000) ── creates JWTs (login/register), validates on every route
|
v NATS request-reply (claims travel in the payload)
|
Rust Core (no HTTP port) ── enforces tenant_id on every query
| Stage | Responsibility |
|---|---|
| Kong | Validates the JWT signature and expiry for Qadra-issued tokens. Injects X-User-Email, X-Tenant-Id, and Authorization headers. Routes /api/* to the Gateway, /* to the React SPA. |
| Gateway | Creates JWTs on login/register, validates them on every authenticated route, extracts claims, and forwards them inside the NATS request payload. |
| Rust Core | Trusts the claims in the NATS payload and enforces tenant_id scoping at the repository layer. Has no HTTP server and never parses auth headers. |
Why this split? JWT creation/validation is a thin, latency-insensitive layer that does not need Rust's performance. Keeping it in the Gateway lets Rust Core stay a pure NATS data layer, decoupled from any HTTP framework.
Auth operations themselves run over NATS on the tenant-less qadra.auth.> subjects (login, register, refresh, store_token, revoke_token, verify_email, etc.). These are pre-authentication context, so they carry no tenant in the subject.
JWT Claims
Two token types are issued. The short-lived access token carries identity and tenant context; the long-lived refresh token carries nothing but a subject and is stored hashed.
Access Token (15 minutes)
{
"sub": "user-uuid",
"email": "user@example.com",
"name": "Jane Analyst",
"tid": "tenant-uuid",
"tname": "Acme Capital",
"role": "admin",
"sa": true
}
| Claim | Meaning |
|---|---|
sub | User ID |
email | User email |
name | Display name |
tid | Active tenant ID |
tname | Active tenant name |
role | Per-tenant role (owner, admin, user, or synthetic super_admin on drop-in) |
sa? | Present and true only for super admins. Omitted for regular users to keep the JWT small for the 99.9% case. |
Refresh Token (7 days)
{ "sub": "user-uuid", "type": "refresh" }
The refresh token is SHA256-hashed before it is stored in the database — the raw token never lives at rest. Refresh rotation (qadra.auth.refresh + store_token + revoke_token) issues a new pair and revokes the old refresh token.
Multitenancy
Every domain object is scoped by tenant_id. Row-level isolation is enforced at the repository layer in Rust Core: there is no query path that does not filter by tenant.
#![allow(unused)] fn main() { impl Repository for PostgresRepository { async fn get_workload(&self, tenant_id: Uuid, id: Uuid) -> Result<Workload> { sqlx::query_as!(Workload, "SELECT * FROM workloads WHERE tenant_id = $1 AND id = $2", tenant_id, id ).fetch_one(&self.pool).await } } }
A cross-tenant query returns empty, not an error — Tenant B querying Tenant A's data simply finds nothing.
Users Belong to Many Tenants
Users are not bound to a single organization. The user_tenants junction table maps a user to each tenant they belong to, with a per-tenant role:
CREATE TABLE user_tenants (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'user',
is_default BOOLEAN NOT NULL DEFAULT false,
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, tenant_id)
);
| Column | Purpose |
|---|---|
role | Per-tenant role: owner, admin, or user |
is_default | Which tenant is selected on login |
joined_at | When the membership was created |
- Email is globally unique (
idx_users_email_unique). register_useris atomic — it creates the tenant, the user, and theuser_tenantsmembership in a single transaction.- Switching tenants (
POST /auth/switch-tenant) verifies membership and mints a fresh access token with the newtid/tname.
Per-Tenant Roles
| Role | Capabilities |
|---|---|
owner | Full control of the tenant. Immutable — cannot be changed or removed. |
admin | Invite/remove members, change roles, manage templates and tenant settings. |
user | Standard member access. |
Only owner/admin may invite members, change roles, or remove members. The owner role cannot be reassigned or removed.
Super Admin
Platform-level administration is a server-wide privilege, not a per-tenant role. It is a single boolean on the users table:
ALTER TABLE users ADD COLUMN is_super_admin BOOLEAN NOT NULL DEFAULT false;
CREATE INDEX idx_users_super_admin ON users(id) WHERE is_super_admin = true;
Super admin operations run over the tenant-less qadra.admin.> subjects. Every operation independently calls is_super_admin(user_id) before executing.
| Capability | Subject |
|---|---|
| List all tenants | qadra.admin.list_tenants |
| Create tenant + assign owner | qadra.admin.create_tenant |
| List all users | qadra.admin.list_users |
| Reset a user's password | qadra.admin.reset_password |
| Drop into any tenant | qadra.admin.switch_tenant |
| Promote to super admin | qadra.admin.promote |
| Demote a super admin | qadra.admin.demote |
| Platform stats | qadra.admin.stats |
Drop-In via Synthetic JWT
When a super admin drops into a tenant they do not belong to, the Gateway mints a synthetic JWT carrying that tenant's tid/tname and role: "super_admin". This preserves a key invariant: user_tenants only ever reflects real membership. The drop-in adds no row to user_tenants, so it never pollutes tenant member lists or requires cleanup. The synthetic super_admin role is not a real per-tenant role and clearly signals temporary elevated access.
Self-Protection
- A super admin cannot promote or demote themselves.
- The first super admin is promoted only via direct SQL (or a migration seed) — there is no self-service path to elevation.
Password Hashing
Passwords are hashed with argon2 for all new credentials. bcrypt is supported only for verifying legacy hashes.
| Scenario | Algorithm |
|---|---|
| New password (register, reset) | argon2 |
Verifying a legacy hash ($2b$ / $2a$ prefix) | bcrypt |
| Verifying any other hash | argon2 |
Detection is by hash prefix: a stored hash beginning with $2b$ or $2a$ is verified with bcrypt; everything else is verified with argon2. New passwords are always written as argon2, so legacy bcrypt hashes are phased out naturally on the next password change.
Email Verification
New users receive a verification email; the flow is token-based and fire-and-forget so it never blocks registration.
ALTER TABLE users ADD COLUMN email_verified BOOLEAN NOT NULL DEFAULT false;
CREATE TABLE email_verification_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
- Token: 32 random bytes, hex-encoded (64 chars), with a 24-hour expiry.
- Verification: a token that is valid, unused, and unexpired is consumed in a single transaction that sets
used_at = NOW()andusers.email_verified = true. - Post-registration: the verification email is sent by a detached async task (
tokio::spawn) so registration latency is unaffected. - Resend (
qadra.auth.resend_verification): invalidates existing tokens, creates a new one, and sends a fresh email.
Team Invites
Invitations are token-based with smart routing — existing users are added directly, unknown emails receive an email invite.
CREATE TABLE team_invites (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
email TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
invited_by UUID NOT NULL REFERENCES users(id),
token TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
accepted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
- Smart invite (
qadra.auth.invite_member): if a user with that email already exists globally, they are added to the tenant directly; otherwise an invite is created and an email sent. - Token: 32 random bytes, hex-encoded, with a 7-day expiry.
- Accept (
qadra.auth.accept_invite): a valid, unaccepted, unexpired token adds the user to the tenant viauser_tenants. - Cancel (
qadra.auth.cancel_invite): owner/admin may hard-delete a pending invite. - Role checks: only
owner/admincan invite; theownerrole is immutable.
Defense in Depth
Authorization is enforced at every layer, so a bypass at one layer is caught by the next:
| Layer | What It Prevents |
|---|---|
| Kong JWT validation | Invalid or expired tokens reaching the Gateway |
| Gateway middleware | Missing or malformed authentication |
| NATS payload validation | Malformed requests reaching handlers |
Repository tenant_id filter | Cross-tenant data access |
| Database foreign keys | Orphaned or invalid references |
CI Security & Supply-Chain Posture
The CI pipeline (.github/workflows/ci.yml, plus a scheduled security.yml) gates every change on a battery of security checks before any image is published.
Static & Dependency Scanning
| Check | Tool | Scope |
|---|---|---|
| Secret scanning | Gitleaks | Scans the full git history for committed secrets (API keys, passwords, tokens). |
| Dependency review | actions/dependency-review | Blocks PRs that introduce HIGH+ vulnerabilities or GPL-licensed dependencies (PR only). |
| Dockerfile linting | Hadolint | Per-image Dockerfile linting (gateway, agent, root). |
| Rust audit | cargo audit + cargo deny | Scheduled (Mondays 06:00 UTC) advisory and license checks. |
Container & Image Scanning
| Check | Tool | Output |
|---|---|---|
| Image vulnerability scan | Trivy | Scans built images for CRITICAL/HIGH issues; results uploaded as SARIF to the GitHub Security tab. |
Supply-Chain Integrity
For images published to GHCR on main:
| Mechanism | Tool | Purpose |
|---|---|---|
| Image signing | Cosign | Keyless signing via Sigstore/Fulcio — verifiable provenance of who built the image. |
| SBOM generation | Syft (SPDX JSON) | A software bill of materials per image, retained 90 days. |
| SBOM attestation | actions/attest-sbom | Attaches a verifiable provenance chain to the image. |
| Build provenance | BuildKit SBOM + Provenance | Attached to image manifests via docker/build-push-action. |
All of these jobs feed the aggregate ci-pass gate, which is the single required status check for branch protection. A failure in any security job blocks the merge.
Known Tech Debt
A handful of security shortcuts are tracked deliberately (see tech-debt-index.md):
- NATS transport is plaintext (TD-006): login/register passwords cross NATS in the clear. Acceptable because NATS is not exposed externally; NATS TLS is planned for production.
- Internal services use HTTP (TD-003): Kong terminates external TLS; mTLS between internal services is a production task.
- Credentials in
docker-compose.yml(TD-004): local-dev only; production will use Docker secrets or Vault. users.tenant_idlegacy column (TD-007): retained alongsideuser_tenantsfor backward compatibility; to be dropped once all code reads the junction table.
API Reference
Overview
All API endpoints are served by the Node.js Gateway at port :4000. The gateway handles JWT creation/validation, CORS, and request validation, then bridges every request to Rust Core via NATS messaging. The gateway never touches PostgreSQL directly.
Base URL: http://localhost:4000 (development)
Request flow:
Client -> Node.js Gateway (:4000) -> NATS -> Rust Core -> PostgreSQL
API Design Philosophy
Gateway-mediated: The HTTP layer is a thin Node.js service. All business logic and data access lives in Rust Core, reached via NATS request-reply. This separation keeps auth concerns isolated from the data layer.
Tenant-scoped: Authentication includes tenant context via JWT claims. You never specify tenant_id in request bodies -- it is derived from your access token.
Structured errors: Every error includes a machine-readable code, human-readable message, and optional details.
Endpoint Categories
| Category | Prefix | Auth Required | Description |
|---|---|---|---|
| Auth | /auth/* | Varies | Registration, login, token management, email verification |
| Users | /users/* | Yes | User profile, tenant list, avatar management |
| Teams | /teams/* | Yes | Team members, invitations, role management |
| Tenants | /tenants/* | Yes | Tenant settings and branding |
| Agents | /agents/* | Yes | Agent CRUD (create, list, update, delete) |
| Conversations | /conversations/* | Yes | Chat conversations, messages, streaming |
| Flows | /flows/* | Yes | Visual workflow definitions and execution |
| Artifact Templates | /artifact-templates/* | Yes | Structured output templates for agents |
| Knowledge | /knowledge/* | Yes | Document ingestion with human-in-the-loop gating |
| Files | /files/* | Yes | File upload, download, deletion (S3-backed) |
/email/* | Yes | Send templated emails | |
| Notifications | /notifications/* | Yes | Email templates and notification audit log |
| Audit | /audit/* | Yes | NATS bus audit trail |
| Admin | /admin/* | Super Admin | Platform-wide tenant/user management |
| Health | /health* | No | Liveness and readiness checks |
Authentication
JWT Authentication
Qadra uses short-lived access tokens and long-lived refresh tokens. The gateway creates and validates JWTs -- Rust Core is not involved in token mechanics.
Access token (15 min): Carries user identity and tenant context.
Refresh token (7 days): Used to obtain new access tokens. SHA256-hashed before database storage.
Use access token in requests:
Authorization: Bearer eyJ0eXAiOiJKV1Q...
JWT Claims
Access tokens contain these claims:
| Claim | Type | Description |
|---|---|---|
sub | string (UUID) | User ID |
email | string | User email |
name | string | User display name |
tid | string (UUID) | Active tenant ID |
tname | string | Active tenant name |
role | string | Per-tenant role (owner, admin, user, or super_admin for drop-in) |
sa | boolean? | Present and true only for super admins. Omitted for regular users. |
Refresh tokens contain only { sub, type: "refresh" }.
Auth Endpoints
Authentication and session management. Registration and login do not require auth. All others require a valid access token unless noted.
| Method | Path | Auth | NATS Subject | Description |
|---|---|---|---|---|
POST | /auth/register | No | qadra.auth.register | Create user + tenant, return tokens |
POST | /auth/login | No | qadra.auth.login | Verify credentials, return tokens |
POST | /auth/refresh | No | qadra.auth.refresh + store_token + revoke_token | Rotate token pair |
GET | /auth/verify-email?token=xxx | No | qadra.auth.verify_email | Verify email token |
POST | /auth/logout | Yes | qadra.auth.revoke_token | Revoke refresh token |
POST | /auth/switch-tenant | Yes | qadra.auth.get_user or qadra.admin.switch_tenant | Switch active tenant, get new access token |
POST | /auth/accept-invite | Yes | qadra.auth.accept_invite | Accept team invite by token |
POST | /auth/resend-verification | Yes | qadra.auth.resend_verification | Resend verification email |
Register
POST /auth/register
Content-Type: application/json
{
"email": "user@example.com",
"password": "securepassword",
"name": "Jane Doe"
}
Response (201 Created):
{
"access_token": "eyJ0eXAiOiJKV1Q...",
"refresh_token": "eyJ0eXAiOiJKV1Q...",
"user": {
"id": "uuid",
"email": "user@example.com",
"name": "Jane Doe",
"tenants": [
{
"tenant_id": "uuid",
"tenant_name": "Jane Doe's Workspace",
"role": "owner",
"is_default": true
}
]
},
"active_tenant": {
"tenant_id": "uuid",
"tenant_name": "Jane Doe's Workspace",
"role": "owner",
"is_default": true
}
}
Login
POST /auth/login
Content-Type: application/json
{
"email": "user@example.com",
"password": "securepassword"
}
Response (200 OK): Same shape as register response.
Refresh Tokens
POST /auth/refresh
Content-Type: application/json
{
"refresh_token": "eyJ0eXAiOiJKV1Q..."
}
Response (200 OK): Returns new access_token, refresh_token, user, and active_tenant. The old refresh token is revoked (rotation).
Switch Tenant
POST /auth/switch-tenant
Authorization: Bearer eyJ0eXAiOiJKV1Q...
Content-Type: application/json
{
"tenant_id": "uuid"
}
Response (200 OK):
{
"access_token": "eyJ0eXAiOiJKV1Q...",
"active_tenant": {
"tenant_id": "uuid",
"tenant_name": "Acme Corp",
"role": "admin"
}
}
For super admins (sa: true in JWT), this bypasses membership checks and creates a synthetic JWT with role: "super_admin".
User Endpoints
All require authentication.
| Method | Path | NATS Subject | Description |
|---|---|---|---|
GET | /users/me | qadra.auth.get_user | Get current user profile + tenants |
GET | /users/me/tenants | qadra.auth.get_user | List user's tenant memberships |
POST | /users/me/avatar | qadra.auth.file.create + qadra.auth.update_avatar | Upload avatar (multipart, max 2MB, auto-resized to 256x256 WebP) |
DELETE | /users/me/avatar | qadra.auth.update_avatar | Remove avatar |
Get Profile
GET /users/me
Authorization: Bearer eyJ0eXAiOiJKV1Q...
Response:
{
"user": {
"id": "uuid",
"email": "user@example.com",
"name": "Jane Doe",
"avatar_url": "/api/files/uuid/download",
"email_verified": true,
"tenants": [...]
},
"active_tenant": {
"tenant_id": "uuid",
"tenant_name": "Acme Corp",
"role": "admin"
}
}
Upload Avatar
POST /users/me/avatar
Authorization: Bearer eyJ0eXAiOiJKV1Q...
Content-Type: multipart/form-data
file: <image file (JPEG, PNG, or WebP, max 2MB)>
The image is automatically resized to 256x256 and converted to WebP via the imaginary service.
Team Endpoints
Manage team members and invitations for the current tenant. All require authentication.
| Method | Path | NATS Subject | Description |
|---|---|---|---|
GET | /teams/members | qadra.auth.list_members | List tenant members |
POST | /teams/members/invite | qadra.auth.invite_member | Invite by email (smart: add directly or send invite) |
PATCH | /teams/members/:userId/role | qadra.auth.update_member_role | Change member role (owner/admin only) |
DELETE | /teams/members/:userId | qadra.auth.remove_member | Remove member (owner/admin only) |
GET | /teams/invites | qadra.auth.list_invites | List pending invites |
DELETE | /teams/invites/:inviteId | qadra.auth.cancel_invite | Cancel pending invite (owner/admin only) |
Invite Member
POST /teams/members/invite
Authorization: Bearer eyJ0eXAiOiJKV1Q...
Content-Type: application/json
{
"email": "colleague@example.com",
"role": "admin"
}
Smart invite logic: If a user with that email already exists on the platform, they are added to the tenant directly. If not, an invitation email is sent.
Response (201 Created):
{
"success": true,
"added_directly": false,
"message": "Invitation email sent"
}
Tenant Endpoints
Manage the current tenant's settings and branding. All require authentication.
| Method | Path | NATS Subject | Description |
|---|---|---|---|
GET | /tenants/current | qadra.auth.get_tenant | Get current tenant settings + branding |
PATCH | /tenants/current | qadra.auth.update_tenant | Update tenant name/settings (owner/admin only) |
Update Tenant
PATCH /tenants/current
Authorization: Bearer eyJ0eXAiOiJKV1Q...
Content-Type: application/json
{
"name": "Acme Corp",
"settings": {
"branding": { "primary_color": "#1a73e8" },
"storage": { "provider": "internal" }
}
}
Agent Endpoints
Manage tenant-scoped AI agents. All require authentication.
| Method | Path | NATS Subject | Description |
|---|---|---|---|
POST | /agents | qadra.{tid}.agent.do.create | Create a new agent |
GET | /agents | qadra.{tid}.agent.do.list | List all agents (paginated) |
GET | /agents/:agentId | qadra.{tid}.agent.do.get | Get agent by ID |
PUT | /agents/:agentId | qadra.{tid}.agent.do.update | Update agent |
DELETE | /agents/:agentId | qadra.{tid}.agent.do.delete | Delete agent |
Create Agent
POST /agents
Authorization: Bearer eyJ0eXAiOiJKV1Q...
Content-Type: application/json
{
"name": "research-analyst",
"display_name": "Research Analyst",
"description": "Gathers market data and competitive analysis",
"system_prompt": "You are a research analyst...",
"control_type": "relinquish_to_parent",
"output_visibility": "user_facing",
"max_calls_per_parent_agent": 3
}
| Field | Type | Default | Description |
|---|---|---|---|
name | string | required | Unique agent identifier |
display_name | string? | null | Human-readable name |
avatar_url | string? | null | Agent avatar URL |
description | string? | null | What this agent does |
system_prompt | string | "" | System prompt for LLM context |
control_type | enum | relinquish_to_parent | retain, relinquish_to_parent, or relinquish_to_start |
output_visibility | enum | user_facing | user_facing or internal |
max_calls_per_parent_agent | int | 3 | Safety rail: max invocations per parent |
Conversation Endpoints
Chat-style conversations with agents. Supports both synchronous and SSE-streaming message delivery. All require authentication.
| Method | Path | NATS Subject | Description |
|---|---|---|---|
POST | /conversations | qadra.{tid}.chat.do.create | Create a conversation |
GET | /conversations | qadra.{tid}.chat.do.list | List conversations (paginated) |
GET | /conversations/:id | qadra.{tid}.chat.do.get | Get conversation by ID |
PATCH | /conversations/:id | qadra.{tid}.chat.do.update_title | Update conversation title |
DELETE | /conversations/:id | qadra.{tid}.chat.do.delete | Soft-delete conversation |
POST | /conversations/:id/messages | qadra.{tid}.chat.do.send_message | Send message (synchronous, 240s timeout) |
POST | /conversations/:id/messages/stream | qadra.{tid}.chat.do.send_message | Send message with SSE progress stream |
GET | /conversations/:id/messages | qadra.{tid}.chat.do.list_messages | List messages (paginated) |
POST | /conversations/:id/messages/:messageId/rate | qadra.{tid}.chat.do.rate_message | Rate a message (+1 or -1) |
Send Message
POST /conversations/:id/messages
Authorization: Bearer eyJ0eXAiOiJKV1Q...
Content-Type: application/json
{
"agent_id": "uuid",
"content": "Analyze the competitive landscape for Acme Corp",
"flow_execution_id": "uuid (optional)"
}
Stream Message (SSE)
POST /conversations/:id/messages/stream
Authorization: Bearer eyJ0eXAiOiJKV1Q...
Content-Type: application/json
{
"agent_id": "uuid",
"content": "Analyze the competitive landscape for Acme Corp"
}
Returns a text/event-stream with progress events:
event: flow_started
data: {"execution_id":"uuid","flow_name":"research-flow"}
event: node_started
data: {"node_id":"agent-1","node_type":"Agent","label":"Web Search"}
event: node_completed
data: {"node_id":"agent-1","output":{...}}
event: assistant_message
data: {"id":"uuid","role":"assistant","content":"Based on my analysis..."}
event: done
data: {}
Flow Endpoints
Visual graph-based workflows (ReactFlow). Create flow definitions, execute them, and manage executions with human-in-the-loop gates. All require authentication.
Flow Definitions
| Method | Path | NATS Subject | Description |
|---|---|---|---|
POST | /flows | qadra.{tid}.flow.do.create | Create a new flow |
GET | /flows | qadra.{tid}.flow.do.list | List all flows (paginated) |
GET | /flows/:flowId | qadra.{tid}.flow.do.get | Get flow by ID |
PUT | /flows/:flowId | qadra.{tid}.flow.do.update | Update flow (name, description, flow_data, is_active) |
DELETE | /flows/:flowId | qadra.{tid}.flow.do.delete | Delete flow |
POST | /flows/generate | qadra.{tid}.flow.do.generate | AI-generate a flow from natural language prompt |
Flow Executions
| Method | Path | NATS Subject | Description |
|---|---|---|---|
POST | /flows/:flowId/execute | qadra.{tid}.flow.do.execute | Start flow execution |
GET | /flows/:flowId/executions | qadra.{tid}.flow.do.execution_list | List executions for a flow |
GET | /flows/executions/:execId | qadra.{tid}.flow.do.execution_get | Get execution status and state |
POST | /flows/executions/:execId/cancel | qadra.{tid}.flow.do.execution_cancel | Cancel running execution |
POST | /flows/executions/:execId/input | qadra.{tid}.flow.do.human_input | Submit human input for paused execution |
Create Flow
POST /flows
Authorization: Bearer eyJ0eXAiOiJKV1Q...
Content-Type: application/json
{
"name": "research-pipeline",
"description": "Multi-step research workflow",
"flow_data": {
"nodes": [...],
"edges": [...],
"viewport": { "x": 0, "y": 0, "zoom": 1 }
}
}
Execute Flow
POST /flows/:flowId/execute
Authorization: Bearer eyJ0eXAiOiJKV1Q...
Content-Type: application/json
{
"input": {
"target_company": "Acme Corp",
"analysis_type": "competitive"
}
}
Submit Human Input
When an execution is paused at a HumanInput node:
POST /flows/executions/:execId/input
Authorization: Bearer eyJ0eXAiOiJKV1Q...
Content-Type: application/json
{
"node_id": "human-input-1",
"response": {
"approved": true,
"notes": "Proceed with analysis"
}
}
AI Flow Generation
Generate a flow graph from a natural language description:
POST /flows/generate
Authorization: Bearer eyJ0eXAiOiJKV1Q...
Content-Type: application/json
{
"prompt": "Create a workflow that researches a company, validates findings, and produces a summary report"
}
Flow Node Types
| Type | Description |
|---|---|
Start | Entry point, seeds initial state |
End | Terminal node, marks execution complete |
Agent | Invokes an AI agent with context |
Condition | Evaluates expression, branches flow |
HumanInput | Pauses execution, waits for user response |
Loop | Iterates over collection (max 100 iterations) |
Http | Makes external HTTP request |
ExecuteFlow | Runs a sub-flow |
Transform | JavaScript expression to transform state |
Delay | Pauses execution for a duration (max 300s) |
SpoFilter | Queries SPO triples into state, with an optional asserter filter |
SpoWrite | Persists agent output as SPO triples with provenance (config: content_key, asserter, source, output_key) |
ClaimValidation | Grounds prior-stage claims against SPO triples (config: input_keys default scarlet_output,ayana_output, output_key, asserter_filter, query_limit); reports per-claim SUPPORTED/CONTRADICTED/NOT_MENTIONED |
Execution Status Lifecycle
pending -> running -> completed
-> failed
-> cancelled
running -> paused (HumanInput) -> running (after input submitted)
Artifact Template Endpoints
Define structured output formats for agent-produced artifacts. All require authentication.
| Method | Path | NATS Subject | Description |
|---|---|---|---|
POST | /artifact-templates | qadra.{tid}.artifact_template.do.create | Create a template |
GET | /artifact-templates | qadra.{tid}.artifact_template.do.list | List templates (paginated, filterable by artifact_type) |
GET | /artifact-templates/:id | qadra.{tid}.artifact_template.do.get | Get template by ID |
PUT | /artifact-templates/:id | qadra.{tid}.artifact_template.do.update | Update template |
DELETE | /artifact-templates/:id | qadra.{tid}.artifact_template.do.delete | Delete template |
Create Artifact Template
POST /artifact-templates
Authorization: Bearer eyJ0eXAiOiJKV1Q...
Content-Type: application/json
{
"name": "investment-memo",
"display_name": "Investment Memo",
"description": "Structured investment memorandum",
"artifact_type": "document",
"expected_sections": [
{ "name": "Executive Summary", "required": true },
{ "name": "Market Analysis", "required": true },
{ "name": "Risk Factors", "required": true },
{ "name": "Recommendation", "required": true }
],
"output_format": "markdown"
}
Knowledge Endpoints
Human-gated document ingestion into the knowledge graph. Agents or users propose documents; humans approve or reject before ingestion runs. All require authentication.
| Method | Path | NATS Subject | Description |
|---|---|---|---|
POST | /knowledge/propose | qadra.{tid}.epistemic.propose_document | Propose a document for review |
POST | /knowledge/documents/:id/approve | qadra.{tid}.epistemic.approve_document | Approve a pending document (triggers async ingestion) |
POST | /knowledge/documents/:id/reject | qadra.{tid}.epistemic.reject_document | Reject a pending document |
POST | /knowledge/ingest | qadra.{tid}.epistemic.ingest_document | Direct ingestion (bypass proposal gate, 2 min timeout) |
GET | /knowledge/documents | qadra.{tid}.epistemic.list_documents | List ingested documents (paginated, filterable by status) |
GET | /knowledge/documents/:id | qadra.{tid}.epistemic.get_document | Get single document |
Propose Document
POST /knowledge/propose
Authorization: Bearer eyJ0eXAiOiJKV1Q...
Content-Type: application/json
{
"title": "Company Overview",
"content": "Apple was founded by Steve Jobs in 1976...",
"content_type": "text/plain",
"source_url": "https://example.com/article",
"source_type": "web"
}
Response (201 Created):
{
"document_id": "uuid"
}
Approve Document
POST /knowledge/documents/:id/approve
Authorization: Bearer eyJ0eXAiOiJKV1Q...
Approval returns immediately. Ingestion runs asynchronously on the Rust side. Poll GET /knowledge/documents/:id for status updates.
Response:
{
"document_id": "uuid",
"status": "approved",
"message": "Ingestion started -- poll document status for progress"
}
Direct Ingestion
POST /knowledge/ingest
Authorization: Bearer eyJ0eXAiOiJKV1Q...
Content-Type: application/json
{
"title": "Company Overview",
"content": "Apple was founded by Steve Jobs in 1976...",
"content_type": "text/plain",
"source_url": "https://example.com/article",
"source_type": "upload"
}
Response (201 Created):
{
"document_id": "uuid",
"routing": "both",
"triples_extracted": 5,
"chunks_created": 3,
"duration_ms": 2340
}
Max content length: 1MB of text per document.
Ingest Triples
Ingest pre-extracted SPO triples directly, bypassing document analysis and triple extraction. Useful when triples are produced upstream (e.g., by a SpoWrite flow node). Each triple carries its provenance.
POST /knowledge/ingest-triples
Authorization: Bearer eyJ0eXAiOiJKV1Q...
Content-Type: application/json
{
"triples": [
{
"subject": "Apple",
"predicate": "founded_by",
"object": "Steve Jobs",
"confidence": 0.95,
"source": "Company Overview",
"asserter": "research-analyst",
"assertion_type": "fact"
}
]
}
NATS subject: qadra.{tid}.epistemic.ingest.
Trace Claim
Trace a memo claim back to the supporting SPO triples (each with its asserter and source) and the source documents (with excerpt and content) that ground it. The server enforces a 450 ms internal budget to meet a sub-500 ms acceptance criterion.
POST /knowledge/trace
Authorization: Bearer eyJ0eXAiOiJKV1Q...
Content-Type: application/json
{
"claim": "Apple was founded by Steve Jobs in 1976",
"max_results": 5
}
| Field | Type | Default | Description |
|---|---|---|---|
claim | string | required | Claim text to trace (1-2000 chars) |
max_results | int? | null | Max matches to return (1-20) |
NATS subject: qadra.{tid}.epistemic.trace_claim.
Response:
{
"claim": "Apple was founded by Steve Jobs in 1976",
"matches": [
{
"triple": {
"subject": "Apple",
"predicate": "founded_by",
"object": "Steve Jobs",
"confidence": 0.95,
"source": "Company Overview",
"asserter": "research-analyst",
"assertion_type": "fact"
},
"documents": [
{
"document_id": "uuid",
"title": "Company Overview",
"source_url": "https://example.com/article",
"source_type": "web",
"content": "Apple was founded by Steve Jobs in 1976...",
"excerpt": "Apple was founded by Steve Jobs in 1976",
"confidence": 0.95
}
]
}
],
"duration_ms": 312
}
Knowledge Conflicts
Detect and triage competing assertions on the same subject/predicate. All require authentication.
| Method | Path | NATS Subject | Description |
|---|---|---|---|
GET | /knowledge/conflicts | qadra.{tid}.epistemic.list_conflicts | List detected conflicts (paginated, filterable by subject) |
POST | /knowledge/conflicts/:id/acknowledge | qadra.{tid}.epistemic.acknowledge_conflict | Mark a conflict as seen |
POST | /knowledge/conflicts/:id/resolve | qadra.{tid}.epistemic.resolve_conflict | Resolve a conflict (records chosen assertion + reviewer) |
POST | /knowledge/conflicts/:id/dismiss | qadra.{tid}.epistemic.dismiss_conflict | Dismiss a conflict as not actionable |
List Conflicts
GET /knowledge/conflicts?subject=Apple&limit=25&offset=0
Authorization: Bearer eyJ0eXAiOiJKV1Q...
| Parameter | Type | Default | Description |
|---|---|---|---|
subject | string? | null | Filter by subject |
limit | int | 25 | Results per page (1-100) |
offset | int | 0 | Pagination offset |
Resolve Conflict
POST /knowledge/conflicts/:id/resolve
Authorization: Bearer eyJ0eXAiOiJKV1Q...
Content-Type: application/json
{
"chosen_assertion": "Apple was founded in 1976",
"reason": "Confirmed against primary source filing"
}
Attribution Sessions
KG-SMILE gate-run audit (Story 104). Every call into the epistemic query pipeline persists a gate run with its per-triple attributions. All require authentication.
| Method | Path | NATS Subject | Description |
|---|---|---|---|
GET | /knowledge/attribution-sessions | qadra.{tid}.epistemic.list_attribution_sessions | List KG-SMILE gate runs (paginated) |
GET | /knowledge/attribution-sessions/:id | qadra.{tid}.epistemic.get_attribution_session | Get one gate run with its per-triple attributions |
List Attribution Sessions
GET /knowledge/attribution-sessions?workload_id=uuid&competence_passed=true&limit=25&offset=0
Authorization: Bearer eyJ0eXAiOiJKV1Q...
| Parameter | Type | Default | Description |
|---|---|---|---|
workload_id | string (UUID)? | null | Filter by workload |
agent_name | string? | null | Filter by agent name |
from | string (ISO 8601)? | null | Start of date range |
to | string (ISO 8601)? | null | End of date range |
competence_passed | boolean? | null | Filter by competence gate result |
verification_passed | boolean? | null | Filter by verification gate result |
limit | int | 25 | Results per page (1-100) |
offset | int | 0 | Pagination offset |
File Endpoints
Generic file upload and download backed by S3-compatible storage (MinIO by default, tenant-configurable). All require authentication.
| Method | Path | NATS Subject | Description |
|---|---|---|---|
POST | /files/upload | qadra.auth.file.create | Upload file (multipart, purpose-based validation) |
GET | /files/:fileId/download | qadra.auth.file.get | Download file (proxied from S3) |
DELETE | /files/:fileId | qadra.auth.file.get + qadra.auth.file.delete | Delete file (S3 + metadata) |
Upload File
POST /files/upload
Authorization: Bearer eyJ0eXAiOiJKV1Q...
Content-Type: multipart/form-data
file: <file data>
purpose: "document"
entity_type: "workload" (optional)
entity_id: "uuid" (optional)
Purpose-based validation:
| Purpose | Max Size | Allowed Types |
|---|---|---|
avatar | 2 MB | image/jpeg, image/png, image/webp |
logo | 2 MB | image/jpeg, image/png, image/webp, image/svg+xml |
| Other | 10 MB | Any |
Download File
GET /files/:fileId/download
Authorization: Bearer eyJ0eXAiOiJKV1Q...
Returns the file content with appropriate Content-Type and Content-Disposition headers. Downloads are always proxied through the gateway -- never direct S3 links.
Email Endpoints
Send templated emails. Requires authentication.
| Method | Path | NATS Subject | Description |
|---|---|---|---|
POST | /email/send | qadra.email.send | Send templated email |
Send Email
POST /email/send
Authorization: Bearer eyJ0eXAiOiJKV1Q...
Content-Type: application/json
{
"to": ["recipient@example.com"],
"template_slug": "welcome",
"variables": {
"user_name": "Jane",
"tenant_name": "Acme Corp"
}
}
Response:
{
"success": true,
"email_id": "smtp2go-id",
"succeeded": 1,
"failed": 0
}
Notification Endpoints
Manage email templates and view the notification audit log. All require authentication. Template mutations and log access require owner/admin role.
Email Templates
| Method | Path | NATS Subject | Description |
|---|---|---|---|
GET | /notifications/templates | qadra.email.template.list | List all email templates (global + tenant) |
GET | /notifications/templates/:slug | qadra.email.template.get | Get template by slug |
PUT | /notifications/templates/:slug | qadra.email.template.upsert | Create/update tenant template override (owner/admin) |
DELETE | /notifications/templates/:slug | qadra.email.template.delete | Delete tenant template override (owner/admin) |
POST | /notifications/templates/:slug/test | qadra.email.template.test_send | Send test email to self (owner/admin) |
Notification Log
| Method | Path | NATS Subject | Description |
|---|---|---|---|
GET | /notifications/logs | qadra.email.logs | List notification audit log (owner/admin, paginated) |
Upsert Template
PUT /notifications/templates/welcome
Authorization: Bearer eyJ0eXAiOiJKV1Q...
Content-Type: application/json
{
"name": "Welcome Email",
"subject": "Welcome to {{tenant_name}}, {{user_name}}!",
"html_body": "<h1>Welcome!</h1><p>Hello {{user_name}}...</p>",
"text_body": "Welcome! Hello {{user_name}}..."
}
Templates use Handlebars {{var}} syntax for variable substitution.
Audit Endpoints
Query the NATS bus audit trail stored in MongoDB. Requires authentication.
| Method | Path | NATS Subject | Description |
|---|---|---|---|
GET | /audit/logs | qadra.audit.list | List NATS audit trail (paginated, filterable) |
List Audit Logs
GET /audit/logs?category=auth&operation=login&limit=25&offset=0
Authorization: Bearer eyJ0eXAiOiJKV1Q...
Query parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
category | string? | null | Filter by category (e.g., auth, flow, epistemic) |
operation | string? | null | Filter by operation (e.g., login, create, execute) |
limit | int | 25 | Results per page (1-100) |
offset | int | 0 | Pagination offset |
Response:
{
"logs": [
{
"entry_id": "uuid",
"timestamp": "2026-03-18T10:00:00Z",
"subject": "qadra.auth.login",
"category": "auth",
"operation": "login",
"payload_bytes": 142
}
],
"pagination": {
"total": 500,
"limit": 25,
"offset": 0,
"has_more": true
}
}
Super Admin Endpoints
Platform-wide administrative operations. All require requireAuth + requireSuperAdmin middleware (JWT must have sa: true).
| Method | Path | NATS Subject | Description |
|---|---|---|---|
GET | /admin/tenants | qadra.admin.list_tenants | List all tenants (paginated) |
POST | /admin/tenants | qadra.admin.create_tenant | Create tenant with owner |
GET | /admin/users | qadra.admin.list_users | List all users (paginated) |
POST | /admin/users/:userId/reset-password | qadra.admin.reset_password | Reset user password |
POST | /admin/users/:userId/promote | qadra.admin.promote | Promote to super admin |
POST | /admin/users/:userId/demote | qadra.admin.demote | Remove super admin |
GET | /admin/stats | qadra.admin.stats | Platform stats (counts + recent entries) |
Create Tenant
POST /admin/tenants
Authorization: Bearer eyJ0eXAiOiJKV1Q... (super admin)
Content-Type: application/json
{
"name": "New Organization",
"owner_user_id": "uuid"
}
Platform Stats
GET /admin/stats
Authorization: Bearer eyJ0eXAiOiJKV1Q... (super admin)
Response:
{
"total_tenants": 42,
"total_users": 156,
"super_admin_count": 2,
"recent_tenants": [...],
"recent_users": [...]
}
Health Endpoints
No authentication required.
| Method | Path | Description |
|---|---|---|
GET | /health | Liveness check (always returns ok) |
GET | /health/ready | Readiness check (verifies NATS connectivity) |
Error Responses
All errors follow this format:
{
"error": {
"code": "WORKLOAD_NOT_FOUND",
"message": "Workload with ID xyz not found"
}
}
Error Codes
| Code | HTTP Status | Description |
|---|---|---|
UNAUTHORIZED | 401 | Invalid or missing authentication |
FORBIDDEN | 403 | No access to resource (insufficient role) |
NOT_FOUND | 404 | Resource not found |
CONFLICT | 409 | Resource already exists (e.g., duplicate invite) |
VALIDATION_ERROR | 422 | Invalid request body |
RATE_LIMITED | 429 | Too many requests |
INTERNAL_ERROR | 500 | Server error |
SERVICE_UNAVAILABLE | 502/503 | Backend (Rust Core via NATS) unreachable |
Pagination
Paginated endpoints accept limit and offset query parameters:
GET /flows?limit=20&offset=0
Limits are clamped to 1-100. Default limit is 20, default offset is 0.
Paginated responses include a pagination object:
{
"data": [...],
"pagination": {
"total": 150,
"limit": 20,
"offset": 0,
"has_more": true
}
}
Not all paginated endpoints return a total count. List endpoints that return lightweight arrays (e.g., /teams/members, /flows) may omit the pagination wrapper and return { data: [...] } or { members: [...] } directly.
Glossary
Domain terms with specific meanings in Qadra. Understanding these precisely is essential---we use words carefully.
Core Concepts
| Term | Definition | NOT This | Why It Matters |
|---|---|---|---|
| Agent | Named specialist with a persona (display name, avatar, system prompt) that performs tasks via LLM calls. A team member with a defined role. | A chatbot. A prompt wrapper. | Agents have roles, expertise, and responsibilities---not open-ended capabilities. |
| Workload | Unit of work that moves through pipeline stages (deal, ticket, project). | A chat thread. A conversation. | Workloads have completion criteria. They end when the pipeline is done. |
| Pipeline | Configurable sequence of stages a workload traverses. | A prompt chain. A workflow. | Pipelines enforce ordering. Stage N must complete before stage N+1 begins. |
| Task | Agent assignment to a workload at a specific stage. | A todo item. A background job. | Tasks are created dynamically as workloads progress. They track execution state. |
| Artifact | Output produced by an agent (document, analysis, report). | A file. A response. | Artifacts are deliverables for humans. Task outputs are data for downstream agents. |
| Tenant | Organization with isolated data, agents, and pipelines. | A user. An account. | Complete isolation. One tenant cannot see another tenant's anything. |
| Conversation | Direct chat interface where a user talks to agents. Agent can be switched per message. | A workload. A pipeline execution. | Conversations are freeform; workloads are structured. Both produce artifacts. |
Agent Model
| Term | Definition | Why It Matters |
|---|---|---|
| System Prompt | The agent's instructions and persona definition. Defines behavior, expertise, and communication style. | Agents are LLM personas with structured prompts, not programs. |
| Control Type | What happens after an agent completes: retain (keep control), relinquish_to_parent (default), relinquish_to_start. | Prevents infinite agent loops and controls orchestration flow. |
| Output Visibility | Whether output is shown to users (user_facing, default) or kept internal to orchestration (internal). | Some agents do background work that should not surface to end users. |
| Safety Rails | Per-pipeline max_handoffs_per_turn (default 25) and per-agent max_calls_per_parent_agent (default 3). | Hard limits that prevent infinite loops and recursive abuse during orchestration. |
| Pipeline Snapshot | Immutable JSONB copy of pipeline stages captured at workload creation time. | Running workloads are unaffected by subsequent pipeline edits. |
Agent Flows
| Term | Definition | Why It Matters |
|---|---|---|
| Agent Flow | Visual, graph-based workflow definition stored as ReactFlow JSONB ({nodes, edges, viewport}). Tenant-scoped, supports multiple node types. | Enables no-code orchestration of multi-step agent processes with branching and conditions. |
| Flow Execution | Runtime instance of a flow. State machine: pending, running, paused, completed, failed, cancelled. Contains blackboard state, per-node states, and execution queue. | Each execution is an independent run with its own state. |
| Flow Node | Individual step in a flow graph. Types: Start, End, Agent, Condition, HumanInput, Loop, Http, ExecuteFlow, Transform, Delay, KnowledgeLookup, Extract, Research, Artifact. | Each type has specific config data and execution behavior. |
| Flow State Dictionary | JSONB blackboard shared across all nodes in an execution. Each node reads inputs and writes outputs. | Key-value pairs flow downstream through the graph, enabling data passing between nodes. |
| HumanInput Gate | Flow node type that pauses execution and waits for user response. Sets execution to paused and node to waiting_for_input. | Human-in-the-loop approval, data entry, or decision points within automated flows. |
| Flow Executor | Event-driven graph executor in src/flow_executor.rs. Processes the execution queue, runs nodes, evaluates conditions, handles branching/merging. | Safety rails: MAX_TOTAL_STEPS=500, max Loop iterations=100, max Delay=300s. |
| Flow Snapshot | Immutable copy of flow_data captured at execution start. | Running executions are unaffected by subsequent edits to the flow definition. |
| SpoFilter Node | Flow node that queries the SPO store for triples (optionally filtered by asserter) and writes them into flow state. | Lets a flow pull structured prior knowledge from the knowledge graph mid-execution. |
| SpoWrite Node | Flow node that persists an agent's output as SPO triples with provenance. Config: content_key, asserter, source, output_key. | Closes the loop---agent outputs become queryable, grounded knowledge for downstream stages. |
| ClaimValidation Node | Flow node that grounds prior-stage claims against SPO triples, reporting SUPPORTED/CONTRADICTED/NOT_MENTIONED per claim. | Catches ungrounded or contradicted claims before they reach a final deliverable. |
Knowledge Graph
| Term | Definition | Why It Matters |
|---|---|---|
| SPO Index | Subject-Predicate-Object triple store for structured fact storage and retrieval. | Facts are queryable and verifiable. You can check if a claim is grounded. |
| Triple | A single fact: (Subject, Predicate, Object). Example: (Apple, founded_by, Steve Jobs). | The atomic unit of factual knowledge. Triples link to form a knowledge graph. |
| Epistemic Metadata | Who made a claim (asserter) and its epistemic status: fact, claim, or opinion. | Not all knowledge is equal. Facts from NASA differ from opinions in a blog post. |
| Vector Store | Embedding storage for semantic similarity search (pgvector with HNSW indexes). | Enables "find similar content" without exact keyword matching. |
| Document Ingestion | Pipeline in src/ingestion/ that analyzes content, extracts SPO triples, chunks text, and embeds vectors. | Intelligent routing---facts go to SPO, narrative goes to vectors. |
| Content Routing | Analyzer classifies content as Spo (triples only), Vector (chunks only), Both, or Discard. | Prevents junk content from polluting the knowledge graph. |
| Document Proposal | Human-gated ingestion flow. Agents propose documents (pending_approval); humans approve or reject before ingestion runs. | Lifecycle: pending_approval then approved then processing then completed/failed, or pending_approval then rejected. |
| Knowledge Lookup | Querying the SPO index and vector store together. SPO navigates structure; pgvector retrieves similar content. | Standard RAG searches everything. SPO-RAG searches where the knowledge graph points. |
| Provenance | document_triples and document_chunks tables link extracted knowledge back to source documents. | Every fact in the knowledge graph traces to its origin. |
| Grounding | Validating LLM outputs against SPO facts. | The antidote to hallucination. Ungrounded claims are flagged. |
| Semantic Cache | Redis-based cache storing verification results by embedding similarity. | Avoids redundant LLM calls for semantically similar queries. |
| Entity Alias | Surface form to canonical SPO node mapping. Handles synonyms and abbreviations. | "AAPL", "Apple Inc.", and "Apple" all resolve to the same entity. |
Attribution (KG-SMILE)
| Term | Definition | Why It Matters |
|---|---|---|
| KG-SMILE | Knowledge Graph Structured Metrics for Interpretable LLM Explanations. | Academic foundation for explainable GraphRAG. |
| Two Gates Model | Pre-synthesis competence gate + post-synthesis verification gate. | "Should I attempt this?" + "Did I represent the context faithfully?" |
| Competence Gate | Pre-synthesis check: does the knowledge graph have enough coverage for this query? | Prevents attempts on queries outside the system's knowledge. |
| Verification Gate | Post-synthesis check: does the response faithfully represent the retrieved context? Two LLM-free verifiers run in production (rule-based grounding + Belnap four-valued); perturbation attribution is a future higher-precision path. | Catches hallucinations and unfaithful responses. |
| Rule-Based Grounding Gate | Production verifier (verify_response_rule_based): extracts implied triples from the response and checks word-overlap against context triples; passes iff faithfulness ≥ 0.5. | The default verifier on every chat response — no LLM. |
| Belnap Four-Valued Gate | Production verifier (verify_with_belnap, qadra-belnap crate): grounds each claim against a polarity-tagged SPO graph over Belnap logic — Grounded / Contradicted / Contested / Ungrounded. Fails only on Contradicted. | Distinguishes "false" from "no evidence"; pure-Rust, optional ONNX UD parser. |
| Perturbation Analysis | Systematically removing triples to measure their impact on responses (verify_response). Higher-precision attribution path, not yet on the live chat pipeline. | Reveals which facts actually influenced the output. |
| Fidelity | How well perturbation responses align with the original (R-squared). | High fidelity = attributions explain the variance. |
| Faithfulness | Correlation between attributions and actual response impact. | High faithfulness = attributions are trustworthy. |
| Stability | Robustness of responses to knowledge graph modifications. | High stability = responses do not flip on minor changes. |
Agent Evaluation
| Term | Definition | Why It Matters |
|---|---|---|
| Rubric / RubricKind | The primary metric kind scored for an agent: SourceCoverage (Scarlet---triple provenance), FilterAccuracy (Ayana), GroundingScore (Eliza), ReviewCompleteness (Reagan). | Each persona is judged on the dimension that matters for its job, not a one-size-fits-all score. |
| Release Gate | release_gate_check: fails if any agent's golden-fixture average falls below its threshold. | Stops regressions in agent quality from shipping---a hard CI gate on persona behavior. |
Architecture
| Term | Definition | Why It Matters |
|---|---|---|
| Rust Core | The truth layer. Stateless Rust application handling all NATS subjects. Owns PostgreSQL and MongoDB connections. Eight handler groups: epistemic, workload, auth, email, audit, observer, admin, flow. | Source of truth for all data operations. Never accessed directly via HTTP. |
| Gateway | Node.js HTTP service (gateway/) that handles JWT creation/validation, CORS, file uploads to MinIO, and routes HTTP requests to Rust Core via NATS. Never touches PostgreSQL directly. | Thin HTTP surface translating REST into NATS request-reply. |
| Python Agent | Python service (agent/) that handles LLM-driven orchestration. Receives NATS messages for workload execution, agent queries, and document verification. | The intelligence layer---Rust handles data, Python handles reasoning. |
| NATS | Message bus for all inter-service communication. Request-reply pattern. JetStream enabled for durable streams. | Decouples services. Core, Agent, and Gateway communicate only through NATS. |
| NATS Observer | Catch-all NATS subscriber (qadra.>) that records every message to MongoDB nats_audit collection. Metadata only, no payload content. Fire-and-forget. | Full audit trail without modifying existing handlers. Skips qadra.audit.> to avoid recursion. |
| ServiceContainer | IoC container wiring all trait implementations. Built via ServiceContainer::from_config(config). | Single point of configuration. Easy testing with mocks. Swap backends without code changes. |
| Hot Layer | Redis cache/streams for frequently accessed data. | 10-100x faster than PostgreSQL for repeated reads. |
| Source of Truth | PostgreSQL. All authoritative state lives here. | If Redis says X and PostgreSQL says Y, PostgreSQL wins. |
Infrastructure
| Term | Definition | Why It Matters |
|---|---|---|
| Kong | API gateway for JWT validation, rate limiting, and routing. DB-less mode with declarative config. | Offloads auth concerns from application. Proven at scale. |
| pgvector | PostgreSQL extension for vector similarity search with HNSW indexes. | Native vector search without a separate vector database. |
| MinIO | S3-compatible object storage for files, avatars, tenant logos, documents. Self-hosted. | S3 API is industry standard. Same code works with AWS S3, Backblaze B2, etc. |
| Imaginary | HTTP-based image processing service (resize, crop, format conversion). | Avatar uploads auto-resized to 256x256 WebP. Falls back gracefully if unavailable. |
| OTel | OpenTelemetry---observability framework for traces, metrics, logs to Loki/Prometheus/Grafana. | Industry standard. Trace requests across services. |
Multi-Tenancy and Auth
| Term | Definition | Why It Matters |
|---|---|---|
| UserTenant | Junction record linking a user to a tenant with a per-tenant role and is_default flag. | Users can belong to multiple organizations. |
| Super Admin | Platform-level is_super_admin boolean on users table. NOT a per-tenant role. Capabilities: create tenants, drop into any tenant, reset passwords, promote/demote. | Server-wide privilege for platform operators. First super admin promoted via direct SQL. |
| Admin Drop-In | Super admin switching into a tenant they do not belong to. Creates a synthetic JWT with target tenant context. Does NOT add to user_tenants. | Platform support without polluting tenant member lists. |
| Team Invite | Token-based invitation (7-day expiry). Smart routing: existing users added directly, unknown users receive email invite. | Supports both internal user addition and external email invitations. |
Templates and Output
| Term | Definition | Why It Matters |
|---|---|---|
| Artifact Template | Reusable structure defining expected sections, output format, and optional renderer binding. Tenant-scoped, versioned. | Standardizes agent output. Ensures documents always have consistent sections. |
| Email Template | Per-tenant email template with Handlebars {{var}} syntax. Global defaults with tenant_id IS NULL; tenant overrides by slug. | Consistent, customizable email communication per organization. |
| Notification Log | Audit record of a sent notification (email, future SMS/push). Multi-channel from day one. | Every email the platform sends is traceable for compliance. |
| Plugin | Lightweight program that transforms data without model calls. Types: extractor, validator, evaluator. | Deterministic data processing---fast, cheap, predictable. |
File Storage
| Term | Definition | Why It Matters |
|---|---|---|
| FileRecord | Metadata record in files table linking a tenant-scoped file to its S3 object. Polymorphic via purpose, entity_type, entity_id. | No URL column---URLs are always gateway-proxied for tenant isolation. |
| StorageProvider | Enum (internal or s3) indicating where files are stored. internal = platform MinIO. s3 = tenant's own S3. | Tenants can bring their own storage backend. |
Investment Banking Domain
| Term | Definition | Why It Matters |
|---|---|---|
| Deal | A workload in the investment banking pipeline. | The unit of work we are tracking. Each deal produces an investment recommendation. |
| Due Diligence | Pipeline stage where claims are validated and risks assessed. | Where grounding matters most. Every claim is verified against facts. |
| Investment Memo | Artifact synthesizing findings into investment recommendation. | The deliverable for the Investment Committee. |
| IC | Investment Committee---group that reviews investment memos. | Decision-makers. The memo is written for them. |
| CIM | Confidential Information Memorandum. Source document for deal analysis. | Primary input. Contains company financials, strategy, market position. |
Anti-Glossary
Terms we deliberately do not use---and what to say instead:
| Avoid | Use Instead | Why |
|---|---|---|
| "Chat" | "Conversation" or "Workload" | Conversations are freeform agent chat. Workloads are structured pipeline execution. Neither is a generic chatbot. |
| "Message" | "Task output" or "Artifact" | Agents produce work products. (Exception: conversation messages are called messages.) |
| "Prompt" | "System prompt" or "Agent persona" | Agents have structured personas, not ad-hoc prompt strings. |
| "AI Assistant" | "Agent" | Agents are specialists with defined capabilities, not general assistants. |
| "RAG" | "Knowledge retrieval" or "SPO + vector" | We use structured facts AND semantic search. RAG undersells it. |
| "Context window" | "Requirements" or "Forwarded context" | Agents receive structured context from prior stages, not raw token dumps. |
| "Hallucination" | "Ungrounded claim" | Be specific. Claims are grounded or ungrounded against SPO facts. |