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:

  1. User asks a question
  2. System retrieves "relevant" documents via RAG
  3. Everything gets stuffed into a prompt
  4. Model generates a response
  5. 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:

AgentStageWhat They DoWhy Specialized
Research AnalystResearchGathers market data, competitive analysisNeeds access to market databases, knows how to extract relevant metrics
Due Diligence SpecialistDue DiligenceValidates claims, assesses risksCross-references facts against knowledge graph, flags ungrounded claims
Memo WriterInvestment MemoSynthesizes findings into recommendationFormats for IC consumption, knows what decision-makers need
ReviewerReviewFinal quality checkFresh 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 ISQadra is NOTWhy It Matters
Workload orchestrationA chatbot frameworkWork has structure, stages, and completion criteria
Agents as team membersAI assistantsAgents have roles, expertise, and responsibilities
Pipelines with stagesConversation threadsProgress is measurable, handoffs are explicit
Structured artifactsFree-form responsesOutputs are typed, versioned, and auditable
Multi-model coordinationSingle-model promptingRight model for each task, selected by orchestration
Fact-grounded outputsHopeful generationClaims 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 TemplateQadra Agent
"You are a helpful research analyst..."Named persona with system prompt, display name, avatar, description
Hopes the model understands contextReceives scoped context from pipeline stages or flow state
Output is whatever the model saysOutput is structured, captured as artifacts, forwarded to downstream stages
Testing = vibesTesting = predictable orchestration with observable state at each node
Versioning = copy-pasteVersioning = 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_type determines what happens after the agent completes (retain control, hand off to parent, or return to pipeline start). output_visibility controls whether output is user-facing or kept internal. max_calls_per_parent_agent prevents runaway loops.

  • Versioning: Agents support version numbers. New versions can be deployed alongside old ones, with is_active controlling 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 TypePurpose
Start / EndEntry and exit points of the graph
AgentInvoke an agent with context from the flow state
ConditionBranch based on expressions evaluated against flow state
HumanInputPause execution and wait for user input before continuing
LoopIterate over a collection or repeat until a condition is met
HttpMake external HTTP calls
ExecuteFlowRun a sub-flow (composition)
TransformManipulate flow state data
DelayWait a specified duration
KnowledgeLookupQuery the knowledge graph or vector store
ResearchAutonomous external research
ExtractLLM-driven structured parameter extraction from unstructured text
ArtifactProduce 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 TypeUse SPOUse 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 TypeRoutingExample
Structured factsSPO triples"Revenue was $100B in Q4"
NarrativeVector chunks"The company has shown remarkable growth..."
MixedBothWikipedia 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:

ConcernHow Qadra Handles It
Data isolationEvery query includes tenant_id. Cross-tenant queries are impossible at the repository layer.
Knowledge isolationSPO triples and vector embeddings are tenant-scoped. Acme's facts never leak to Beta Corp.
Agent isolationTenants define their own agents. "Research Analyst" means different things to different orgs.
Billing isolationLLM costs, storage, and compute are tracked per-tenant via usage records.
ComplianceAudit 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.

LayerTechnologyPurposeWhy This Choice
CoreRust + NATSData layer, NATS handlersMemory safety without GC pauses. Async-first. Compiles to single binary.
GatewayNode.js + ExpressHTTP server, JWT, CORSThin layer for auth and routing. Communicates with Rust Core via NATS.
IntelligencePython AgentLLM orchestrationPython ecosystem for ML/LLM tooling. Handles workload execution, agent loops.
FrontendReact + ViteSPA with flow builderReactFlow for visual flow editing. Tailwind + zustand for state.
DatabasePostgreSQL 16Source of truth, pgvectorACID transactions, native vector search, mature ecosystem
Cache/EventsRedis StackCaching, pub/sub, streamsSub-millisecond reads, consumer groups for reliable delivery
MessagingNATSService communicationRequest-reply between Rust Core, Gateway, and Python Agent
AuditMongoDBDecision trees, tracesSchema-flexible for evolving audit formats, append-optimized
GatewayKongJWT validation, rate limitingOffloads auth from application, proven at scale
ObservabilityOTel + GrafanaMetrics, logs, tracesIndustry 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

RequirementVersionWhy
Rust1.75+Async traits, let-else syntax, modern features
DockerLatestPostgreSQL, MongoDB, NATS, MinIO, observability stack
Node.js18+Gateway and web frontend development
Yarn1.x+Package manager for gateway and web
Python3.11+Agent development
sqlx-cliLatestDatabase 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:

CratePurposeDependencies
qadra-traitsAll interfaces and shared typesMinimal (async-trait, uuid, chrono)
qadra-containerIoC container, mock stubstraits
qadra-db-postgresPostgreSQL Repository impltraits, sqlx
qadra-llm-openrouterLLM provider (OpenRouter)traits
qadra-cache-redisRedis cache impltraits
qadra-queue-redisRedis Streams queue impltraits
qadra-vector-pgvectorpgvector VectorIndex impltraits
qadra-semantic-cache-redisSemantic verification cachetraits
qadra-epistemic-coreEpistemic operations (KG, attribution)traits
qadra-natsNATS client wrappernats crate
qadra (root)Binary entry point, NATS handlers, flow executorall 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:

  1. Migration -- Create SQL migration for any new tables or columns
  2. Traits types -- Add types and trait methods in qadra-traits
  3. PostgreSQL impl -- Implement trait methods in qadra-db-postgres
  4. Container mock stubs -- Add default return values in qadra-container
  5. NATS handler -- Add handler case in src/nats_service.rs
  6. Gateway route -- Add HTTP route in gateway/src/routes/
  7. Frontend -- Add page/store in web/src/
  8. Rule docs -- Update .claude/rules/ files

Adding a New NATS Handler

  1. Define the operation in src/nats_service.rs:
#![allow(unused)]
fn main() {
"your_operation" => {
    // Parse request, call repository, return response
}
}
  1. 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);
});
  1. Register route in gateway/src/index.ts

Adding a New Database Table

  1. Create migration:
sqlx migrate add my_table
  1. 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);
  1. Add trait in crates/qadra-traits/src/lib.rs
  2. Implement in crates/qadra-db-postgres/src/lib.rs
  3. Add mock stub in crates/qadra-container/src/lib.rs

Code Style

Naming Conventions

