- System position
- Resource hierarchy
- Domain model (Postgres tables)
- Storage architecture
- Multi-tenancy and RLS
- Ingestion pipeline (10 stages)
- Query pipeline (4 agents)
- Hexagonal adapters
- Lock-step modules
flyquery is the third pillar of Firefly OperationOS, alongside flycanon (operational knowledge repository for unstructured content) and flyradar (operations-discovery and diagnostic intelligence).
| Service | What it stores | How you ask it |
|---|---|---|
| flycanon | Documents, process artefacts, knowledge items | Natural-language RAG with citations |
| flyradar | Discovered process graphs, bottlenecks, dependency DAGs | Discovery jobs + diagnostic queries |
| flyquery | Structured files uploaded by operators | Natural-language Text-to-SQL over Parquet |
flyquery's specific responsibilities:
- Ingestion — every common structured-file format, multi-table extraction (XLSX sheets, JSON top-level arrays), per-format readers behind a hexagonal port, materialised to Parquet on object storage.
- Schema knowledge base — every table/column a workspace exposes (post-materialisation), with embeddings, samples, profiles, PII tags, and human + AI-curated descriptions. Snapshots are immutable; re-uploads produce new versions with annotation transplant.
- Relation graph — cross-table join proposals (heuristic + AI) for the file-upload world where no FK catalog exists.
- Semantic layer — MetricFlow-shape YAML metrics, dimensions, and glossary, versioned per dataset.
- Example store —
(question, SQL)pairs for Vanna-style few-shot retrieval, auto-populated from successful runs asAGENT_LEARNED/PROPOSEDuntil an operator approves. - Query execution — multi-agent pipeline (Grounding → Generation → Executor → Critic → Explainer) gated by per-token scopes, an AST firewall, row caps, statement timeouts, PII scanning, and conversation-drill-down memory.
- Audit trail — every upload, snapshot diff, question, candidate SQL, executed statement, and cost event, append-only.
flyquery does not own:
- Ingestion of unstructured documents (flycanon).
- Operations discovery or pain-point analysis (flyradar).
- Connections to customer databases (no DSNs, no driver pool).
- dbt manifests, OpenAPI specs, or BI dashboard rendering.
Workspace (identity root; RLS scope)
└── Dataset logical container
└── File one upload; one blob in object storage
└── Table 1 from CSV/TSV/JSONL; N from XLSX sheets;
N from JSON top-level array keys
└── Snapshot versioned per re-upload; immutable
└── Schema objects TABLE + COLUMN rows with embeddings
└── Relations join proposals scoped to a dataset
| Situation | API call | Effect |
|---|---|---|
| First upload | POST /datasets/{id}/files (multipart) |
Creates File + one or more Tables. Returns {file_id, tables: [{table_id, qualified_name, n_rows_estimate}]} |
| XLSX with 3 sheets | same | Returns 3 table_id values |
JSON with {"orders": [...], "customers": [...]} |
same | Returns 2 table_id values |
| Re-upload into existing slot | PUT /datasets/{ds}/tables/{table_id}:upload |
New Snapshot, same table_id; annotations carry forward |
| Snapshot pointer | — | flyquery_tables.current_snapshot_id flips atomically when a new Snapshot reaches READY (stage 10) |
| Snapshot pinning | — | flyquery_queries.table_id_snapshot_pins_json records which snapshot answered each query, keeping historical answers reproducible across re-uploads |
All tables are prefixed flyquery_. Every multi-tenant row carries
(tenant_id, workspace_id). The model mirrors flycanon's shape.
flyquery_workspaces
kms_key_uri (text|null) per-workspace CMK/CMEK URI; null = storage-native SSE
retention_days (int|null) null = keep forever
allow_direct_sql (bool) enables POST /sql:execute
default_locale (text) date-format hint for CSV parsing (default en-US)
storage_used_bytes (bigint) denormalised cache
flyquery_datasets
id, tenant_id, workspace_id
name (UQ per workspace)
drift_policy (AUTO|REVIEW)
ingest_policy_json (jsonb) include/exclude patterns, sample disable, budget overrides
status (ACTIVE|ARCHIVED)
flyquery_files
id, tenant_id, workspace_id, dataset_id
original_filename, file_format, compression
size_bytes, content_hash_sha256
object_store_key see §4 key layout
table_extraction_rules_json sheet allowlist, JSON path spec
status (RECEIVED|PARSED|FAILED|DELETED)
flyquery_tables
id, tenant_id, workspace_id, dataset_id, source_file_id
name (UQ per dataset), qualified_name
kind (UPLOADED|DERIVED)
sheet_or_json_path (text|null)
current_snapshot_id (FK|null)
description, description_source (HUMAN|INTROSPECTED|AGENT|null)
flyquery_schema_snapshots
id, tenant_id, workspace_id, dataset_id, table_id
snapshot_hash (deterministic over column names + types)
n_columns, n_rows_estimate, n_rows_actual
parquet_object_key
status (PARTIAL|READY|FAILED)
triggered_by (USER|AGENT|SCHEDULED|REPARSE)
flyquery_schema_changes append-only delta log
column_name, change (ADDED|REMOVED|TYPE_CHANGED|RENAMED|RENAMED_CANDIDATE)
before_json, after_json, llm_rationale
flyquery_schema_objects query-time metadata
kind (TABLE|COLUMN), parent_id
qualified_name "dataset.table" or "dataset.table.column"
data_type, is_nullable
pii_tag, pii_source (REGEX|PRESIDIO|AGENT|HUMAN|null)
sample_values_json, profile_json
embedding (vector(1536))
content_tsv (tsvector) BM25 over name + description + synonyms
flyquery_relations proposed/manual joins
from_table_id, from_column_name, to_table_id, to_column_name
kind (HEURISTIC|AGENT_PROPOSED|MANUAL)
confidence, status (PROPOSED|APPROVED|REJECTED)
flyquery_semantic_metrics
name (UQ per dataset), label, description
definition_yaml, compiled_sql_template
metric_type (SIMPLE|RATIO|DERIVED|CUMULATIVE)
status (DRAFT|PUBLISHED|RETIRED)
flyquery_semantic_dimensions mirror of metrics for dimensions / entities
flyquery_semantic_versions immutable history per metric/dim
flyquery_glossary_terms workspace-scoped (not per-dataset)
flyquery_examples Vanna-style few-shot
question, generated_sql, normalised_sql
source (USER_CURATED|AGENT_LEARNED)
quality (PROPOSED|APPROVED|REJECTED)
embedding (vector(1536))
flyquery_queries every run, append-only
question, prior_turn_ids (uuid[])
table_id_snapshot_pins_json
semantic_path_taken (SEMANTIC_LAYER|SYNTHESIS|HYBRID)
candidates_json
executed_sql
execution_status (OK|REFINED_OK|FAILED|REJECTED_BY_FIREWALL)
retries (int), row_count, elapsed_ms, cost_cents
flyquery_query_results preview cache
result_preview_json capped at FLYQUERY_RESULT_PREVIEW_MAX_BYTES
result_object_key results/{query_id}.parquet
ttl_expires_at
flyquery_conversations multi-turn sessions
flyquery_conversation_turns
question, executed_sql, summary, table_qnames_json
snapshot_pins_json {table_id: snapshot_id}
flyquery_agent_tokens long-lived bearer tokens
dataset_allowlist_json
workspace_allowlist_json
scopes_json
flyquery_ingest_jobs async ingest orchestration
job_kind (PARSE_AND_INGEST|REPARSE|SAMPLE_REFRESH|DESCRIBE_PASS|RELATION_PASS)
status (PENDING|RUNNING|SUCCEEDED|FAILED|CANCELLED)
flyquery_ingest_events streamable per-stage progress
stage, status, message, payload_json
flyquery_audit_events append-only, mirrors canon_audit_events
flyquery_cost_events mirrors canon_cost_events
ingest_job_id (FK|null), query_id (FK|null)
flyquery uses a two-tier storage design:
-
Object storage — original upload blobs + materialised Parquet snapshots. Backed by
LocalFs(dev),S3,GCS, orAzure Blobvia theObjectStorehexagonal port. Selected byFLYQUERY_OBJECT_STORE. -
DuckDB — in-process per-request query engine. ATTACHes the Parquet snapshots at query time; no persistent DuckDB database. This eliminates shared mutable state between requests.
-
Postgres + pgvector — metadata, schema knowledge base, relations, embeddings, audit, agent tokens. The single persistent transactional store.
All keys are namespaced by (tenant_id, workspace_id, dataset_id).
flyquery/ configurable prefix (FLYQUERY_OBJECT_STORE_BASE)
{tenant_id}/
{workspace_id}/
{dataset_id}/
files/{file_id}.{ext} original upload; one blob even when a file
produces multiple tables
tables/{table_id}/v{n}.parquet ingested-table snapshots
derived/{table_id}/v{n}.parquet derived-table snapshots (CREATE TABLE … AS SELECT)
results/{query_id}.parquet full query results (TTL = FLYQUERY_RESULT_TTL_HOURS)
Key points:
- A single
flyquery_filesrow maps to onefiles/blob, even when XLSX with 3 sheets produces 3flyquery_tablesrows. - Re-uploading a table slot creates a new
files/blob → a newtables/{table_id}/v{n+1}.parquet. Previous blobs are retained until workspace retention expires. - Workspace purge walks
{tenant_id}/{workspace_id}/and removes all keys after a 30-day tombstone window.
Two roles are required per deployment:
| Role | BYPASSRLS | Used for |
|---|---|---|
flyquery_admin |
Yes | Migrations + cross-workspace workers |
flyquery_app |
No | Request-scoped queries (runtime) |
The flyquery_app role is bound in the runtime DATABASE_URL. Migrations run
via FLYQUERY_DATABASE_URL_ADMIN. Without this split, RLS is silently bypassed
in production.
Applied to every multi-tenant table:
USING (
tenant_id = current_setting('app.tenant_id')
AND workspace_id = current_setting('app.workspace_id')
)FORCE ROW LEVEL SECURITY ensures the policy applies even to table owners.
Special cases:
flyquery_workspaces: policy istenant_id = ... AND id = current_setting('app.workspace_id').flyquery_agent_tokens: policy istenant_id = ...(workspace check is in code againstworkspace_allowlist_json).
TenantContextMiddleware (lock-step with canon) extracts X-Tenant-Id and
X-Workspace-Id from each request and issues SET LOCAL app.tenant_id = ? and
SET LOCAL app.workspace_id = ? via a SQLAlchemy after_begin event listener.
This is a LOCAL transaction-scoped setting — it automatically resets when the
connection returns to the pool.
Asynchronous, restartable, streamable, idempotent across re-ingests. Each stage emits an SSE event (see ingestion.md for full detail).
┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐
│ flyquery serve │ │ flyquery worker │ │ flyquery worker │
│ (API server) │ │ ingest │ │ retention │
│ │ │ (IngestWorker) │ │ (RetentionWorker) │
│ HTTP / SSE │ │ EDA consumer of │ │ Periodic loop: │
│ controllers │ │ flyquery.ingest │ │ - stuck-job reaper │
│ + services + repos │ │ + 10-stage pipeline │ │ - orphan republish │
│ (sync ingest path │ │ + retry / cancel / │ │ - TTL deletes │
│ in-process) │ │ dead-letter │ │ - PURGED hard-del │
└──────────┬───────────┘ └──────────┬───────────┘ └──────────┬───────────┘
│ scale: replicas │ scale: N replicas │ scale: 1
│ │ × _CONCURRENCY │ usually
▼ ▼ ▼
Postgres (RLS-enforced) ─ EDA bus ─ Object storage (Parquet)
Each is a distinct process. Production runs ≥1 of each; dev /
docker-compose can collapse all three into a single process via
flyquery worker all (NOT recommended for production — see
workers.md for full topology + scaling guidance). The
RetentionWorker doesn't run the pipeline; it owns the cross-cutting
cleanup duties (TTL sweeps + reaping stuck RUNNING jobs + republishing
orphaned PENDING jobs) listed in
retention_worker.py.
POST /datasets/{id}/files
POST /datasets/{id}/files:async (new in 26.5.10: 202 Accepted; Stage 1 sync, 2-10 on worker)
PUT /datasets/{ds}/tables/{id}:upload
│
▼ bytes → object store: files/{file_id}.{ext}
│
▼ publish to "flyquery.ingest" EDA topic
│
▼ IngestWorker: PENDING → RUNNING
┌─────── 1. receive ──────────────────────────────────┐
│ Verify content_hash, size cap, format from magic │
│ bytes + extension. flyquery_files.status=RECEIVED. │
└────────────────────────┬────────────────────────────┘
▼
┌─────── 2. parse ────────────────────────────────────┐
│ FileReader dispatch by format: │
│ CSV/TSV → chardet + delimiter sniff + DuckDB │
│ XLSX/ODS → python-calamine; one table per sheet │
│ JSON → top-level array or array-valued keys │
│ JSONL → one table │
│ Parquet/Avro/ORC/Feather → pass-through / PyArrow│
│ .gz/.zip/.bz2 → decompress to temp + delegate │
│ Apply table_extraction_rules_json overrides. │
│ Materialise each table as Parquet in object store. │
└────────────────────────┬────────────────────────────┘
▼
┌─────── 3. reconcile + persist snapshot ─────────────┐
│ Insert flyquery_schema_snapshots (PARTIAL). │
│ Diff vs prior snapshot: ADDED/REMOVED/TYPE_CHANGED.│
│ Auto-RENAMED when position+type signature matches │
│ uniquely; otherwise → RenameDetectionAgent proposes│
│ RENAMED_CANDIDATE with llm_rationale. │
│ Annotation transplant: human description, pii_tag, │
│ business_owner, governance_json, synonyms carried │
│ verbatim to the new snapshot. │
└────────────────────────┬────────────────────────────┘
▼
┌─────── 4. sample ───────────────────────────────────┐
│ Read N=FLYQUERY_SAMPLE_N values per column via │
│ DuckDB. PIIScanner gate prevents persisting if │
│ values look like PII. Skippable via │
│ ingest_policy_json.sample_disabled. │
└────────────────────────┬────────────────────────────┘
▼
┌─────── 5. profile ──────────────────────────────────┐
│ null_fraction, approx_count_distinct, top_values, │
│ min/max. Skipped above FLYQUERY_PROFILE_ROW_ │
│ THRESHOLD (default 10M rows). │
└────────────────────────┬────────────────────────────┘
▼
┌─────── 6. relation discovery ───────────────────────┐
│ Across ALL tables in the dataset: │
│ (a) Heuristic: column-name match + compatible │
│ types + one-sided unique constraint. │
│ (b) RelationProposerAgent inspects table/column │
│ descriptions + samples. │
│ Both inserted as PROPOSED; operator approves via │
│ POST /datasets/{id}/relations/{rel_id}:approve. │
└────────────────────────┬────────────────────────────┘
▼
┌─────── 7. describe ─────────────────────────────────┐
│ Tables + columns without descriptions fed to │
│ DescribeAgent in batches. Budget cap: │
│ FLYQUERY_DESCRIBE_BUDGET_CENTS_PER_RUN. │
│ Remaining objects deferred to DESCRIBE_PASS job. │
└────────────────────────┬────────────────────────────┘
▼
┌─────── 8. PII tag ──────────────────────────────────┐
│ PIIScanner classifies from name + description + │
│ samples. Policy: warn|redact|reject. │
│ Late tag flip scrubs sample_values in-place. │
└────────────────────────┬────────────────────────────┘
▼
┌─────── 9. embed + index ────────────────────────────┐
│ Embedding text: "<dataset>.<table>.<column>: │
│ <type>\n<description>\nSamples: ...\nSynonyms: ..." │
│ Per-table aggregate rolls up columns. │
│ Persist embedding + refresh pgvector HNSW + │
│ content_tsv for BM25. │
└────────────────────────┬────────────────────────────┘
▼
┌─────── 10. publish + close snapshot ────────────────┐
│ Atomic transaction: snapshot.status=READY, │
│ tables.current_snapshot_id flips. │
│ Publish flyquery.schema.updated EDA event. │
│ Emit snapshot_ready + final SSE frames. │
└─────────────────────────────────────────────────────┘
Stages 4–8 are skippable via ingest_policy_json. Stages 1–3, 9, 10 are
mandatory.
| Agent | Output type | Stage | Notes |
|---|---|---|---|
DescribeAgent |
DescribedObjects |
7 | Batched; budget-capped per run |
RelationProposerAgent |
ProposedRelations |
6 | Cross-table; status=PROPOSED until human approval |
RenameDetectionAgent |
RenameProposals |
3 | Only on ambiguous reconcile |
GovernanceClassifierAgent (v1+) |
GovernanceTags |
8 | Off by default |
Every /query or /agent/query call runs through this pipeline.
NL question + (conversation_id?, dataset_id?)
│
▼
Hybrid Retrieval (BM25 + pgvector + RRF) over schema_objects,
relations (APPROVED + high-confidence HEURISTIC only),
semantic_metrics (PUBLISHED), glossary, examples (APPROVED)
│
▼
Cross-encoder reranker (top-30 → top-10; FLYQUERY_RERANKER_MODEL)
│
▼
GroundingAgent → GroundedContext {
path: SEMANTIC_LAYER | SYNTHESIS | HYBRID,
tables[], columns[], joins[], metrics[], examples[],
confidence, missing_info: list[str] | None
}
if conversation_id: receives prior turn's
{executed_sql, table_qnames, snapshot_pins} as starting_point
if confidence < FLYQUERY_GROUNDING_MIN_CONFIDENCE AND missing_info:
emits clarification SSE frame alongside the answer
│
├── SEMANTIC_LAYER path ──────────────────────────────────┐
│ MetricFlow → SQL (deterministic compilation) │
│ │
└── SYNTHESIS path ────────────────────────────────────┐ │
GenerationAgent → N candidates (default 3) │ │
merge ◄──┘
│
▼
AST classifier (sqlglot + DuckDB parse) + scope/firewall check
single-statement only
reject DDL on uploaded tables
reject INSERT/UPDATE/DELETE on uploaded tables
reject SQL referencing tables outside dataset_allowlist
reject DuckDB host-touching functions except httpfs/parquet/json/arrow
│
▼
DuckDB executor (in-process, per-request)
ATTACH Parquet snapshots as virtual tables
SET memory_limit, statement_timeout, LIMIT row_cap+1
│
┌────┴──────┐
OK ERROR
│ │
│ CriticAgent (up to FLYQUERY_MAX_REFINE_RETRIES = 2)
│ │ RefinedSql → loop back to AST check
▼
ExplainerAgent → NLAnswer + ChartHint (line|bar|table|pie|none)
│
▼
Conversation memory: append turn (executed_sql, summary, table_qnames, snapshot_pins)
│
▼
Auto-learn: if OK + no retries + no PII + no clarification → insert example
(source=AGENT_LEARNED, quality=PROPOSED)
│
▼
Recorder → flyquery_queries + audit_events + cost_events
full result → results/{query_id}.parquet
preview → flyquery_query_results
| Agent | Output type | Notes |
|---|---|---|
GroundingAgent |
GroundedContext |
Iterative schema expansion up to EXPAND_ITERS=2 |
GenerationAgent |
GeneratedCandidates |
FLYQUERY_GENERATION_CANDIDATES=3; semantic-layer trusted over uploaded tables |
CriticAgent |
RefinedSql |
Always invoked on error; optionally on success to rank N>1 |
ExplainerAgent |
ResultExplanation |
NL summary + chart hint; cheap model by default |
All agents follow canon's build_agent recipe: structured output_type,
auto_register=False, fresh per call, observability middleware wrapping each
run, cost events tied to query_id.
flyquery is hexagonal at every external boundary. The domain core only depends
on port interfaces (Python Protocol classes); adapters are wired at
application startup via @configuration beans.
class ObjectStore(Protocol):
async def put(self, key: str, body: ..., content_type: str,
kms_key_uri: str | None = None) -> ObjectMeta: ...
async def get(self, key: str) -> AsyncIterator[bytes]: ...
async def head(self, key: str) -> ObjectMeta: ...
async def delete(self, key: str) -> None: ...
async def list(self, prefix: str) -> AsyncIterator[ObjectMeta]: ...
async def presign_get(self, key: str, ttl_s: int) -> str: ...
async def copy(self, src_key: str, dst_key: str) -> None: ...Adapters: local_fs_object_store (dev), s3_object_store (aiobotocore),
gcs_object_store (gcloud-aio-storage), azure_blob_object_store
(azure-storage-blob). Selected by FLYQUERY_OBJECT_STORE.
class FileReader(Protocol):
formats: ClassVar[tuple[str, ...]]
async def detect(self, head_bytes: bytes, filename: str) -> bool: ...
async def enumerate_tables(self, handle, rules) -> list[ProposedTable]: ...
async def materialise(self, handle, table, target_parquet, *,
workspace_locale, type_infer_sample_rows) -> MaterialiseResult: ...Implementations: csv_reader, excel_reader, json_reader,
parquet_reader, avro_reader, orc_reader, arrow_reader.
class DialectAdapter(Protocol):
dialect: Literal["duckdb"]
async def execute(self, sql, *, attached_tables, row_cap, ...) -> ExecutionResult: ...
async def explain(self, sql, *, attached_tables) -> ExplainPlan: ...
def ast_classify(self, sql) -> AstClassification: ...
def lint(self, sql) -> list[LintFinding]: ...| Port | Adapters | Notes |
|---|---|---|
VectorStore |
pgvector |
pgvector HNSW only — flyquery is pgvector-only by design |
PiiScanner |
regex, presidio, disabled |
Shared verbatim from canon |
RateLimiter |
memory, redis |
Per-token sliding 60 s window |
IdempotencyStore |
memory, redis |
Lock-step with canon/radar |
SemanticCompiler |
metricflow_compiler |
MetricFlow YAML → DuckDB SQL |
The following files are kept byte-equivalent with flycanon and flyradar.
scripts/check_lockstep.py diffs them against pinned SHAs in CI; PRs are
blocked on mismatch.
src/flyquery/web/agent_deps.py (~3.7 KB)
src/flyquery/web/conventions/middleware.py
src/flyquery/web/conventions/deps.py
src/flyquery/web/conventions/headers.py
src/flyquery/web/conventions/actor.py
src/flyquery/web/conventions/context.py
src/flyquery/web/conventions/validation.py
src/flyquery/web/conventions/errors.py
src/flyquery/web/conventions/idempotency.py
src/flyquery/web/conventions/redis_idempotency.py
src/flyquery/web/conventions/db.py
src/flyquery/web/conventions/handlers.py
src/flyquery/core/services/auth/agent_token_service.py
src/flyquery/core/services/auth/redis_rate_limiter.py
src/flyquery/web/controllers/agent_tokens_controller.py (adapted for flyquery scope catalog)
src/flyquery/core/agents/builder.py
src/flyquery/core/observability/__init__.py
src/flyquery/web/openapi_override.py
21 files total. Any change to these in flyquery must also be reflected in the
canon and radar twins (or vice versa). Post-patch diff is checked via
task lockstep-check.