flycanon ships as a single Python service plus its operational
dependencies. The reference deployment is one Docker image
(ghcr.io/firefly-operationos/flycanon) backed by a Postgres
instance with the vector extension and an embedding-provider key
of your choice. Everything else (Redis, Gotenberg, Docling OCR
sidecar, ...) is optional.
| Topology | Use case | Components |
|---|---|---|
| Single host (compose) | Dev, demo, on-prem POC | docker compose with flycanon + postgres(pgvector) (+ optional redis for the EDA Redis adapter, gotenberg for Office conversion). |
| Container platform (k8s, ECS, Cloud Run, ...) | Production | One Deployment / Service / StatefulSet per role: API, worker, Postgres, optional Redis. |
| Bare-metal | Air-gapped operators | uv run flycanon serve + uv run flycanon worker + an external Postgres. |
The image, the env vars, and the migration step are identical across all three. The differences are how you wire the dependencies.
docker pull ghcr.io/firefly-operationos/flycanon:latest # latest main
docker pull ghcr.io/firefly-operationos/flycanon:26.5.6 # specific release
docker pull ghcr.io/firefly-operationos/flycanon:26.5 # latest 26.5.xMulti-arch manifest: pulling :latest on Apple Silicon /
AWS Graviton picks the linux/arm64 layer; pulling on x86 picks
linux/amd64. Override explicitly with --platform linux/amd64 if
needed.
The same image runs three commands, selectable via the CMD /
command::
| Command | What it does | Replica policy |
|---|---|---|
serve |
FastAPI ASGI server on :8500. Stateless. |
Horizontally scalable behind a load balancer. |
worker |
Subscribes to the EDA outbox / Redis / Kafka and processes async jobs (ConsolidationRequested, ...). |
At least one. Many is fine -- the work-queue claim is idempotent. |
migrate |
Runs alembic upgrade head and exits. |
Single shot, before serve / worker start on a new schema. |
The docker-entrypoint.sh shell switch maps these to the right
process call.
The repo ships docker-compose.yml -- one
copy is enough for a single-host deployment. The composition is:
flycanon-api -> 8500/tcp (CMD ["serve"])
flycanon-worker (CMD ["worker"])
flycanon-pg -> 5432/tcp pgvector/pgvector:pg16
flycanon-redis -> 6379/tcp (optional; only with FLYCANON_EDA_ADAPTER=redis)
gotenberg -> 3000/tcp (optional; only with FLYCANON_OFFICE_CONVERTER=gotenberg)
docker compose --env-file .env up -d # start everything
docker compose exec flycanon-api flycanon migrate # apply migrations
curl -fsS http://localhost:8500/actuator/health | jq .The compose file uses BuildKit named contexts so the build-from-source
path (docker compose build) works without copying the sibling
firefly framework repos into the flycanon tree -- see
cicd.md § Build contexts.
All keys are prefixed FLYCANON_*. The
env_template file is the canonical reference;
copy it to .env (which is gitignored) and edit. The minimum
required for production:
| Key | What it is | Required? |
|---|---|---|
FLYCANON_DATABASE_URL |
Async-SQLAlchemy URL (postgresql+asyncpg://user:pass@host:5432/db) |
Yes |
FLYCANON_EMBEDDING_MODEL |
<provider>:<model> (e.g. openai:text-embedding-3-small, voyageai:voyage-large-2, ollama:nomic-embed-text) |
Yes |
FLYCANON_EMBEDDING_DIMENSIONS |
Dimensions of the chosen embedding model. Must match pgvector index dimension -- a mismatch produces a runtime error on first insert. |
Yes |
FLYCANON_ANSWER_MODE |
Answer engine for /api/v1/query: rlm (default) or rag (deprecated, opt-in). See Answer mode. |
Defaults to rlm. |
FLYCANON_ANSWER_MODEL |
<provider>:<model> for the RAG answer endpoint (default anthropic:claude-sonnet-4-6). Only used when FLYCANON_ANSWER_MODE=rag. |
Needed only for rag mode. |
FLYCANON_ANSWER_FALLBACK_MODEL |
Used when the primary RAG model errors (e.g. provider 5xx, rate limit). | Recommended for rag mode. |
FLYCANON_STORE_ORIGINALS |
Persist original document bytes to the object store so RLM can reason over whole documents. Required (true, the default) for RLM. See Answer mode. |
Defaults to true. |
| Provider API keys | OPENAI_API_KEY, ANTHROPIC_API_KEY, VOYAGEAI_API_KEY, COHERE_API_KEY, ... -- read by fireflyframework-agentic from env at boot. ANTHROPIC_API_KEY is required at runtime in the default RLM mode (the RLM engine calls the Anthropic Messages API directly). |
As needed for your provider mix. |
FLYCANON_VECTOR_STORE |
Dense backend: pgvector (default), qdrant (--extra qdrant), or chroma (--extra chroma). |
Defaults to pgvector. |
FLYCANON_EDA_ADAPTER |
postgres (default -- durable outbox + LISTEN/NOTIFY), memory, redis, kafka. |
Defaults to postgres. |
FLYCANON_API_KEYS |
Comma-separated static API keys. When set, every /api/v1/* request requires Authorization: Bearer <key>. |
Optional. |
FLYCANON_CORS_ORIGINS |
Comma-separated origins for Access-Control-Allow-Origin. |
Optional. |
For a complete list with defaults and inline docs, run:
docker run --rm ghcr.io/firefly-operationos/flycanon:latest cat /app/env_templateFLYCANON_ANSWER_MODE selects the engine for the non-streaming answer
path (/api/v1/query, /api/v1/query:stream, and the agent-tier
equivalents). rlm is the default; rag is opt-in and deprecated. Any
value other than rag is normalised to rlm.
| Key | What it is | Default |
|---|---|---|
FLYCANON_ANSWER_MODE |
rlm (default) routes to the Recursive Language Model answerer; rag routes to the legacy hybrid-retrieval answerer. |
rlm |
FLYCANON_RLM_ROOT_MODEL |
Orchestrator model that drives the CodeAct REPL loop. <provider>:<model>. |
anthropic:claude-sonnet-4-6 |
FLYCANON_RLM_SUB_MODEL |
Model for flat recursive sub-calls made from REPL code. | anthropic:claude-sonnet-4-6 |
FLYCANON_RLM_ANSWER_MODEL |
Model for the final single-shot answer synthesis. | anthropic:claude-sonnet-4-6 |
FLYCANON_RLM_MAX_ITERS |
Max orchestrator turns before the loop gives up and asks for a plain-text answer from the transcript. | 8 |
FLYCANON_RLM_SUB_BUDGET |
Total recursive sub-call budget across one root session. | 12 |
FLYCANON_RLM_MAX_DEPTH |
How deep rlm(...) may nest before degrading to a flat llm. |
1 |
FLYCANON_RLM_PROMPT_CACHE |
Mark the large static RLM system prompt with Anthropic cache_control: ephemeral so it is cached server-side and reused across the many Messages calls one CodeAct session makes (cuts input-token cost + per-call latency). false sends it as a plain string. |
true |
FLYCANON_RLM_SANDBOX |
Where the model-written REPL code runs. subprocess (the secure default) execs it in a scrubbed-env, resource-limited child process; inprocess is the explicit opt-out that runs it in the engine's own restricted exec namespace (dev / trusted use only). Only the exact value inprocess opts out; anything else resolves to subprocess. See RLM execution sandbox. |
subprocess |
FLYCANON_RLM_SANDBOX_TIMEOUT_S |
Per-turn wall-clock timeout (seconds) for the subprocess sandbox; the child is killed and the turn fails if exceeded. | 30 |
The Recursive Language Model engine is a code-driven CodeAct REPL. A
root orchestrator model writes Python against the in-scope document
corpus (handed to it as a docs variable: docs.keys() lists the
documents, docs[key] returns the full text, docs.pages(key) returns
the page list for precise citation), makes recursive sub-calls on
slices of that corpus, and finishes by citing the filings / pages it
used. It reasons over whole documents rather than retrieving
chunks, which is why it depends on the object store below.
- Originals persisted in the object store. RLM reads the original
document bytes, so intake must persist each original to the object
store (
FLYCANON_STORE_ORIGINALS=true, the default) and record its key on the source row. Sources without a stored original (noobject_store_key) are silently skipped by the RLM corpus builder. ANTHROPIC_API_KEYat runtime. The RLM engine calls the Anthropic Messages API directly for all three RLM models; theanthropic:prefix is stripped before the id is sent.
When FLYCANON_ANSWER_MODE=rag, the answer dispatcher emits a
server-side deprecation warning log on every RAG-mode answer:
FLYCANON_ANSWER_MODE=rag is deprecated and will be removed in a future release; RLM is the default
The legacy RAG answer path is slated for removal in a future release;
migrate to the default rlm mode. When answer_mode=rag, the answer
endpoints return an X-Flycanon-Deprecation response header to clients
-- the client-facing deprecation signal alongside the server log. The
/api/v1/search surface (raw hybrid
retrieval, no LLM) is unaffected and stays.
The RLM REPL execs model-written Python every turn, and the corpus is
user-uploaded, so a malicious document can prompt-inject the
orchestrator model into writing hostile code. To contain that, the exec
runs out of process by default (FLYCANON_RLM_SANDBOX=subprocess):
- Scrubbed environment. The child inherits only a minimal whitelist
(
PATH,LANG,LC_ALL,PYTHONPATH,PYTHONHASHSEED) -- noANTHROPIC_API_KEY, noAWS_*/AZURE_*cloud creds, noDATABASE_URL/REDIS_*, and noFLYCANON_*secrets. Prompt-injected code cannot read a credential because none are present. - Resource limits. At startup the child clamps itself with
setrlimit: a CPU-time cap (RLIMIT_CPU), a 1 GiB address-space cap (RLIMIT_AS), andRLIMIT_FSIZE = 0so it cannot create or grow any file. The parent additionally enforces a wall-clock timeout (FLYCANON_RLM_SANDBOX_TIMEOUT_S, default 30 s) and SIGKILLs a child that overruns. - Capability RPC, no infrastructure in the child. The child holds no
document store or model client. Its
docs/llm/rlm/finalstubs marshal each call to the parent as a length-prefixed JSON-only frame (neverpickle/eval), validated against fixed allowlists; the parent services it against the real infra and replies. The secrets, network, and infrastructure objects live only in the parent. - Blast radius. An escape is limited to the in-scope corpus the parent already exposes through those capability RPCs -- the same documents the query was authorised to read.
Setting FLYCANON_RLM_SANDBOX=inprocess disables this isolation and runs
the model code in the engine's own process (restricted builtins only). It
is intended for dev / trusted environments and is not recommended in
production.
Residual risk / follow-up. The child is still a normal process on the
host network: a sandbox escape (or a bug in the restricted builtins) could
attempt network egress to exfiltrate the in-scope corpus. Hard
network/syscall blocking -- running the child under nsjail / seccomp
inside a network namespace with no egress -- is a recommended
defence-in-depth follow-up. It needs a Dockerfile change (the sandbox
tooling and a dropped-network profile) and is not yet in place.
The object store holds the original document bytes the intake persisted
so RLM can replay them. localfs (default) writes files under a root
directory for dev / test; s3 (requires uv sync --extra s3) writes to
a bucket. AWS credentials for the s3 backend are read from the
standard environment (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY /
profiles / instance roles), not from these settings.
| Key | What it is | Default |
|---|---|---|
FLYCANON_OBJECT_STORE_BACKEND |
localfs (default) or s3. |
localfs |
FLYCANON_OBJECT_STORE_LOCALFS_ROOT |
Root directory for the localfs backend. |
./var/objects |
FLYCANON_OBJECT_STORE_S3_BUCKET |
Bucket name for the s3 backend. |
(empty) |
FLYCANON_OBJECT_STORE_S3_PREFIX |
Key prefix prepended to every object key within the bucket. | (empty) |
FLYCANON_OBJECT_STORE_S3_ENDPOINT_URL |
Custom endpoint for MinIO / S3-compatible services; empty = default AWS. | (empty) |
FLYCANON_OBJECT_STORE_S3_REGION |
Region; empty defers to boto3's standard region resolution. | (empty) |
FLYCANON_STORE_ORIGINALS |
Whether intake persists original bytes and records the key on the source row. On by default so RLM has a whole-document corpus; a write failure is best-effort and never fails the ingest. | true |
The RLM corpus store fetches each in-scope filing's original from the
object store and PyMuPDF-extracts its page-structured text on first
access -- the expensive part of an RLM query. A shared, synchronous
page cache sits in front of that fetch so an in-scope filing is
fetched + extracted at most once per process (in-memory LRU) and,
with the Redis backend, once per fleet. Entries are keyed by the
source's content_sha256, so a re-ingested source (new bytes -> new
sha) misses the stale entry automatically -- there is no explicit
invalidation.
Backend selection mirrors the rate-limiter / idempotency stores:
auto (the default) uses Redis when FLYCANON_REDIS_URL is set,
in-memory otherwise; redis / memory force one or the other. The
Redis client is synchronous (the cache is read from the RLM
engine's worker thread). For multi-replica deployments set
FLYCANON_REDIS_URL so a single fetch on one replica warms the whole
fleet.
| Key | What it is | Default |
|---|---|---|
FLYCANON_CORPUS_CACHE_BACKEND |
auto (Redis when FLYCANON_REDIS_URL set, else in-memory), redis, or in_memory. |
auto |
FLYCANON_CORPUS_CACHE_TTL_S |
Per-entry TTL in seconds (both backends). | 3600 |
FLYCANON_CORPUS_CACHE_MAX_ENTRIES |
LRU cap for the in-memory backend (the Redis backend relies on native EX expiry). |
512 |
flycanon runs against PostgreSQL with the vector extension.
The reference docker-compose uses pgvector/pgvector:pg16 so the
extension ships pre-installed.
CREATE DATABASE flycanon;
CREATE USER canon WITH PASSWORD 'canon';
GRANT ALL PRIVILEGES ON DATABASE flycanon TO canon;
\c flycanon
CREATE EXTENSION IF NOT EXISTS vector;
GRANT ALL ON SCHEMA public TO canon;Alembic migrations live under migrations/. Run
once per deploy:
docker run --rm \
--env FLYCANON_DATABASE_URL=postgresql+asyncpg://canon:canon@db.internal:5432/flycanon \
ghcr.io/firefly-operationos/flycanon:latest migratealembic upgrade head is also run on container startup when
RUN_MIGRATIONS=true (the env_template default). Production deploys
typically prefer migrating as a separate step (RUN_MIGRATIONS=false
in the long-running containers, plus a one-shot migrate Job /
Container right before the API rollout).
Migration 0013_rls_policies emits ALTER TABLE ... FORCE ROW LEVEL SECURITY on every canon_* table. The application connection must
not be a Postgres superuser (FORCE-RLS does not apply to
superusers), and the migration runner / background workers must
bypass the policy or they would see zero rows. Provision two
roles before pointing FLYCANON_DATABASE_URL at the cluster:
-- Admin role: runs migrations + cross-workspace workers
-- (consolidation re-embed sweep, retention reaper, EDA ingest worker).
CREATE ROLE flycanon_admin LOGIN PASSWORD 'change-me' BYPASSRLS;
GRANT ALL PRIVILEGES ON DATABASE flycanon TO flycanon_admin;
GRANT ALL ON SCHEMA public TO flycanon_admin;
-- App role: request-path engine. NO BYPASSRLS, NOT a superuser.
-- RLS policies filter by the per-session GUCs that
-- `install_tenant_guc_hook()` sets on each transaction.
CREATE ROLE flycanon_app LOGIN PASSWORD 'change-me';
GRANT CONNECT ON DATABASE flycanon TO flycanon_app;
GRANT USAGE ON SCHEMA public TO flycanon_app;
GRANT SELECT, INSERT, UPDATE, DELETE
ON ALL TABLES IN SCHEMA public TO flycanon_app;
GRANT USAGE, SELECT
ON ALL SEQUENCES IN SCHEMA public TO flycanon_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO flycanon_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT USAGE, SELECT ON SEQUENCES TO flycanon_app;Wire FLYCANON_DATABASE_URL at flycanon_app for the serve role
and at flycanon_admin for the migrate + worker roles. See
architecture.md -> Row-level security
for the rationale; the integration suite
(tests/integration/test_rls_isolation.py) exercises the
BYPASSRLS vs. app_user contract end-to-end.
flycanon is Postgres-native for both retrieval channels: BM25 rides
on a tsvector + GIN index on canon_chunks.tsv (generated column from
canon_chunks.content), and dense vectors live in pgvector in the same
Postgres. The deploy needs Postgres only.
flycanon handles both PDF kinds (Full Digital Text PDFs and PDF-Images) without caller flags -- see pipeline.md § PDF ingestion for the routing detail.
| Engine | When | How to enable |
|---|---|---|
| Tesseract (default) | Ships with the image. Apt packages: tesseract-ocr-eng, -spa, -fra, -deu, -ita, -por, -cat. |
No-op -- it's the default. Languages default to eng+spa; override with FLYCANON_OCR_LANG=eng+spa+fra. |
| Docling | Layout-aware OCR with native multi-column / table handling. | Build a derived image: RUN uv pip install --no-cache 'flycanon[docling]'. Then run with FLYCANON_PDF_OCR_ENGINE=docling. Adds ~2.5 GB per architecture (PyTorch + HF wheels). |
The Docling extra is not baked into the published image for the same reason it isn't on flydocs's: the size cost punishes the 95% of deploys that don't need layout-aware OCR.
Office formats (DOCX / XLSX / PPTX / ODT / ODS / ODP / RTF) are read via native per-format loaders (python-docx, openpyxl, python-pptx, odfpy, striprtf) by default. For high-fidelity extraction you can render Office docs to PDF first:
| Converter | How | Trade-off |
|---|---|---|
none (default) |
Native per-format loaders (python-docx / openpyxl / python-pptx / odfpy / striprtf). | Zero extra service. Text + structure fidelity for tables / images is best-effort. |
gotenberg |
HTTP sidecar (gotenberg/gotenberg:8 -- see docker-compose). |
High fidelity, distroless runtime stays clean. Adds one service. |
libreoffice |
In-container soffice subprocess. |
High fidelity, no extra service. Bloats the runtime image (~1 GB). Build a derived image with libreoffice-core. |
Switch via FLYCANON_OFFICE_CONVERTER=none|gotenberg|libreoffice.
The embedding stack is provider-agnostic -- any
fireflyframework-agentic embedder identifier works. Set the model
id, the dimensions, and the provider's API key env var.
| Provider | Model id example | Dimensions | API key env |
|---|---|---|---|
| OpenAI | openai:text-embedding-3-small |
1536 | OPENAI_API_KEY |
| OpenAI | openai:text-embedding-3-large |
3072 | OPENAI_API_KEY |
| VoyageAI | voyageai:voyage-large-2 |
1536 | VOYAGEAI_API_KEY |
| Cohere | cohere:embed-multilingual-v3.0 |
1024 | COHERE_API_KEY |
| Mistral | mistral:mistral-embed |
1024 | MISTRAL_API_KEY |
| Ollama (local) | ollama:nomic-embed-text |
768 | (none -- needs OLLAMA_HOST) |
Dimensions must match the pgvector column. flycanon's
migrations create canon_chunk_vectors.embedding as vector(<dim>)
at first boot using FLYCANON_EMBEDDING_DIMENSIONS. Changing the
embedding model after data is loaded is a re-index -- not a hot
swap.
flycanon ships two complementary auth modes; both come from pyfly:
| Mode | When | Config |
|---|---|---|
| Static API keys | The simplest production gate. | FLYCANON_API_KEYS=key1,key2,... -- callers send Authorization: Bearer <key>. |
| OAuth2 resource server | Integrating with an existing IdP (Keycloak / Auth0 / Cognito / ...). | Set pyfly.security.oauth2.resource-server.enabled=true plus the provider's issuer URI in pyfly.yaml. |
Default deployment: both off (open). Production deployments must
enable at least one before exposing /api/v1/* to the network.
- Tracing. W3C trace context (
traceparent,tracestate) + correlation headers (X-Correlation-Id,X-Request-Id,X-Tenant-Id) are propagated automatically by pyfly'sCorrelationFilter-- they ride into the audit log on every mutation. - Metrics. Pyfly's actuator endpoints (
/actuator/metrics,/actuator/prometheus) expose the standard JVM-style metrics (HTTP request totals + latency, async pool sizes, ...). Scrape them with Prometheus. - Health probes.
/actuator/health/readinessaggregates the database health indicator + the EDA event-publisher health indicator./actuator/health/livenessis a process-up signal. Wire them as KubernetesreadinessProbe+livenessProbe.
# Kubernetes example
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8500
initialDelaySeconds: 5
periodSeconds: 5
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8500
periodSeconds: 10flycanon does not store the canonical bytes -- only the extracted text, chunks, embeddings, and metadata. Rough planning numbers for the canonical store:
| Per source (Manifiesto.pdf-ish: 12 pages of digital text) | Bytes |
|---|---|
canon_sources row |
~2 KB |
| Extracted text (markdown) | ~25 KB |
| Chunks (avg 800 chars * 30 chunks) | ~24 KB |
| Embeddings (30 * 1536 dim * 4 bytes) | ~180 KB |
| Total | ~230 KB per source |
A 10K-source corpus lands around 2.3 GB before indexes. The
pgvector HNSW index doubles that. Plan accordingly.
The canonical state lives in Postgres. A pg_dump (or your cloud provider's managed backup) captures everything flycanon needs to resume:
canon_sources(no bytes)canon_chunks(text + section path + page)canon_chunk_vectors(embeddings)canon_knowledge_items+canon_knowledge_versions+canon_citationscanon_candidates+canon_audit_events+canon_taxonomy_nodes- The
pyfly_eda_outboxfor in-flight EDA messages
The BM25 projection lives in Postgres (canon_chunks.tsv + GIN
index), so the pg_dump captures it too -- there is no separate
file-backed corpus to back up.
Releases are CalVer (YY.MM.PP). The compatibility contract:
YY.MM.x-- patch releases. Drop-in replacement; no schema change, no env-var change, no SDK breakage.YY.MM.0-- monthly release. May introduce new optional env vars or new endpoints, never breaks existing ones. Runmigratebefore the new image rolls out.- Major (year) bumps -- explicitly documented breaking changes in the release notes.
The recommended upgrade dance:
# 1. Pull the new image
docker pull ghcr.io/firefly-operationos/flycanon:26.6.0
# 2. Run migrations against the live database
docker run --rm \
--env-file .env \
ghcr.io/firefly-operationos/flycanon:26.6.0 migrate
# 3. Roll the API + worker deployments
docker compose --env-file .env up -dAPI responses are append-only (new fields land as additive); existing SDK versions keep working against a newer service.
See troubleshooting.md for a problem -> root cause -> fix table covering the common production failure modes (embeddings unavailable, pgvector dim mismatch, OCR missing, ...).