ItemConventionExample
Cratesqadra-*qadra-db-postgres
Modulessnake_caseflow_executor
TypesPascalCaseFlowExecution
Functionssnake_casecreate_flow
ConstantsSCREAMING_SNAKEMAX_TOTAL_STEPS
Database tablessnake_caseagent_flows
API endpointskebab-case/flows/:flowId/execute
NATS subjectsdot-separatedqadra.{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:

  1. TEST_DATABASE_URL -- Explicit test database URL (if set)
  2. DATABASE_URL -- Derives test URL by replacing database name with qadra_test
  3. 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 code
  • develop -- Integration branch
  • feat/* -- Feature branches
  • fix/* -- Bug fix branches

CI Pipeline

GitHub Actions at .github/workflows/ci.yml runs on push to main/develop and PRs targeting main. Key jobs:

JobDescription
secret-scanGitleaks secret detection
rust-formatcargo fmt --check
rust-check-testclippy + unit tests (one compile)
test-integrationDatabase integration tests (pgvector)
coverage-rustcargo llvm-cov code coverage
build-rustRelease binary build
webBiome + tsc + vitest + build
dockerBuildx + Trivy + SBOM + cosign
ci-passAggregate 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 $TOKEN is the access_token from login (15-minute lifetime --- re-login or refresh if it expires).
  • You never pass tenant_id in 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.
TutorialWhat you learnReference
1. Ingest knowledge & query itThe human-gated ingestion lifecycle and epistemic grounding in conversationsKnowledge Graph
2. Build & run an SPO persona pipeline flowWiring Scarlet -> Ayana -> Eliza -> Reagan with a claim-validation checkpointAPI Reference -> Flows
3. Trace a memo claim for sign-offWalking a claim back to its supporting triples and source documentsKnowledge 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.

  1. 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..."
    
  2. 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" }
    
  3. Approve the proposal (the human gate). Approval stamps approved_by/approved_at and 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"
    }
    
  4. Poll until ingestion completes. Status moves approved -> processing -> completed. The ingestion_result carries 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 to doc-2222 for provenance.

  5. 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" }
    
  6. 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" }
    
  7. 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 verification block 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 under ungrounded_claims instead of hiding them.

    To watch the gates and node steps in real time, POST to /conversations/cnv-4444/messages/stream instead and read the text/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:

PersonaRoleNode behavior
ScarletResearch --- writes triplesAgent then SpoWrite (persists her output as SPO triples with provenance)
AyanaAnalysis --- filters triplesAgent then SpoFilter (queries SPO triples into state, optionally by asserter)
ElizaDue diligence --- drafts the DDAgent
ReaganReview --- approves the outputAgent
---Cross-stage grounding checkClaimValidation (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.

  1. Create the flow definition. The graph is a ReactFlow {nodes, edges, viewport} object. Each node's data carries its type-specific config. Reuse $TOKEN from 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/generate builds one from a natural-language prompt instead (see API Reference -> AI Flow Generation).

  2. Execute the flow. The input object seeds the flow state --- here, the company under review. The executor walks the graph from Start, 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" }
    
  3. Poll the execution until it finishes. Status follows pending -> running -> completed (or failed/cancelled). If you had inserted a HumanInput node, it would pause at paused here and wait for POST /flows/executions/exe-7777/input.

    curl http://localhost:4000/flows/executions/exe-7777 \
      -H "Authorization: Bearer $TOKEN"
    
  4. Inspect the results. A completed execution exposes the per-node node_states (each with status, input, output) and the shared state blackboard. The ClaimValidation node'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_MENTIONED label 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).

  1. Trace the claim. Paste the memo statement verbatim. max_results caps 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
      }'
    
  2. Read the trace. Each match groups a supporting triple (with its asserter and assertion_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 by scarlet, grounded in doc-2222 with the exact excerpt. duration_ms confirms it came in under the sub-500 ms IC budget.

  3. Handle an unsupported claim. If the memo asserts something the graph cannot back up, matches comes 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_MENTIONED label from Tutorial 2's ClaimValidation node 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

ContainerLanguageRole
Rust Core (qadra-core)RustData layer. Subscribes to NATS subjects, reads/writes PostgreSQL, MongoDB, Redis. No HTTP server.
Node.js Gateway (qadra-gateway)TypeScript/ExpressHTTP boundary. JWT creation/validation, CORS, file uploads. Bridges HTTP requests to NATS. Never touches PostgreSQL.
React SPA (web/)TypeScript/ReactFrontend. Vite dev server at :5173, built and served via nginx in production.
Python Agent (agent/)PythonIntelligence 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-container for 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  │   │                  │
└──────────────────┘   └──────────────────┘   └──────────────────┘
DatabasePurposeWhy This Choice
PostgreSQLSource of truth for all stateACID transactions, pgvector for embeddings, mature tooling, sqlx compile-time verification
RedisHot layer for performanceSub-millisecond cache reads, pub/sub for real-time, streams for event sourcing
MongoDBAudit and compliance logsSchema-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 tablesAgent definitions (cached)Decision trees
pgvector embeddingsPipeline configs (cached)Request traces
Workloads, tasks, artifactsSession stateNATS audit log (nats_audit)
Tenant data, usersEvent streamsCompliance logs
SPO triples, documentsPub/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

  1. Gateway receives HTTP request, validates JWT, publishes to qadra.{tenant}.workload.do.create
  2. Rust Core handler validates payload, inserts into workloads table within a transaction
  3. Snapshots pipeline stages into pipeline_snapshot JSONB
  4. Publishes workload.created to Redis Stream
  5. 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 PatternHandlerPurpose
qadra.{tenant}.epistemic.>Rust CoreKG queries, triples, attribution, SPO, document ingestion
qadra.{tenant}.workload.do.>Rust CoreWorkload CRUD (create, get, list, tasks, artifacts, stage ops)
qadra.{tenant}.flow.do.>Rust CoreFlow CRUD and execution (create, get, list, update, delete, execute, human_input)
qadra.{tenant}.agent.do.>Rust CoreAgent CRUD (create, get, list, update, delete)
qadra.{tenant}.artifact_template.do.>Rust CoreArtifact template CRUD (create, get, list, update, delete)
qadra.{tenant}.chat.do.>Rust CoreConversations and messages (create, send, list, rate)
qadra.auth.>Rust CoreAuthentication (login, register, refresh, tokens, email verification) -- tenant-less
qadra.email.>Rust CoreEmail operations (send templated email via SMTP2GO) -- tenant-less
qadra.audit.>Rust CoreAudit log queries (list NATS bus audit trail) -- tenant-less
qadra.admin.>Rust CoreSuper 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.executePython AgentFull pipeline orchestration (LLM-driven)
qadra.{tenant}.agent.queryPython AgentAgent loop (synthesis, research, gap detection)
qadra.{tenant}.verify.documentPython AgentDocument verification

Auth Operations (qadra.auth.*)

Auth subjects are tenant-less (pre-authentication context):

OperationSubjectDescription
loginqadra.auth.loginVerify credentials, return user + tenants
registerqadra.auth.registerCreate user + tenant + membership
refreshqadra.auth.refreshValidate refresh token hash
store_tokenqadra.auth.store_tokenStore refresh token hash
revoke_tokenqadra.auth.revoke_tokenRevoke single refresh token
revoke_allqadra.auth.revoke_allRevoke all refresh tokens for user
get_userqadra.auth.get_userGet user profile + tenant memberships
list_membersqadra.auth.list_membersList members of a tenant
invite_memberqadra.auth.invite_memberSmart invite: add directly if user exists, email invite if not
update_member_roleqadra.auth.update_member_roleChange a member's role (owner/admin only)
remove_memberqadra.auth.remove_memberRemove member from tenant (owner/admin only)
accept_inviteqadra.auth.accept_inviteAccept team invite by token
list_invitesqadra.auth.list_invitesList pending team invites
cancel_inviteqadra.auth.cancel_inviteCancel a pending invite
verify_emailqadra.auth.verify_emailVerify email token, mark user verified
resend_verificationqadra.auth.resend_verificationInvalidate old tokens, send new verification email
get_tenantqadra.auth.get_tenantGet tenant details + settings (branding)
update_tenantqadra.auth.update_tenantUpdate tenant name/settings (owner/admin)

Admin Operations (qadra.admin.*)

Admin subjects are tenant-less. Every operation checks is_super_admin before executing.

OperationSubjectDescription
list_tenantsqadra.admin.list_tenantsList all tenants (paginated)
create_tenantqadra.admin.create_tenantCreate tenant + assign owner
list_usersqadra.admin.list_usersList all users with tenant memberships (paginated)
reset_passwordqadra.admin.reset_passwordReset a user's password (argon2)
switch_tenantqadra.admin.switch_tenantDrop into any tenant (bypasses membership)
promoteqadra.admin.promoteGrant super admin (cannot self-promote)
demoteqadra.admin.demoteRevoke super admin (cannot self-demote)
statsqadra.admin.statsPlatform stats

Workload Operations (qadra.{tenant}.workload.do.*)

OperationSubject SuffixDescription
createworkload.do.createCreate workload (snapshots pipeline stages)
getworkload.do.getGet workload by ID
listworkload.do.listList workloads (filter by status)
create_taskworkload.do.create_taskCreate task for agent at stage
complete_taskworkload.do.complete_taskMark task completed with output
fail_taskworkload.do.fail_taskMark task failed with error
create_artifactworkload.do.create_artifactStore artifact produced by agent
record_stageworkload.do.record_stageRecord stage result
advance_stageworkload.do.advance_stageAdvance to next pipeline stage
forwarded_ctxworkload.do.forwarded_ctxBuild forwarded context from prior stages
list_agentsworkload.do.list_agentsList active agents
get_pipelineworkload.do.get_pipelineGet pipeline definition

Flow Operations (qadra.{tenant}.flow.do.*)

OperationSubject SuffixDescription
createflow.do.createCreate flow definition
getflow.do.getGet flow by ID
listflow.do.listList flows (paginated)
updateflow.do.updateUpdate flow (name, description, flow_data, is_active)
deleteflow.do.deleteDelete flow
executeflow.do.executeStart flow execution (creates execution, runs graph)
execution_getflow.do.execution_getGet execution state
execution_listflow.do.execution_listList executions for a flow
execution_cancelflow.do.execution_cancelCancel running execution
human_inputflow.do.human_inputSubmit human input (resumes paused execution)
generateflow.do.generateAI-generate flow graph from natural language prompt

Email Operations (qadra.email.*)

OperationSubjectDescription
sendqadra.email.sendResolve template, render, send via SMTP2GO
template.listqadra.email.template.listList global + tenant email templates
template.getqadra.email.template.getGet template by slug (tenant-specific, global fallback)
template.upsertqadra.email.template.upsertCreate/update tenant template override
template.deleteqadra.email.template.deleteDelete tenant template override
template.test_sendqadra.email.template.test_sendRender template, send to requesting user
logsqadra.email.logsList notification audit log (paginated)

Epistemic Operations (qadra.{tenant}.epistemic.*)

Tenant-scoped knowledge-graph operations handled by Rust Core (and qadra-epistemic-core):

OperationSubject SuffixDescription
trace_claimepistemic.trace_claimTrace a single claim back to its supporting SPO triples
trace_tripleepistemic.trace_tripleChain-of-custody provenance for a triple (source document, excerpt, asserter)
list_attribution_sessionsepistemic.list_attribution_sessionsList KG-SMILE gate-run audit sessions (filterable, paginated) (Story 104)
get_attribution_sessionepistemic.get_attribution_sessionFetch one gate run with its per-triple attributions (Story 104)
list_conflictsepistemic.list_conflictsList detected contradictions in the KG
acknowledge_conflictepistemic.acknowledge_conflictMark a conflict as acknowledged
resolve_conflictepistemic.resolve_conflictResolve a conflict (record the winning triple)
dismiss_conflictepistemic.dismiss_conflictDismiss a conflict as a non-issue
extract_financial_metricsepistemic.extract_financial_metricsFinancial 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:

  1. Start: Parse flow graph, find Start node, seed execution queue
  2. Loop: Pop node from queue, check predecessors complete, execute, store output, enqueue successors, persist state
  3. Pause: HumanInput nodes pause execution, wait for user response
  4. Resume: Human input submitted, merge into state, re-enqueue successors, continue loop
  5. 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:

NodePurposeConfig
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:

  1. Load workload and agents: Fetch the workload (including pipeline_snapshot) and list all active agents from Rust Core via NATS
  2. Sort stages: Order pipeline stages by their order field
  3. 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 agent structure, 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 LLM
    • complete_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)
  4. Return WorkloadResult with 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

SubjectHandlerPurpose
qadra.{tenant}.workload.executeWorkloadOrchestrator.execute_workloadFull pipeline orchestration
qadra.{tenant}.agent.queryAgent loopSynthesis, research, gap detection
qadra.{tenant}.verify.documentVerificationDocument verification against KG

NATS Callbacks to Rust Core

The CoreClient wraps NATS request-reply calls to Rust Core for all data operations:

CoreClient MethodNATS SubjectPurpose
get_workloadqadra.{tid}.workload.do.getFetch workload with pipeline snapshot
list_agentsqadra.{tid}.workload.do.list_agentsList active agents for agent selection
get_pipelineqadra.{tid}.workload.do.get_pipelineFetch pipeline definition (fallback)
get_forwarded_contextqadra.{tid}.workload.do.forwarded_ctxMerged outputs from prior stages
create_taskqadra.{tid}.workload.do.create_taskRecord agent assignment
complete_taskqadra.{tid}.workload.do.complete_taskStore task output
fail_taskqadra.{tid}.workload.do.fail_taskMark task failed
create_artifactqadra.{tid}.workload.do.create_artifactPersist agent output as artifact
record_stage_resultqadra.{tid}.workload.do.record_stagePersist stage completion
advance_stageqadra.{tid}.workload.do.advance_stageMove 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:

VariableDefaultDescription
QADRA_NATS_URLnats://localhost:4222NATS server URL
QADRA_PORT8080HTTP server port
QADRA_LLM_PROVIDERopenaiLLM provider (openai, anthropic, openrouter)
QADRA_LLM_API_KEY--LLM API key (falls back to OPENROUTER_API_KEY from root .env)
QADRA_LLM_MODELgoogle/gemini-3-flash-previewDefault LLM model
QADRA_LLM_EMBEDDING_MODELtext-embedding-3-smallEmbedding model
QADRA_EXA_API_KEY--Exa API key for autonomous research
QADRA_TAVILY_API_KEY--Tavily API key for research
QADRA_PERPLEXITY_MODELperplexity/sonarPerplexity model for research via OpenRouter
QADRA_MAX_RESEARCH_ITERATIONS3Maximum research iterations per query
QADRA_ENABLE_AUTONOMOUS_RESEARCHtrueEnable 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

ServiceContainerPortPurpose
postgresqadra-postgres5432PostgreSQL 16 + pgvector
mongodbqadra-mongodb27017Audit traces, NATS audit log
natsqadra-nats4222NATS message bus
qadra-coreqadra-core3000 (internal)Rust Core -- NATS handlers only
qadra-gatewayqadra-gateway4000Node.js HTTP gateway
kongqadra-kong8000, 8001API gateway, JWT validation, routing
minioqadra-minio9000, 9001S3-compatible object storage
imaginaryqadra-imaginary9002HTTP image processing (resize, crop, format conversion)

Observability Stack

ServiceContainerPortPurpose
otel-collectorqadra-otel-collector4317, 4318, 8889OpenTelemetry Collector
lokiqadra-loki3100Log aggregation
prometheusqadra-prometheus9090Metrics storage (30d retention, 5GB max)
grafanaqadra-grafana3200Visualization and alerting
cadvisorqadra-cadvisor8088Container metrics

Resource Limits

ServiceMemory LimitMemory Reserved
postgres2GB512MB
mongodb1GB256MB
qadra-core1GB256MB
qadra-gateway256MB64MB
minio512MB128MB
imaginary256MB64MB
prometheus1GB256MB
grafana512MB128MB

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:

StreamPurposeConsumers
tasks.createdNew task assignmentsWorkers
tasks.progress.{id}Progress updatesWebSocket handlers
tasks.completedTask finishedStage advancement
stages.advanceMove workload to next stagePipeline coordinator
artifacts.createdNew artifact producedNotification 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
  • sa claim 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:

  1. Kong: Validates JWT signature and expiry
  2. Gateway: Extracts user/tenant claims, injects into NATS payload
  3. Rust Core: Enforces tenant_id scoping 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

LayerWhat It Prevents
Kong JWT validationInvalid/expired tokens
Gateway middlewareMissing authentication
NATS payload validationMalformed requests
Repository tenant filterCross-tenant data access
Database foreign keysOrphaned/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/logo have 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, service fields

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. Skips qadra.audit.> to avoid recursive logging; degrades to a no-op if MongoDB is down.
  • Chose Kong as the API gateway over Traefik / nginx / custom -- battle-tested JWT validation, rate limiting, and routing with declarative config.
  • Chose Axum for any Rust HTTP needs over Actix / Rocket / Go -- Tower middleware ecosystem, async-first, type-safe extractors.

Agent Model

Agents are team members, not prompts.

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

Knowledge Graph & SPO Personas

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

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

Runtime & Plugins

Lightweight, sandboxed, swappable.

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

Workloads & Orchestration

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

Identity & Multi-Tenancy

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

Conversations & Tool-Use

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

Email & Files

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

Frontend

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

CI/CD & Deploy

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

Domain Model

Core Concepts

ConceptDefinitionExample
TenantOrganization with isolated data"Acme Capital Partners"
AgentNamed specialist with a persona and system prompt"Research Analyst"
PipelineConfigurable sequence of stages with draft/live versioning"Deal Pipeline"
WorkloadUnit of work that moves through pipeline stages"Acme Corp Acquisition"
TaskAgent assignment to a workload at a stage"Research Analyst -> Research stage"
ArtifactOutput produced by an agent, optionally from a template"Market Analysis Report.pdf"
FlowVisual graph-based workflow with branching and human gates"Intake Triage Flow"
ConversationDirect agent chat interface with switchable agents"Research chat with Analyst"
DocumentSource 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 language
  • avatar_url: Profile image
  • system_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:

TypeBehaviorUse Case
retainAgent keeps control, can be called againConversational agents, iterative research
relinquish_to_parentReturns control to the calling agent or orchestratorMost agents -- do your job, hand back
relinquish_to_startReturns control to the pipeline entry pointTerminal agents that complete a branch

Output Visibility

VisibilityBehaviorUse Case
user_facingOutput displayed to the end userFinal deliverables, reports, answers
internalOutput kept within orchestration, hidden from userIntermediate 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.

PersonaRoleBehavior
ScarletSPO claim writerExtracts claims and writes them as SPO triples with provenance (asserter, source, assertion_type, confidence). Output is {"triples":[…],"summary":…} JSON.
AyanaInvestment filterReads Scarlet's SPO triples, applies investment filters, and routes deals PASS/CONDITIONAL/FAIL with a pre-screen score.
ElizaDue-diligence writerWrites due-diligence findings back to the SPO store, grounded in Scarlet's triples.
ReaganSenior Investment ReviewerSynthesizes 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.

MethodPathNATS SubjectDescription
POST/agentsqadra.{tid}.agent.do.createCreate a new agent
GET/agentsqadra.{tid}.agent.do.listList agents (paginated)
GET/agents/:agentIdqadra.{tid}.agent.do.getGet agent by ID
PUT/agents/:agentIdqadra.{tid}.agent.do.updateUpdate agent fields
DELETE/agents/:agentIdqadra.{tid}.agent.do.deleteDelete agent

Create request fields (all except name are optional with defaults):

FieldTypeDefaultValidation
namestringrequired1-200 chars
display_namestringnullmax 200 chars
avatar_urlstringnullvalid URL, max 2048 chars
descriptionstringnullmax 2000 chars
system_promptstring""no limit
control_typeenumrelinquish_to_parentretain, relinquish_to_parent, relinquish_to_start
output_visibilityenumuser_facinguser_facing, internal
max_calls_per_parent_agentinteger31-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:

  1. Persona panel: Display name, description. These define the agent's identity as seen by users.
  2. 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.
  3. 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 stages with draft_stages, then clears the draft.
  • Discarding: Sets draft_stages to NULL, leaving stages untouched.

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:

StateMeaningWhat's Happening
pendingCreated but not startedWaiting for user to initiate processing
activeProcessing in progressTasks are running, stages advancing
completedAll stages finished successfullyAll artifacts produced, work is done
failedUnrecoverable error occurredA task failed and couldn't be retried
cancelledManually cancelledUser stopped processing

State Transitions:

FromToTriggerNotes
pendingactiveExecution startsCreates tasks for first stage, snapshots pipeline
activeactiveStage advancementAll stage tasks complete -> next stage
activecompletedAll stages completeFinal stage tasks done
activefailedTask failure (no retry)Retry exhausted or unretryable error
activecancelledUser cancellationManual 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:

TypeContentsPurpose
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

TypeDescriptionExample
documentWritten contentResearch report
analysisStructured analysisRisk assessment
memoInvestment memoIC presentation
reportFormatted reportDue diligence summary
dataStructured dataFinancial 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.

FieldTypeDefaultDescription
namestringrequiredInternal identifier (1-200 chars, unique per tenant+version)
display_namestringnullHuman-readable name (max 200 chars)
descriptionstringnullWhat this template is for (max 2000 chars)
artifact_typestring"document"Category: document, analysis, memo, report, etc.
expected_sectionsarray[]Section definitions (see below)
output_schemaobjectnullJSON Schema for validating structured output
output_formatenum"markdown"markdown, json, html, pdf
renderer_idUUIDnullOptional reference to a renderer plugin
is_activebooleantrueWhether this template is available for use
is_globalbooleanfalseWhether 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
  }
]
FieldTypeDefaultDescription
namestringrequiredSection heading
descriptionstringoptionalGuidance for what this section should contain
requiredbooleantrueWhether 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:

  1. 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.
  2. After generation: If output_schema is set, the output can be validated against the JSON Schema.
  3. Rendering: If renderer_id is set, the renderer plugin transforms the raw output into the target output_format (e.g., markdown to PDF).
  4. Storage: The resulting artifact is stored in the artifacts table with template_id set, 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.

MethodPathNATS SubjectDescription
POST/artifact-templatesqadra.{tid}.artifact_template.do.createCreate a new template
GET/artifact-templatesqadra.{tid}.artifact_template.do.listList templates (paginated, filterable by artifact_type)
GET/artifact-templates/:idqadra.{tid}.artifact_template.do.getGet template by ID
PUT/artifact-templates/:idqadra.{tid}.artifact_template.do.updateUpdate template fields
DELETE/artifact-templates/:idqadra.{tid}.artifact_template.do.deleteDelete 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:

  1. Template Info: Display name, description, artifact type (dropdown), and output format (dropdown).
  2. Expected Sections: A dynamic list where you add, remove, and reorder sections. Each section has a name field, optional description, and a required checkbox.
  3. 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
StatusMeaning
pendingCreated but not started
runningActively executing nodes
pausedWaiting for human input at a HumanInput node
completedEnd node reached
failedNode error or safety limit exceeded
cancelledUser 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.

FieldTypeDescription
labelstringDisplay 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.

FieldTypeDescription
labelstringDisplay name
output_keysstring[]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.

FieldTypeDescription
labelstringDisplay name
agent_idUUID stringReferences an agent in the agents table. The agent's system_prompt is loaded and used as the LLM system message.
prompt_templatestringInstructions for this step. Supports {{variable}} template substitution from state.
output_keystringState 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.

FieldTypeDescription
labelstringDisplay name
expressionstringExpression to evaluate. Supports: key == value, key != value, or key (truthy check).
modestring"expression" (default) or "llm" (LLM-based evaluation, placeholder).

Expression evaluation rules:

  • key == value: Compares state[key] against value. 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. true for non-empty strings, any number, true booleans, non-empty arrays/objects. false for empty strings, null, missing keys, false booleans.

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.

FieldTypeDescription
labelstringDisplay name
promptstringMessage shown to the user describing what input is needed.
timeout_secondsnumberOptional. Not currently enforced.
output_keystringState 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.

FieldTypeDescription
labelstringDisplay name
collection_keystringState key containing the array to iterate over. Default: "items".
max_iterationsnumberMaximum 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.

FieldTypeDescription
labelstringDisplay name
methodstringHTTP method. Default: "GET".
urlstringRequired. Supports {{variable}} template substitution from state.
headersobjectOptional. Key-value map of request headers.
bodyJSONOptional. Request body for POST/PUT/PATCH (sent as JSON).
output_keystringState 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.

FieldTypeDescription
labelstringDisplay name
target_flow_idUUID stringThe flow to execute.
input_mappingstringHow 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.

FieldTypeDescription
labelstringDisplay name
mappingsobjectKey-value map where keys are output field names and values are either state field names (string) to copy, or literal JSON values.
output_keystringState 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.

FieldTypeDescription
labelstringDisplay name
duration_secondsnumberSeconds 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.

FieldTypeDescription
labelstringDisplay name
querystringSearch query. Supports {{variable}} template substitution.
sourcestring"spo", "vector", or "both". Default: "both". Currently queries SPO regardless of this value.
output_keystringState key where results are stored. Default: "knowledge".
limitnumberMaximum 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).

FieldTypeDescription
labelstringDisplay name
querystringResearch query. Supports {{variable}} template substitution.
max_resultsnumberMaximum research results. Default: 3.
output_keystringState 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.

FieldTypeDescription
labelstringDisplay name
template_idUUID stringOptional. References an artifact_templates row. Provides section structure, output schema, and format guidance.
artifact_namestringName for the artifact. Supports {{variable}} template substitution. Default: "Untitled Artifact".
content_keystringState key containing upstream content to format. If empty or null, the full state is used as context.
output_keystringState key where the artifact is stored. Default: "artifact".

Behavior: Four modes depending on available inputs:

  1. No upstream content, no template: Calls LLM to generate content from the full state context.
  2. No upstream content, with template: Calls LLM with template section guidance to generate structured content.
  3. Upstream content, with template: Calls LLM to reformat the upstream content into the template structure.
  4. 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}}.

FieldTypeDescription
labelstringDisplay name
input_keystringState key containing the raw text to extract from. Default: "user_message".
output_variablesstringComma-separated list of variable names to extract (e.g., "company_name, metric, time_period").
instructionsstringOptional. 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

  1. User submits a prompt via POST /flows/generate with a natural language description of the desired workflow.
  2. Gateway forwards the request to Rust Core via NATS subject qadra.{tid}.flow.do.generate.
  3. Rust Core loads context: active agents and artifact templates for the tenant.
  4. 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.
  5. Validation: The generated JSON is validated for structural correctness.
  6. Position assignment: If nodes lack positions, a top-to-bottom grid layout is applied automatically.
  7. Result returned: The validated flow_data JSONB 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:

  • nodes and edges arrays exist and are non-empty
  • Exactly 1 start node
  • At least 1 end node
  • 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_key in its data, the node's output is stored as state[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:

  1. For each outgoing edge, checks if the edge's sourceHandle matches the route
  2. Matching edges: target nodes are enqueued
  3. Non-matching edges: the skip_subtree() function marks the target node and its entire downstream subtree as Skipped (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:

  1. HumanInput node immediately returns NodeResult::Paused
  2. Node status set to WaitingForInput
  3. Full execution state (state dict, node_states, queue) persisted to database
  4. Execution status set to Paused
  5. Executor returns -- the execution is now dormant

Resume (triggered by POST /flows/executions/:execId/input):

  1. NATS handler stores user input in the waiting node's output field
  2. FlowExecutor::resume() is called
  3. Loads persisted state from database
  4. Finds the WaitingForInput node, marks it Completed
  5. Merges the node's output into state under its output_key
  6. Re-enqueues the node's successors
  7. Sets execution status to Running
  8. 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

RailLimitBehavior on Violation
MAX_TOTAL_STEPS500 node executions per runExecution fails with error
Loop max iterations100 itemsArray truncated silently
Delay max duration300 seconds (5 min)Duration capped silently
HTTP timeout30 seconds per requestNode 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:

EventDataWhen
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-deleted
  • idx_conversation_messages_conv_created -- ordered message retrieval by conversation
  • idx_conversation_messages_agent -- find all messages by a specific agent
  • idx_conversation_messages_rating -- query rated messages for feedback analysis

Relationship to other entities:

  • Each conversation_messages.agent_id points to the agent that produced the assistant response. User messages have agent_id = NULL.
  • conversation_messages.flow_execution_id links to a flow_executions row when the message was produced by running a flow.
  • conversation_messages.metadata is a JSONB bag that carries artifacts (inline content blocks for rendering) and suggested_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:

  1. The user selects an agent in the UI (agent picker dropdown).
  2. When sending a message, the selected agent_id is included in the request body.
  3. The backend invokes the chosen agent's system prompt + conversation history to generate a response.
  4. Both the user message and assistant message are persisted. The assistant message records the agent_id that produced it.
  5. 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:

  1. The send_message handler on the Rust Core evaluates whether the agent should invoke a flow (based on the agent's tool configuration and the conversation context).
  2. If a flow is triggered, the executor runs the flow graph and publishes progress events to a NATS progress subject.
  3. The gateway forwards these NATS events as SSE events to the client (see SSE Streaming below).
  4. When the flow completes, the agent receives the flow's output state and synthesizes a final response.
  5. The assistant message is persisted with flow_execution_id set, 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_subject field set.
  • Rust Core publishes FlowProgressEvent messages ({event, data, seq}) to the progress subject during processing.
  • The gateway forwards each event to the SSE stream.

Event types and their data payloads:

EventDataWhen
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_message before the NATS reply arrives, the gateway uses the NATS reply as a fallback and writes it as an assistant_message SSE event.
  • If the SSE stream fails entirely, the frontend falls back to the non-streaming POST /conversations/:id/messages endpoint.

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: rating column is SMALLINT CHECK (rating IN (-1, 1)). Only -1 and 1 are valid.
  • Idempotent: Rating the same message again overwrites the previous rating.
  • Index: idx_conversation_messages_rating enables 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:

OperationNATS Subject
Createqadra.{tid}.chat.do.create
Getqadra.{tid}.chat.do.get
Listqadra.{tid}.chat.do.list
Update titleqadra.{tid}.chat.do.update_title
Deleteqadra.{tid}.chat.do.delete
Send messageqadra.{tid}.chat.do.send_message
List messagesqadra.{tid}.chat.do.list_messages
Rate messageqadra.{tid}.chat.do.rate_message

Key Design Decisions

  • agent_id on 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_id on 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. Setting deleted_at excludes them from listing queries while preserving the audit trail.
  • metadata JSONB bag: Carries artifacts (inline content blocks like charts or agent-produced documents) and suggested_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
StatusMeaning
pending_approvalProposed but awaiting human review
approvedHuman approved, ready for ingestion
processingIngestion pipeline running (routing, extraction, embedding)
completedTriples and/or chunks created
failedIngestion error
rejectedHuman 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:

RouteActionWhen
SPOExtract triples onlyShort structured content (facts, definitions)
VectorChunk and embed onlyLong narrative (articles, reports)
BothExtract triples AND chunkMixed content
DiscardSkip (boilerplate, junk)Low-value content

Results link back to the document:

  • document_triples: Which SPO triples came from this document
  • document_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, set email_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:

CapabilityDescription
Create tenantsCreate tenant + assign owner
Drop into any tenantSynthetic JWT with target tenant context (bypasses membership)
Reset passwordsForce-reset any user's password
List all tenants/usersPlatform-wide visibility
Promote/demoteGrant 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/logo have 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

ResourceItemsDetails
Users51 owner, 1 admin, 3 users (via user_tenants)
API Keys2qk_live_abc... (admin), qk_live_xyz... (read-only)
Agents4research-analyst, due-diligence-specialist, memo-writer, reviewer
Pipelines1deal-pipeline: Research -> DD -> Memo -> Review
Workloads2Acme Corp Acquisition (4 tasks, 3 artifacts), Beta Inc Analysis (2 tasks, 1 artifact)
Flows1Intake triage flow (8 nodes, 3 executions)
Conversations12Direct agent chats by team members
Knowledge Graph--1,234 SPO nodes, 5,678 triples, 89 documents (43 approved, 6 pending)
Files155 avatars, 2 logos, 8 attachments
Email Templates3Custom 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_prompt focused on sourcing and synthesis)
  • Due Diligence Specialist: Validates claims, checks risks (system_prompt focused on verification and risk assessment)
  • Memo Writer: Synthesizes findings into investment memo (system_prompt focused on clear financial writing)
  • Reviewer: Final quality check (system_prompt focused 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
StoragePurposeQuery PatternBest For
SPO IndexStructured factsPattern matching (SP?, ?PO, S?O)Known entities, relationships, verification
Vector StoreSemantic retrievalEmbedding 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.

ComponentDescriptionExample
SubjectThe entity being described"Apple"
PredicateThe relationship"founded_by"
ObjectThe 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:

PatternQueryReturns
SP?Subject + Predicate -> ?Objects
?PO? + Predicate + ObjectSubjects
S?OSubject + ? + ObjectPredicates
S??Subject -> ?Predicate+Object pairs
?P?? + Predicate -> ?Subject+Object pairs
??O? -> ObjectSubject+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:

GoodBad
founded_byresearch_finding
is_located_inkey_point
acquiredis_about
has_featurerelates_to
revenue_ismentioned_in

Rules:

  • Predicates should be verbs or verb phrases
  • Use snake_case: is_located_in, not isLocatedIn
  • Be specific: founded_by not associated_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:

  1. Document chunks: Paragraph-boundary text chunks (~500 chars) for narrative retrieval
  2. SPO node values: Subject/predicate/object values for semantic search on the knowledge graph itself
#![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:

  1. An agent discovers relevant content (via search, connector, or user upload)
  2. The agent proposes the document for ingestion --- it appears in a review queue
  3. A human reviews and approves (or rejects) the proposal
  4. 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:

RoutingContent TypeActionExample
SpoShort, structured factsExtract triples only"Apple was founded in 1976 by Steve Jobs."
BothMixed or long contentExtract triples AND chunk for vectorsNews article with facts and commentary
DiscardJunk contentSkip storageCookie 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, or opinion
  • 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, and end_offset for 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:

  1. Document chunks --> embed_and_store_chunks: Batch-embeds chunk content, upserts EmbeddingRecord entries into the vector index with metadata (chunk_index, start_offset, end_offset)
  2. SPO node values --> embed_spo_nodes: Batch-embeds subject node values, updates their embedding column in spo_nodes via VectorSearchRepository

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.

  1. SPO node search --- match the claim text against SPO node values (subject/object) to find candidate nodes
  2. Triple lookup --- fetch triples involving those nodes, carrying asserter and assertion_type
  3. Reverse document join --- walk document_triples back to documents for 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).

SurfaceValue
NATS subjectqadra.{tid}.epistemic.trace_claim
HTTP endpointPOST /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

TypeDefinitionExample
FactVerifiable through multiple independent sources, widely accepted as true(Apple, founded_in_year, 1976) --- asserter: SEC filings
ClaimStated by a source but not universally verified, may be contested(NVIDIA, will_reach, $5T_market_cap) --- asserter: Morgan Stanley
OpinionSubjective 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:

ClaimTypeMaps to AssertionTypeDescription
VerifiedFactFactMulti-source corroborated (multiple fact indicators like "confirmed", "verified", "documented")
StatedFactClaimSingle authoritative source (e.g., press release, one fact indicator)
ProjectionOpinionForward-looking statement ("expects", "forecast", "guidance", "outlook")
ThirdPartyAssessmentClaimExternal evaluation ("rated", "scored", "analyst", "benchmark")
OpinionOpinionSubjective assessment ("I believe", "seems", "likely", "probably")
DerivedClaimComputed 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:

  1. Score the text against each indicator set (count of matches / total indicators in set)
  2. Apply priority ordering: projection > opinion > assessment > fact
  3. The first category exceeding a 0.05 score threshold wins
  4. Confidence is computed as 0.6 + (score * 0.4), capped at 1.0
  5. Multiple fact indicators (score > 0.15) promote StatedFact to VerifiedFact
  6. If no indicators match, default to StatedFact at 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 TypeWeight
Fact1.0
Claim0.7
Opinion0.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 triples
  • min_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.

VerifierMechanismLLM calls?Status
Rule-based grounding (verify_response_rule_based)Sentence parse + predicate-keyword detection + word-overlap against context triplesNoProduction — 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 latticeNo (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 scoresYes (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:

  1. 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.
  2. Classify each triple — compute word overlap with every context triple; SUPPORTED if any overlap exists, otherwise UNGROUNDED.
  3. Faithfulness = supported / total implied (1.0 if none were extracted).
  4. Gate decision — pass iff faithfulness >= min_faithfulness (default 0.5).
  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 valueMeaningGraph evidence
Grounded (True)Graph supports the claimmatching edge, same polarity
Contradicted (False)Graph asserts the oppositematching edge, opposite polarity
Contested (Both)Graph both supports and contradictscompeting edges
Ungrounded (Neither)Graph says nothingno matching edge

Pipeline (verify_with_belnap):

  1. build_belnap_graph(triples) — each SPO triple becomes a polarity-tagged edge. Edge polarity is derived from the predicate string via predicate_polarity (a negation/contrast-marker heuristic) — v1. The research-faithful source is classify_polarity over the asserting excerpt computed at SPO write time; that is a tracked follow-up (see crates/qadra-belnap/MODEL.md).
  2. Parse the response into claims (subject entity, predicate polarity) using the UD parser.
  3. Ground each claim against the graph and reconcile the claim's polarity against the four-valued result.
  4. passed is 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 edge221$4 - 2 - 2 + 3 = 3$Cluster
Chain (A--B--C), edge A-B120$4 - 1 - 2 + 0 = 1$Cluster
Hub with 4 spokes, spoke edge140$4 - 1 - 4 + 0 = -1$Bridge
Isolated edge110$4 - 1 - 1 + 0 = 2$Cluster

Edge Classification

Curvature values partition edges into three structural roles:

ClassificationCurvature RangeStructural RoleAttribution 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 TypeTypical $\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_selection bridge edges, only bridges are included. Otherwise, edges below the mean curvature are used as a fallback. The result is capped at max_context_edges.

Configuration

All curvature behavior is controlled by CurvatureConfig:

FieldTypeDefaultPurpose
bridge_thresholdf32$-0.1$Curvature below this value classifies an edge as a Bridge. More negative = stricter bridge detection.
min_bridge_percentagef32$0.15$Minimum fraction of edges that must be bridges before curvature-guided sampling is recommended over random.
weight_floorf32$0.01$Minimum weight $\varepsilon$ ensuring every edge has nonzero sampling probability, even highly positive-curvature cluster edges.
max_context_edgesusize$50$Maximum number of edges to include in LLM context or perturbation sets. Caps the output of select_context_edges.
min_bridges_for_selectionusize$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 i
  • P_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

MetricDefaultPurpose
min_coverage0.3Pre-synthesis entity coverage
min_connectivity0.2Pre-synthesis entity connectivity
min_faithfulness0.5Post-synthesis attribution validity
min_fidelity0.7Post-synthesis model fit (R-squared)
kernel_sigma0.5Gaussian 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 name
  • spo_coverage and spo_connectivity (the curvature analysis bridge percentage)
  • competence_passed and 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:

SurfaceValue
ListNATS qadra.{tid}.epistemic.list_attribution_sessions --- HTTP GET /knowledge/attribution-sessions
Get oneNATS 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>,
}
}
ScoreLevelMeaning
>= 0.9Highly groundedNearly all claims supported by context
>= 0.7Mostly groundedMost claims supported
>= 0.5Partially groundedSome claims unsupported
< 0.5Poorly groundedMajority 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:

  1. Competence Gate (pre-synthesis): Do we have knowledge about this topic?
  2. Verification Gate (post-synthesis): Did we stay grounded in that knowledge?
  3. 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:

  1. Read the prior-stage agent outputs from the flow state (the input_keys)
  2. Extract individual claims from each output
  3. Ground each claim against SPO triples in the Knowledge Core
  4. Label each claim SUPPORTED, CONTRADICTED, or NOT_MENTIONED
  5. Write a per-claim result list plus a validation summary back to state under output_key

Configuration:

FieldDefaultPurpose
input_keysscarlet_output,ayana_outputComma-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:

  1. Query entities are checked against alias_of predicates
  2. If an alias is found, the canonical entity is used for the SPO query
  3. 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:

  1. Normalize and group: All triples are grouped by normalized (subject, predicate) pairs. Normalization lowercases and collapses whitespace, so "Tesla" and "tesla" group together.
  2. 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.
  3. Same object, different confidence: Two triples asserting (Tesla, founded_by, Elon Musk) at different confidence levels are NOT a contradiction --- they reinforce each other.
  4. 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 TypeWeightRationale
fact1.0Verified, highest credibility
claim0.6Stated but not universally verified
opinion0.3Subjective, lowest credibility
unknown/missing0.5Conservative 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:

  1. Same assertion type: Flag for human review --- which is current?
  2. Higher-confidence new triple: Replace old triple, archive the original
  3. Lower-confidence new triple: Store as alternative, mark as contested
  4. 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:

  1. LLM call to generate/parse the response (~500ms-2s, ~$0.01-0.10)
  2. 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

ThresholdBehaviorUse Case
0.98+Near-exact match onlyHigh-precision, low hit rate
0.92-0.95Same semantic meaningRecommended default
0.85-0.90Related queriesHigher hit rate, some risk
< 0.85Too looseWrong results returned

Start at 0.93, monitor cache hit rate and verification accuracy.

Performance

OperationLatencyCost
Cache miss (full path)500-2000msLLM tokens + compute
Cache hit (validated)10-20msRedis + 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

StageResponsibilityTrait
MIRRORPull documents from a source system, detect what changed since last syncContentConnector
PROCESSChunk documents, enrich chunks, generate embeddingsDocumentProcessor, EmbeddingProvider
LOADUpsert embeddings into tenant-isolated vector storage; route facts to the SPO indexVectorIndex

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:

TraitPurposeKey Methods
ContentConnectorKnowledge ingestionlist_documents, fetch_documents
DocumentProcessorChunking / enrichmentchunk, enrich, embed
EmbeddingProviderVectors from textembed(texts), dimension()
VectorIndexSemantic searchupsert, search, delete_by_document, delete_tenant
SyncStateStoreDelta sync trackingget_states, upsert_states, mark_deleted, get_last_sync

Available Connectors

ConnectorCrateSource SystemSource Filter
Gatewayqadra-connector-gatewayDirect uploads via the gateway (default)--
Confluenceqadra-connector-confluenceAtlassian Confluence wikiSpaces
Google Driveqadra-connector-gdriveGoogle Drive filesFolders
SharePointqadra-connector-sharepointMicrosoft SharePointSites / libraries
Slackqadra-connector-slackSlack messagesChannels
S3qadra-connector-s3S3-compatible object storagePrefixes

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:

TypeTriggerMethod
IncrementalEvery 15 minuteslist_documents(since=last_sync) -> delta -> fetch only changed docs
FullDaily, 2 AMlist_documents(since=None) -> detect deletions, rebuild sync state
WebhookReal-timeProcess 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_state table 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:

  1. 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.
  2. Version comparison --- for systems with explicit version numbers (Confluence, SharePoint). A higher version means the document changed.
  3. Timestamp comparison --- the fallback. Compare last_modified against the stored synced_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:

  • DocumentContent is an enum of Mmap, Bytes, or Borrowed --- documents can be memory-mapped or borrowed with zero allocation on load.
  • Chunk borrows from the document content (&'doc str) --- no string copies during chunking.
  • EnrichedChunk uses Cow<'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:

MethodPurpose
get_enabledList enabled connectors for a tenant
getFetch a single connector's config
upsertCreate or update connector config
disableDisable a connector
update_credentialsRotate credentials

Each configuration holds:

  • Credentials --- one of OAuth2, ApiKey, Basic, or Aws, 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

TablePurpose
service_plansPlan definitions (id, limits JSONB, available_services JSONB)
tenant_entitlementsPer-tenant plan + overrides + current usage
entitlement_audit_logWho 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:

  1. Build-time --- is_service_compiled(service) checks the active feature flags. The minimal build ships with only the gateway connector and zero cloud dependencies.
  2. 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].

AgentRubricWhat It MeasuresThreshold
ScarletSourceCoverageFraction of emitted {"triples":[…]} carrying a non-empty source0.70
AyanaFilterAccuracy(TP + TN) / total over labelled items0.80
ElizaGroundingScoreFraction of claims grounded in the supplied SPO context0.70
ReaganReviewCompletenessFraction of required review sections present0.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_check fails 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

FilePurpose
crates/qadra-traits/src/eval.rsRubric and case types, scoring trait
crates/qadra-epistemic-core/src/eval/rubric.rsRubric implementations (SourceCoverage, FilterAccuracy, GroundingScore, ReviewCompleteness)
crates/qadra-epistemic-core/src/eval/harness.rsAggregation, release_gate_check, emit_agent_eval_metrics
crates/qadra-epistemic-core/src/eval/fixtures.rsGolden + negative cases per agent
tests/eval_harness.rsIntegration 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:

SignalPatternWeight
iterates_promptRefines query based on the previous response0.6
specifies_format"as JSON", "in a table", "bullet points"0.5
uses_examplesProvides input/output examples0.5
chains_queries"now use that to...", "take that and..."0.5
provides_contextBackground info supplied upfront0.4

Novice Indicators

Lower-weight signals that the user is accepting output passively:

SignalPatternWeight
accepts_firstMoves to a new topic without refining0.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 }
}
LevelResolved When
ExpertNormalized expert score > 0.4
AdvancedNormalized expert > 0.25 or intermediate > 0.5
NoviceNormalized novice > 0.4
IntermediateDefault / 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

DimensionNoviceIntermediateAdvancedExpert
VerbosityComprehensiveDetailedConciseConcise
HeadersYesYesYesNo
BulletsYesYesYesYes
Technical levelLaymanIntermediateTechnicalExpert
Anticipate follow-upsYesYesNoNo
SimplifyYesNoNoNo
ElaborateYesYesNoNo

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 level and confidence.
  • 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.

ConceptWhat it is
AgentFlowThe graph definition ({nodes, edges, viewport}). Tenant-scoped, reusable.
FlowExecutionA single run of a flow. Has its own state machine and per-node state.
Flow NodeOne 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()
);
ColumnRole
flow_snapshotImmutable copy of flow_data captured at run start
stateThe blackboard dictionary flowing between nodes
node_statesPer-node {status, input, output, error, started_at, completed_at}
execution_queueOrdered node IDs ready to run next
current_node_idNode 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 |
                                             +-----------+
StatusMeaning
pendingExecution created, not yet started
runningExecutor is processing the queue
pausedStopped at a HumanInput node, awaiting user response
completedAn End node was reached
failedA node errored or a safety rail was hit
cancelledExplicitly 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.

  1. Start --- Parse the flow snapshot graph, find the Start node, seed the execution queue.
  2. 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.
  3. Pause --- A HumanInput node sets the execution to paused and stops the loop, waiting for user input.
  4. Resume --- When the user submits input, it is merged into state, the node's successors are re-enqueued, and the loop continues.
  5. 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:

RailLimitPurpose
MAX_TOTAL_STEPS500Cap on total node executions in one run
Max Loop iterations100Per-Loop-node iteration cap
Max Delay300sLongest 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.

NodePurposeKey config
StartEntry point. Seeds the queue.---
EndTerminal node. Marks the execution completed.---
AgentRuns an agent persona via an LLM call, using state as context.agent selection, prompt context
ConditionBranches control flow by evaluating a predicate against state.condition expression, true/false edges
HumanInputPauses execution and waits for user input.prompt, fields
LoopRepeats a sub-section of the graph (capped at 100 iterations).iteration source, max iterations
HttpMakes an outbound HTTP request.url, method, headers, body
ExecuteFlowInvokes another flow as a sub-flow.target flow id, input mapping
TransformReshapes / maps values in the blackboard state.transform expression, output_key
DelayWaits for a fixed duration (capped at 300s).duration
KnowledgeLookupQueries the Knowledge Core (SPO/KG), Corpus (vectors), or both.query (templated), source (knowledge_core | corpus | both), output_key, limit
ResearchAutonomous research via external neural search (Exa).query (templated), max_results, output_key
ArtifactProduces an artifact from an artifact template.template_id, artifact_name (templated), content_key, output_key
ExtractUses an LLM to parse structured parameters out of unstructured text.input_key (default user_message), output_variables (comma-separated), instructions
SpoFilterReads SPO triples (optionally filtered by asserter) into state for downstream nodes.asserter filter, output_key
SpoWritePersists an agent's output as SPO triples with provenance.content_key, asserter, source, output_key
ClaimValidationGrounds 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 requested output_variables, and merges the result into state. If the LLM returns non-JSON, it gracefully sets each variable to null rather than failing the run.
  • KnowledgeLookup is the read side of the Knowledge Core. source chooses SPO triples, vector chunks, or both; results land in state under output_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)
  1. The Gateway creates a unique NATS subject _PROGRESS.{uuid} and subscribes to it.
  2. It sends the execution request with that subject in the payload.
  3. 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.
  4. The Gateway forwards each as an SSE event (event: {type}\ndata: {json}\n\n) and closes the stream on done or 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.

OperationSubject suffixDescription
createflow.do.createCreate a flow definition
getflow.do.getGet a flow by ID
listflow.do.listList flows (paginated)
updateflow.do.updateUpdate name, description, flow_data, or is_active
deleteflow.do.deleteDelete a flow
executeflow.do.executeStart an execution (snapshots the flow, runs the graph)
execution_getflow.do.execution_getGet execution state
execution_listflow.do.execution_listList executions for a flow
execution_cancelflow.do.execution_cancelCancel a running execution
human_inputflow.do.human_inputSubmit human input to a paused execution
generateflow.do.generateAI-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.

SurfaceFormal?StructureBest For
WorkloadYesPipeline stages, tasks, artifactsMulti-stage deals (Research --> Due Diligence --> Memo)
FlowYesReactFlow graph, node state machineRepeatable, branching workflows
ConversationNoFlat message threadInformal, 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_at timestamp. All queries filter WHERE deleted_at IS NULL --- threads are never hard-deleted.
  • Analytics columns: model, token_count, latency_ms, and finish_reason live 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_id is 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:

ToolWhen InjectedPurpose
generate_chartAlwaysLLM decides a visualization is appropriate and returns an Apache ECharts option object
run_flowOnly when the tenant has active flowsLLM 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:

  1. Creates a flow execution
  2. Runs the executor inline (45s timeout) so results appear in the conversation
  3. Fetches the completed execution state
  4. Re-calls the LLM with the actual node outputs so the agent narrates real results, not just "started"
  5. Persists the assistant message with flow_execution_id set

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

EventEmitted ByMeaning
user_savednats_serviceUser message persisted
thinkingnats_serviceEpistemic grounding + LLM call starting
flow_startednats_serviceA run_flow tool call kicked off a flow execution
node_startedFlowExecutorA flow node began executing
node_completedFlowExecutorA flow node finished
node_failedFlowExecutorA flow node errored
flow_completednats_serviceThe triggered flow finished
assistant_messagenats_serviceFinal assistant message (with any artifacts)
donenats_serviceStream 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.

ComponentRole
ArtifactCardCompact card inside an assistant bubble. Shows artifact name, a type icon, and a "Click to open" subtitle.
ArtifactPanelClaude-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.
ChartRendererRenders 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.*).

MethodPathNATS SubjectDescription
POST/conversationschat.do.createCreate a conversation
GET/conversationschat.do.listList conversations (paginated, user-scoped)
GET/conversations/:idchat.do.getGet conversation by ID
PATCH/conversations/:idchat.do.update_titleUpdate title
DELETE/conversations/:idchat.do.deleteSoft-delete (sets deleted_at)
POST/conversations/:id/messageschat.do.send_messageSend message + get LLM response (60s)
GET/conversations/:id/messageschat.do.list_messagesList messages (paginated)
POST/conversations/:id/messages/:messageId/ratechat.do.rate_messageRate a message (+1 / -1)
POST/conversations/:id/messages/streamchat.do.send_messageSSE 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.

PropertyPluginAgent
Calls a model?NoYes
ExecutionQuickJS sandboxLLM inference
PurposeTransform / check / score dataReason and synthesize
DeterminismDeterministicProbabilistic

Plugin Types

There are three plugin types, each with a fixed input/output contract:

TypePurposeContract
ExtractorTransform unstructured → structuredextract(input) → { data, confidence }
ValidatorCheck data against rulesvalidate(input) → { valid, errors }
EvaluatorScore content qualityevaluate(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

LimitValue
Memory4MB (lighter than renderers)
Stack256KB
Filesystem accessNone
Network accessNone
Module importsNone (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:

TypePluginPurpose
Extractorjson-pathExtract fields using dot-notation paths
Extractorregex-extractExtract data using regex patterns
Validatorrequired-fieldsCheck required fields are present
Validatortype-checkValidate field types
Validatorrange-checkValidate numeric ranges
EvaluatorcompletenessScore based on filled vs. expected fields
Evaluatorlength-checkEvaluate content length
Evaluatorkeyword-coverageCheck presence of expected keywords

Global vs. Tenant Plugins

Plugins are either global (available to every tenant) or tenant-specific.

AttributeGlobalTenant-specific
Created byAdmins onlyAny authorized user
Visible toAll tenantsSingle tenant
Stored withGlobal tenant UUIDUser's tenant UUID
Modify / DeleteAdmins onlyTenant 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_name returns 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

MethodPathDescription
GET/v1/pluginsList all plugins
GET/v1/plugins/type/:typeList plugins by type
GET/v1/plugins/:type/:nameGet a specific plugin
POST/v1/pluginsCreate a plugin
POST/v1/plugins/:type/:name/executeExecute a plugin
PATCH/v1/plugins/:type/:name/bundleUpdate bundled code
DELETE/v1/plugins/:type/:nameDeactivate (soft delete)

Workbench Integration

The Workbench (authoring UI) owns the full plugin lifecycle up to publishing:

  1. Plugin authoring with syntax highlighting
  2. NPM dependency resolution
  3. esbuild bundling into self-contained JavaScript
  4. Testing against sample inputs
  5. 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

FilePurpose
migrations/009_plugins.sqlDatabase schema + seeded built-in plugins
crates/qadra-traits/src/lib.rsPlugin types, PluginRepository trait
crates/qadra-db-postgres/src/lib.rsPluginRepository implementation
src/runtime/plugin.rsQuickJS execution functions
src/api/routes/v1/plugins.rsREST 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.

RuntimeMemoryCold startInstances/GB
V8/Node30MB50ms~30
QuickJS500KB<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 on
  • inference --- 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.

LimitValue
Base memory500KB
Execution contextup to 1MB
Max stack size256KB
#![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 context object
  • Call inference() for model APIs (via the bridge)
  • Return structured results via return

CANNOT:

  • Access the filesystem (fs is 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.

BoundaryMechanism
Timeouttokio::time::timeout aborts runaway execution (default 5s)
MemoryQuickJS enforces the per-instance memory limit natively
IsolationA 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

MetricTarget
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
  1. acquire --- take a permit + runtime from the pool
  2. create_context --- build a fresh full context
  3. inject_globals --- set context and inference
  4. execute --- run the code under the timeout (the only variable-duration step)
  5. collect --- deserialize the return value
  6. 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

ServiceContainerPortPurpose
natsqadra-nats4222, 8222NATS JetStream message bus (Core <-> Agent <-> Gateway)
postgresqadra-postgres5432PostgreSQL 16 + pgvector for tenant data, embeddings, SPO index
mongodbqadra-mongodb27017Audit traces, NATS audit log
qadra-coreqadra-core3000 (internal)Rust truth layer -- NATS handlers for epistemic, workload, auth, flows
qadra-agentqadra-agent8080 (internal)Python orchestration layer -- LLM calls, research, agentic loops
qadra-gatewayqadra-gateway4000Node.js HTTP gateway -- JWT, CORS, file uploads, routes to NATS
qadra-uiqadra-ui8080 (internal)Open WebUI fork
kongqadra-kong8000, 8001API Gateway -- JWT validation, rate limiting, routing
minioqadra-minio9000, 9001S3-compatible object storage (file uploads, avatars, tenant logos)
imaginaryqadra-imaginary9002HTTP-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_audit collection).
  • 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

ServiceContainerPortPurpose
otel-collectorqadra-otel-collector4317, 4318, 8889OpenTelemetry Collector
lokiqadra-loki3100Log aggregation
prometheusqadra-prometheus9090Metrics storage (30d retention, 5GB max)
grafanaqadra-grafana3200Visualization and alerting
cadvisorqadra-cadvisor8088Container 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.

ServiceMemory LimitMemory ReservedWhy This Limit
postgres2GB512MBHandles embeddings (vector ops are memory-intensive)
mongodb1GB256MBAudit writes are small; WiredTiger manages its cache
qadra-core1GB256MBRust is memory-efficient; mostly handles async I/O
qadra-agent512MB128MBPython orchestrator; LLM calls are I/O-bound
qadra-gateway256MB64MBThin HTTP layer; file uploads stream to MinIO
minio512MB128MBObject storage; mostly disk I/O
imaginary256MB64MBImage processing; handles one image at a time
nats256MB64MBMessage bus; JetStream persistence to disk
prometheus1GB256MBStores 30 days of metrics locally
grafana512MB128MBDashboard 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:5432
  • mongodb:27017
  • nats:4222
  • minio:9000
  • imaginary:9000 (internal port; host-mapped to 9002)
  • loki:3100
  • otel-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 PatternHandlerPurpose
qadra.{tenant}.epistemic.>Rust CoreKG queries, triples, attribution, SPO
qadra.{tenant}.workload.do.>Rust CoreWorkload CRUD and lifecycle
qadra.{tenant}.flow.do.>Rust CoreFlow CRUD and execution
qadra.{tenant}.chat.do.>Rust CoreConversation CRUD and messaging
qadra.auth.>Rust CoreAuthentication (login, register, tokens)
qadra.email.>Rust CoreEmail operations (templates, delivery)
qadra.audit.>Rust CoreAudit log queries
qadra.admin.>Rust CoreSuper admin operations
qadra.>Rust Core (observer)Catch-all to MongoDB nats_audit
qadra.{tenant}.workload.executePython AgentFull pipeline orchestration
qadra.{tenant}.agent.queryPython AgentAgent 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_run tracing events (Story 104)
  • Eval Quality -- Agent output-quality eval scores per agent vs threshold (Story 94), fed by qadra.eval tracing events

Alert Rules

8 provisioned alerts in Grafana:

AlertConditionSeverity
High Container CPU Usage>80% for 5mwarning
High Container Memory Usage>1GB for 5mwarning
Critical Container Memory Usage>2GB for 2mcritical
Prometheus Scrape Target Downdown for 2mcritical
OTel Collector Export Errorsfailures for 5mwarning
Container Restartedrestart detectedwarning
High Error Rate in Logs>10 errors in 5mwarning
Low Disk Space>85% filesystemwarning

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:

MigrationsDomain
001-005Foundation: tenants, agents, graphs, SPO index, memory
006-007Users and workloads (pipelines, tasks, artifacts)
008-009Plugins and renderers
010-016Attribution, documents, epistemic metadata, entity aliases, indexes
017-018Orchestration patterns, pipeline data forwarding
019-024Multi-tenant users, email templates, team invites, file storage, notifications, super admin
025Agent flows (visual workflows with execution engine)
026-028Agent persona fields, artifact templates, agent persona refactor (drop atomics model)
029Conversations (direct agent chat)
030Document 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:

SystemBackendAnswersShape
Structured logs & metricsOTel Collector -> Loki / Prometheus -> Grafana"What happened across the fleet, and when?"Flat, time-ordered events
Audit decision tracesMongoDB"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:

FieldSource
serviceService name (qadra-core, qadra-gateway, etc.)
trace_idFrom OTel context or X-Request-Id
tenant_idFrom 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:

HeaderPurpose
traceparentW3C Trace Context (standard)
X-Request-IdFallback for non-OTel clients

Log Levels

LevelUse
errorUnrecoverable failures, requires attention
warnDegraded but functional, potential issues
infoKey business events (request start/end, task complete)
debugDetailed flow for troubleshooting
traceEverything (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:

TypeLayerDescription
spo_querySPO/RetrievalSubject-Predicate-Object index lookup
curvature_computeSPO/RetrievalRicci curvature calculation
radius_adaptSPO/RetrievalAdaptive radius based on topology
requirement_resolveResolutionSemantic requirement matching
capability_matchResolutionAgent/tool capability search
memory_searchResolutionMemory/RAG retrieval
embedding_generateResolutionEmbedding generation
atomic_spawnExecutionQuickJS context acquisition
agent_invokeExecutionAgent execution
tool_callExecutionTool invocation
llm_callExecutionLLM inference call
task_startWorkloadsTask execution started
task_progressWorkloadsTask progress update
artifact_createWorkloadsArtifact 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 treesOperational events
Compliance / debuggingTroubleshooting
Per-request hierarchiesFlat entries
Long retentionShort retention
Queryable by structureQueryable 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:

ClassRetention
Default90 days
Errors1 year
Compliance auditsAs 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_SECRET and all default passwords MUST be replaced before deploying. Credentials currently live in docker-compose.yml for 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:

ImageRegistry
ghcr.io/$REPO-coreGHCR
ghcr.io/$REPO-gatewayGHCR
ghcr.io/$REPO-agentGHCR

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

JobDescriptionDepends On
secret-scanGitleaks secret detection (full history)
dependency-reviewFlags vulnerable/GPL dependencies (PR only)
rust-formatcargo fmt --check (no compilation)
rust-check-testclippy + unit + doc tests (one shared compile)
test-integrationsqlx migrations + DB/API tests (pgvector)rust-check-test
coverage-rustcargo llvm-cov → lcov + Codecovrust-check-test
build-rustcargo build --release → binary artifactrust-check-test
gatewaytsc --noEmit + hadolint gateway/Dockerfile
webbiome + tsc + vitest + coverage + yarn build
agentruff + mypy + pytest + hadolint agent/Dockerfile
cypress-componentCypress component tests, no backendweb
cypress-e2eCypress E2E (cy.intercept() API mocking)web
dockerBuildx × 3 images + Trivy + Syft SBOM + cosignbuild-rust, web, test-integration, gateway, agent
ci-passAggregate gate for branch protectionall 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 under web/
  • rust-* / test-integration / coverage-rust — gated on Rust sources, crates/, migrations/, Cargo.*
  • gateway — gated on gateway/
  • agent — gated on agent/

Two jobs run only on push to main, never on PRs or feature branches:

  • build-rust — the release binary build
  • docker — 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

MechanismWhat it doesWhen
GitleaksScans full git history for committed secretsevery run + scheduled
Dependency reviewBlocks PRs introducing HIGH+ vulns or GPL licensesPR only
TrivyScans container images (CRITICAL, HIGH), SARIF → Security tabdocker job
Syft SBOMSPDX JSON per image, 90-day artifact retentiondocker job
CosignKeyless image signing via Sigstore/Fulciomain push
BuildKit SBOM + ProvenanceAttached to image manifestsdocker job
GitHub SBOM Attestationactions/attest-sbom provenance chaindocker 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:

ServiceMemory LimitReserved
postgres2GB512MB
mongodb1GB256MB
qadra-core1GB256MB
qadra-gateway256MB64MB
minio512MB128MB
prometheus1GB256MB
grafana512MB128MB

Performance Targets

MetricTarget
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 config passes (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
StageResponsibility
KongValidates 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.
GatewayCreates JWTs on login/register, validates them on every authenticated route, extracts claims, and forwards them inside the NATS request payload.
Rust CoreTrusts 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
}
ClaimMeaning
subUser ID
emailUser email
nameDisplay name
tidActive tenant ID
tnameActive tenant name
rolePer-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)
);
ColumnPurpose
rolePer-tenant role: owner, admin, or user
is_defaultWhich tenant is selected on login
joined_atWhen the membership was created
  • Email is globally unique (idx_users_email_unique).
  • register_user is atomic — it creates the tenant, the user, and the user_tenants membership in a single transaction.
  • Switching tenants (POST /auth/switch-tenant) verifies membership and mints a fresh access token with the new tid/tname.

Per-Tenant Roles

RoleCapabilities
ownerFull control of the tenant. Immutable — cannot be changed or removed.
adminInvite/remove members, change roles, manage templates and tenant settings.
userStandard 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.

CapabilitySubject
List all tenantsqadra.admin.list_tenants
Create tenant + assign ownerqadra.admin.create_tenant
List all usersqadra.admin.list_users
Reset a user's passwordqadra.admin.reset_password
Drop into any tenantqadra.admin.switch_tenant
Promote to super adminqadra.admin.promote
Demote a super adminqadra.admin.demote
Platform statsqadra.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.

ScenarioAlgorithm
New password (register, reset)argon2
Verifying a legacy hash ($2b$ / $2a$ prefix)bcrypt
Verifying any other hashargon2

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() and users.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 via user_tenants.
  • Cancel (qadra.auth.cancel_invite): owner/admin may hard-delete a pending invite.
  • Role checks: only owner/admin can invite; the owner role is immutable.

Defense in Depth

Authorization is enforced at every layer, so a bypass at one layer is caught by the next:

LayerWhat It Prevents
Kong JWT validationInvalid or expired tokens reaching the Gateway
Gateway middlewareMissing or malformed authentication
NATS payload validationMalformed requests reaching handlers
Repository tenant_id filterCross-tenant data access
Database foreign keysOrphaned 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

CheckToolScope
Secret scanningGitleaksScans the full git history for committed secrets (API keys, passwords, tokens).
Dependency reviewactions/dependency-reviewBlocks PRs that introduce HIGH+ vulnerabilities or GPL-licensed dependencies (PR only).
Dockerfile lintingHadolintPer-image Dockerfile linting (gateway, agent, root).
Rust auditcargo audit + cargo denyScheduled (Mondays 06:00 UTC) advisory and license checks.

Container & Image Scanning

CheckToolOutput
Image vulnerability scanTrivyScans 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:

MechanismToolPurpose
Image signingCosignKeyless signing via Sigstore/Fulcio — verifiable provenance of who built the image.
SBOM generationSyft (SPDX JSON)A software bill of materials per image, retained 90 days.
SBOM attestationactions/attest-sbomAttaches a verifiable provenance chain to the image.
Build provenanceBuildKit SBOM + ProvenanceAttached 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_id legacy column (TD-007): retained alongside user_tenants for 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

CategoryPrefixAuth RequiredDescription
Auth/auth/*VariesRegistration, login, token management, email verification
Users/users/*YesUser profile, tenant list, avatar management
Teams/teams/*YesTeam members, invitations, role management
Tenants/tenants/*YesTenant settings and branding
Agents/agents/*YesAgent CRUD (create, list, update, delete)
Conversations/conversations/*YesChat conversations, messages, streaming
Flows/flows/*YesVisual workflow definitions and execution
Artifact Templates/artifact-templates/*YesStructured output templates for agents
Knowledge/knowledge/*YesDocument ingestion with human-in-the-loop gating
Files/files/*YesFile upload, download, deletion (S3-backed)
Email/email/*YesSend templated emails
Notifications/notifications/*YesEmail templates and notification audit log
Audit/audit/*YesNATS bus audit trail
Admin/admin/*Super AdminPlatform-wide tenant/user management
Health/health*NoLiveness 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:

ClaimTypeDescription
substring (UUID)User ID
emailstringUser email
namestringUser display name
tidstring (UUID)Active tenant ID
tnamestringActive tenant name
rolestringPer-tenant role (owner, admin, user, or super_admin for drop-in)
saboolean?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.

MethodPathAuthNATS SubjectDescription
POST/auth/registerNoqadra.auth.registerCreate user + tenant, return tokens
POST/auth/loginNoqadra.auth.loginVerify credentials, return tokens
POST/auth/refreshNoqadra.auth.refresh + store_token + revoke_tokenRotate token pair
GET/auth/verify-email?token=xxxNoqadra.auth.verify_emailVerify email token
POST/auth/logoutYesqadra.auth.revoke_tokenRevoke refresh token
POST/auth/switch-tenantYesqadra.auth.get_user or qadra.admin.switch_tenantSwitch active tenant, get new access token
POST/auth/accept-inviteYesqadra.auth.accept_inviteAccept team invite by token
POST/auth/resend-verificationYesqadra.auth.resend_verificationResend 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.

MethodPathNATS SubjectDescription
GET/users/meqadra.auth.get_userGet current user profile + tenants
GET/users/me/tenantsqadra.auth.get_userList user's tenant memberships
POST/users/me/avatarqadra.auth.file.create + qadra.auth.update_avatarUpload avatar (multipart, max 2MB, auto-resized to 256x256 WebP)
DELETE/users/me/avatarqadra.auth.update_avatarRemove 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.

MethodPathNATS SubjectDescription
GET/teams/membersqadra.auth.list_membersList tenant members
POST/teams/members/inviteqadra.auth.invite_memberInvite by email (smart: add directly or send invite)
PATCH/teams/members/:userId/roleqadra.auth.update_member_roleChange member role (owner/admin only)
DELETE/teams/members/:userIdqadra.auth.remove_memberRemove member (owner/admin only)
GET/teams/invitesqadra.auth.list_invitesList pending invites
DELETE/teams/invites/:inviteIdqadra.auth.cancel_inviteCancel 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.

MethodPathNATS SubjectDescription
GET/tenants/currentqadra.auth.get_tenantGet current tenant settings + branding
PATCH/tenants/currentqadra.auth.update_tenantUpdate 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.

MethodPathNATS SubjectDescription
POST/agentsqadra.{tid}.agent.do.createCreate a new agent
GET/agentsqadra.{tid}.agent.do.listList all agents (paginated)
GET/agents/:agentIdqadra.{tid}.agent.do.getGet agent by ID
PUT/agents/:agentIdqadra.{tid}.agent.do.updateUpdate agent
DELETE/agents/:agentIdqadra.{tid}.agent.do.deleteDelete 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
}
FieldTypeDefaultDescription
namestringrequiredUnique agent identifier
display_namestring?nullHuman-readable name
avatar_urlstring?nullAgent avatar URL
descriptionstring?nullWhat this agent does
system_promptstring""System prompt for LLM context
control_typeenumrelinquish_to_parentretain, relinquish_to_parent, or relinquish_to_start
output_visibilityenumuser_facinguser_facing or internal
max_calls_per_parent_agentint3Safety rail: max invocations per parent

Conversation Endpoints

Chat-style conversations with agents. Supports both synchronous and SSE-streaming message delivery. All require authentication.

MethodPathNATS SubjectDescription
POST/conversationsqadra.{tid}.chat.do.createCreate a conversation
GET/conversationsqadra.{tid}.chat.do.listList conversations (paginated)
GET/conversations/:idqadra.{tid}.chat.do.getGet conversation by ID
PATCH/conversations/:idqadra.{tid}.chat.do.update_titleUpdate conversation title
DELETE/conversations/:idqadra.{tid}.chat.do.deleteSoft-delete conversation
POST/conversations/:id/messagesqadra.{tid}.chat.do.send_messageSend message (synchronous, 240s timeout)
POST/conversations/:id/messages/streamqadra.{tid}.chat.do.send_messageSend message with SSE progress stream
GET/conversations/:id/messagesqadra.{tid}.chat.do.list_messagesList messages (paginated)
POST/conversations/:id/messages/:messageId/rateqadra.{tid}.chat.do.rate_messageRate 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

MethodPathNATS SubjectDescription
POST/flowsqadra.{tid}.flow.do.createCreate a new flow
GET/flowsqadra.{tid}.flow.do.listList all flows (paginated)
GET/flows/:flowIdqadra.{tid}.flow.do.getGet flow by ID
PUT/flows/:flowIdqadra.{tid}.flow.do.updateUpdate flow (name, description, flow_data, is_active)
DELETE/flows/:flowIdqadra.{tid}.flow.do.deleteDelete flow
POST/flows/generateqadra.{tid}.flow.do.generateAI-generate a flow from natural language prompt

Flow Executions

MethodPathNATS SubjectDescription
POST/flows/:flowId/executeqadra.{tid}.flow.do.executeStart flow execution
GET/flows/:flowId/executionsqadra.{tid}.flow.do.execution_listList executions for a flow
GET/flows/executions/:execIdqadra.{tid}.flow.do.execution_getGet execution status and state
POST/flows/executions/:execId/cancelqadra.{tid}.flow.do.execution_cancelCancel running execution
POST/flows/executions/:execId/inputqadra.{tid}.flow.do.human_inputSubmit 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

TypeDescription
StartEntry point, seeds initial state
EndTerminal node, marks execution complete
AgentInvokes an AI agent with context
ConditionEvaluates expression, branches flow
HumanInputPauses execution, waits for user response
LoopIterates over collection (max 100 iterations)
HttpMakes external HTTP request
ExecuteFlowRuns a sub-flow
TransformJavaScript expression to transform state
DelayPauses execution for a duration (max 300s)
SpoFilterQueries SPO triples into state, with an optional asserter filter
SpoWritePersists agent output as SPO triples with provenance (config: content_key, asserter, source, output_key)
ClaimValidationGrounds 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.

MethodPathNATS SubjectDescription
POST/artifact-templatesqadra.{tid}.artifact_template.do.createCreate a template
GET/artifact-templatesqadra.{tid}.artifact_template.do.listList templates (paginated, filterable by artifact_type)
GET/artifact-templates/:idqadra.{tid}.artifact_template.do.getGet template by ID
PUT/artifact-templates/:idqadra.{tid}.artifact_template.do.updateUpdate template
DELETE/artifact-templates/:idqadra.{tid}.artifact_template.do.deleteDelete 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.

MethodPathNATS SubjectDescription
POST/knowledge/proposeqadra.{tid}.epistemic.propose_documentPropose a document for review
POST/knowledge/documents/:id/approveqadra.{tid}.epistemic.approve_documentApprove a pending document (triggers async ingestion)
POST/knowledge/documents/:id/rejectqadra.{tid}.epistemic.reject_documentReject a pending document
POST/knowledge/ingestqadra.{tid}.epistemic.ingest_documentDirect ingestion (bypass proposal gate, 2 min timeout)
GET/knowledge/documentsqadra.{tid}.epistemic.list_documentsList ingested documents (paginated, filterable by status)
GET/knowledge/documents/:idqadra.{tid}.epistemic.get_documentGet 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
}
FieldTypeDefaultDescription
claimstringrequiredClaim text to trace (1-2000 chars)
max_resultsint?nullMax 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.

MethodPathNATS SubjectDescription
GET/knowledge/conflictsqadra.{tid}.epistemic.list_conflictsList detected conflicts (paginated, filterable by subject)
POST/knowledge/conflicts/:id/acknowledgeqadra.{tid}.epistemic.acknowledge_conflictMark a conflict as seen
POST/knowledge/conflicts/:id/resolveqadra.{tid}.epistemic.resolve_conflictResolve a conflict (records chosen assertion + reviewer)
POST/knowledge/conflicts/:id/dismissqadra.{tid}.epistemic.dismiss_conflictDismiss a conflict as not actionable

List Conflicts

GET /knowledge/conflicts?subject=Apple&limit=25&offset=0
Authorization: Bearer eyJ0eXAiOiJKV1Q...
ParameterTypeDefaultDescription
subjectstring?nullFilter by subject
limitint25Results per page (1-100)
offsetint0Pagination 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.

MethodPathNATS SubjectDescription
GET/knowledge/attribution-sessionsqadra.{tid}.epistemic.list_attribution_sessionsList KG-SMILE gate runs (paginated)
GET/knowledge/attribution-sessions/:idqadra.{tid}.epistemic.get_attribution_sessionGet 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...
ParameterTypeDefaultDescription
workload_idstring (UUID)?nullFilter by workload
agent_namestring?nullFilter by agent name
fromstring (ISO 8601)?nullStart of date range
tostring (ISO 8601)?nullEnd of date range
competence_passedboolean?nullFilter by competence gate result
verification_passedboolean?nullFilter by verification gate result
limitint25Results per page (1-100)
offsetint0Pagination offset

File Endpoints

Generic file upload and download backed by S3-compatible storage (MinIO by default, tenant-configurable). All require authentication.

MethodPathNATS SubjectDescription
POST/files/uploadqadra.auth.file.createUpload file (multipart, purpose-based validation)
GET/files/:fileId/downloadqadra.auth.file.getDownload file (proxied from S3)
DELETE/files/:fileIdqadra.auth.file.get + qadra.auth.file.deleteDelete 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:

PurposeMax SizeAllowed Types
avatar2 MBimage/jpeg, image/png, image/webp
logo2 MBimage/jpeg, image/png, image/webp, image/svg+xml
Other10 MBAny

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.

MethodPathNATS SubjectDescription
POST/email/sendqadra.email.sendSend 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

MethodPathNATS SubjectDescription
GET/notifications/templatesqadra.email.template.listList all email templates (global + tenant)
GET/notifications/templates/:slugqadra.email.template.getGet template by slug
PUT/notifications/templates/:slugqadra.email.template.upsertCreate/update tenant template override (owner/admin)
DELETE/notifications/templates/:slugqadra.email.template.deleteDelete tenant template override (owner/admin)
POST/notifications/templates/:slug/testqadra.email.template.test_sendSend test email to self (owner/admin)

Notification Log

MethodPathNATS SubjectDescription
GET/notifications/logsqadra.email.logsList 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.

MethodPathNATS SubjectDescription
GET/audit/logsqadra.audit.listList NATS audit trail (paginated, filterable)

List Audit Logs

GET /audit/logs?category=auth&operation=login&limit=25&offset=0
Authorization: Bearer eyJ0eXAiOiJKV1Q...

Query parameters:

ParameterTypeDefaultDescription
categorystring?nullFilter by category (e.g., auth, flow, epistemic)
operationstring?nullFilter by operation (e.g., login, create, execute)
limitint25Results per page (1-100)
offsetint0Pagination 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).

MethodPathNATS SubjectDescription
GET/admin/tenantsqadra.admin.list_tenantsList all tenants (paginated)
POST/admin/tenantsqadra.admin.create_tenantCreate tenant with owner
GET/admin/usersqadra.admin.list_usersList all users (paginated)
POST/admin/users/:userId/reset-passwordqadra.admin.reset_passwordReset user password
POST/admin/users/:userId/promoteqadra.admin.promotePromote to super admin
POST/admin/users/:userId/demoteqadra.admin.demoteRemove super admin
GET/admin/statsqadra.admin.statsPlatform 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.

MethodPathDescription
GET/healthLiveness check (always returns ok)
GET/health/readyReadiness 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

CodeHTTP StatusDescription
UNAUTHORIZED401Invalid or missing authentication
FORBIDDEN403No access to resource (insufficient role)
NOT_FOUND404Resource not found
CONFLICT409Resource already exists (e.g., duplicate invite)
VALIDATION_ERROR422Invalid request body
RATE_LIMITED429Too many requests
INTERNAL_ERROR500Server error
SERVICE_UNAVAILABLE502/503Backend (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

TermDefinitionNOT ThisWhy It Matters
AgentNamed 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.
WorkloadUnit 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.
PipelineConfigurable sequence of stages a workload traverses.A prompt chain. A workflow.Pipelines enforce ordering. Stage N must complete before stage N+1 begins.
TaskAgent 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.
ArtifactOutput produced by an agent (document, analysis, report).A file. A response.Artifacts are deliverables for humans. Task outputs are data for downstream agents.
TenantOrganization with isolated data, agents, and pipelines.A user. An account.Complete isolation. One tenant cannot see another tenant's anything.
ConversationDirect 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

TermDefinitionWhy It Matters
System PromptThe agent's instructions and persona definition. Defines behavior, expertise, and communication style.Agents are LLM personas with structured prompts, not programs.
Control TypeWhat happens after an agent completes: retain (keep control), relinquish_to_parent (default), relinquish_to_start.Prevents infinite agent loops and controls orchestration flow.
Output VisibilityWhether 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 RailsPer-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 SnapshotImmutable JSONB copy of pipeline stages captured at workload creation time.Running workloads are unaffected by subsequent pipeline edits.

Agent Flows

TermDefinitionWhy It Matters
Agent FlowVisual, 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 ExecutionRuntime 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 NodeIndividual 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 DictionaryJSONB 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 GateFlow 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 ExecutorEvent-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 SnapshotImmutable copy of flow_data captured at execution start.Running executions are unaffected by subsequent edits to the flow definition.
SpoFilter NodeFlow 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 NodeFlow 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 NodeFlow 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

TermDefinitionWhy It Matters
SPO IndexSubject-Predicate-Object triple store for structured fact storage and retrieval.Facts are queryable and verifiable. You can check if a claim is grounded.
TripleA 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 MetadataWho 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 StoreEmbedding storage for semantic similarity search (pgvector with HNSW indexes).Enables "find similar content" without exact keyword matching.
Document IngestionPipeline 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 RoutingAnalyzer classifies content as Spo (triples only), Vector (chunks only), Both, or Discard.Prevents junk content from polluting the knowledge graph.
Document ProposalHuman-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 LookupQuerying 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.
Provenancedocument_triples and document_chunks tables link extracted knowledge back to source documents.Every fact in the knowledge graph traces to its origin.
GroundingValidating LLM outputs against SPO facts.The antidote to hallucination. Ungrounded claims are flagged.
Semantic CacheRedis-based cache storing verification results by embedding similarity.Avoids redundant LLM calls for semantically similar queries.
Entity AliasSurface form to canonical SPO node mapping. Handles synonyms and abbreviations."AAPL", "Apple Inc.", and "Apple" all resolve to the same entity.

Attribution (KG-SMILE)

TermDefinitionWhy It Matters
KG-SMILEKnowledge Graph Structured Metrics for Interpretable LLM Explanations.Academic foundation for explainable GraphRAG.
Two Gates ModelPre-synthesis competence gate + post-synthesis verification gate."Should I attempt this?" + "Did I represent the context faithfully?"
Competence GatePre-synthesis check: does the knowledge graph have enough coverage for this query?Prevents attempts on queries outside the system's knowledge.
Verification GatePost-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 GateProduction 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 GateProduction 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 AnalysisSystematically 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.
FidelityHow well perturbation responses align with the original (R-squared).High fidelity = attributions explain the variance.
FaithfulnessCorrelation between attributions and actual response impact.High faithfulness = attributions are trustworthy.
StabilityRobustness of responses to knowledge graph modifications.High stability = responses do not flip on minor changes.

Agent Evaluation

TermDefinitionWhy It Matters
Rubric / RubricKindThe 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 Gaterelease_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

TermDefinitionWhy It Matters
Rust CoreThe 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.
GatewayNode.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 AgentPython 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.
NATSMessage 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 ObserverCatch-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.
ServiceContainerIoC 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 LayerRedis cache/streams for frequently accessed data.10-100x faster than PostgreSQL for repeated reads.
Source of TruthPostgreSQL. All authoritative state lives here.If Redis says X and PostgreSQL says Y, PostgreSQL wins.

Infrastructure

TermDefinitionWhy It Matters
KongAPI gateway for JWT validation, rate limiting, and routing. DB-less mode with declarative config.Offloads auth concerns from application. Proven at scale.
pgvectorPostgreSQL extension for vector similarity search with HNSW indexes.Native vector search without a separate vector database.
MinIOS3-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.
ImaginaryHTTP-based image processing service (resize, crop, format conversion).Avatar uploads auto-resized to 256x256 WebP. Falls back gracefully if unavailable.
OTelOpenTelemetry---observability framework for traces, metrics, logs to Loki/Prometheus/Grafana.Industry standard. Trace requests across services.

Multi-Tenancy and Auth

TermDefinitionWhy It Matters
UserTenantJunction record linking a user to a tenant with a per-tenant role and is_default flag.Users can belong to multiple organizations.
Super AdminPlatform-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-InSuper 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 InviteToken-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

TermDefinitionWhy It Matters
Artifact TemplateReusable structure defining expected sections, output format, and optional renderer binding. Tenant-scoped, versioned.Standardizes agent output. Ensures documents always have consistent sections.
Email TemplatePer-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 LogAudit record of a sent notification (email, future SMS/push). Multi-channel from day one.Every email the platform sends is traceable for compliance.
PluginLightweight program that transforms data without model calls. Types: extractor, validator, evaluator.Deterministic data processing---fast, cheap, predictable.

File Storage

TermDefinitionWhy It Matters
FileRecordMetadata 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.
StorageProviderEnum (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

TermDefinitionWhy It Matters
DealA workload in the investment banking pipeline.The unit of work we are tracking. Each deal produces an investment recommendation.
Due DiligencePipeline stage where claims are validated and risks assessed.Where grounding matters most. Every claim is verified against facts.
Investment MemoArtifact synthesizing findings into investment recommendation.The deliverable for the Investment Committee.
ICInvestment Committee---group that reviews investment memos.Decision-makers. The memo is written for them.
CIMConfidential 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:

AvoidUse InsteadWhy
"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.