Kairos — Backend Reference
For AI coding agents: This document covers every service, worker, data model, and configuration parameter in the Kairos backend. Read alongside
ARCHITECTURE.md(layer design),docs/API.md(endpoint reference),docs/INFRA.md(containers, ports, Redis/Qdrant/ES, observability, dev commands), anddocs/FIXTURES.md(mock data fallbacks). All 34 implementation tasks are verified complete.
Table of Contents
- What Kairos Does
- Repository Layout
- Infrastructure Stack
- FastAPI Application
- Data Models (Pydantic)
- Services Layer
- Temporal Workflows and Activities
- Celery Workers
- Go OT Connector
- Database Schemas
- Configuration Reference
- Auth and Authorization
- Observability
- Non-Negotiable Rules
1. What Kairos Does
Kairos is an Industrial Operational Intelligence Platform. It continuously monitors the operational pulse of asset-intensive facilities (oil & gas, power, pharma, steel, mining) and delivers the right knowledge to the right person at the exact moment it is needed — without being asked.
Three-phase architecture:
- Phase 1 (live): Retrieval — ingest documents, build a temporal knowledge graph, answer queries with source-cited results.
- Phase 2 (live): LLM Synthesis —
POST /search/synthesizeassembles retrieved facts into provenance-backed answers via NVIDIA NIM (cloud-only; the local Ollama fallback is disabled — see §6LLMService).POST /search/synthesize/streamreturns the same answer as SSE for progressive render; it is a separate endpoint so the parse contract and its measured answer-quality figure carry no risk. - Phase 3 (live): Proactive Push — event-driven brief delivery to operators (work orders, PTWs, shift handovers, alarms, tag-outs, inspections) via Redis Streams and the EEMUA 191 governor.
2. Repository Layout
kairos/ # repo root
├── backend/ # Python app (Docker build context)
│ ├── api/
│ │ ├── main.py # FastAPI app factory + lifespan
│ │ ├── config.py # All settings (pydantic-settings, env vars)
│ │ ├── dependencies.py # DI: Neo4j, Qdrant, ES, Redis, Supabase, Auth
│ │ ├── middleware/
│ │ │ ├── opa.py # OPA policy enforcement middleware
│ │ │ ├── ratelimit.py # Per-IP Redis rate limit
│ │ │ └── telemetry.py # OTEL tracing + metrics setup
│ │ ├── models/ # Pydantic request/response schemas
│ │ ├── routers/ # One file per domain layer
│ │ ├── services/ # All business logic (no logic in routers)
│ │ └── utils/
│ │ └── failure_families.py # Shared FAILURE_FAMILIES dict (recurring detection)
│ ├── workers/
│ │ ├── celery_app.py # Celery app definition
│ │ ├── attribution.py # Outcome attribution worker
│ │ ├── brief_assembly.py # Delayed brief assembly worker
│ │ ├── model_validation.py # NER model gate evaluation worker
│ │ ├── offboarding.py # Off-boarding interview question generation worker
│ │ ├── temporal_worker.py # Temporal activity worker (ingestion pipeline)
│ │ ├── elicitation_worker.py # Temporal worker (elicitation workflows)
│ │ └── voice_transcription.py # Groq Whisper transcription + NER Celery task
│ ├── workflows/
│ │ ├── document_pipeline.py # DocumentIngestionWorkflow + all 7 activities
│ │ └── elicitation_workflow.py # MicroInterviewWorkflow + question generation (Neo4j + LLM)
│ ├── connectors/ # Go service — OT historian + EAM sync
│ │ ├── cmd/connector/main.go # Entry point, all HTTP handlers
│ │ ├── cmd/connector/auth.go # X-Connector-Secret middleware + boot-time config validation
│ │ ├── cmd/connector/auth_test.go # Go tests: secret check, config refusal, body cap, no upstream echo
│ │ ├── internal/ot/client.go # PIWebAPIClient + MockHistorianClient
│ │ ├── internal/ot/client_test.go # Go test: tag escaping, URL scrubbed from errors
│ │ └── fixtures/sample_assets.json # 5 demo assets for EAM sync
│ ├── scripts/
│ │ ├── seed_users.py # Creates 6 Supabase auth users (admin, engineer, field_worker, reliability, compliance, demo). Passwords come from KAIROS_SEED_PASSWORD_* in .env.
│ │ │ # Passwords from KAIROS_SEED_PASSWORD_* in .env (refuses to run if any is blank);
│ │ │ # role + site_id are written to app_metadata, display name to user_metadata
│ │ ├── migrate_roles_to_app_metadata.py # One-off: copy role/site_id/name from user_metadata to app_metadata for
│ │ │ # existing users. DRY RUN by default, --apply writes cloud Supabase Auth.
│ │ │ # Must run BEFORE deploying code that reads app_metadata only
│ │ ├── seed_regulations.py # Seeds 12 regulations into Neo4j
│ │ ├── init_neo4j.py # Neo4j schema constraints + indices
│ │ ├── init_qdrant.py # Qdrant collection creation
│ │ ├── run_model_validation.py # Entity-extraction F1 vs validation_corpus (Layer-0 model gate).
│ │ │ # NOTE: prints only — it does NOT write audit_log, so its
│ │ │ # results never appear on /governance/model-gate. Only the
│ │ │ # Celery task (POST /governance/model-gate/run) persists.
│ │ ├── seed_validation_corpus.py # Seed NER ground-truth entities from canon (entity-F1 labels)
│ │ ├── load_demo_dataset.py # Load dataset/ via the real API pipeline; seeds aliases + NER corpus (`make load-dataset`)
│ │ ├── audit_submission_patterns.py # ARCHITECTURE §8 mitigation 3: submission-rate outliers.
│ │ │ # Median-based, reports only, silent below 5 accounts.
│ │ ├── verify_graph_perf.py # ARCHITECTURE §7 query-perf regression check (`make graph-perf`).
│ │ │ # Asserts plan SHAPE, not timings — dbHits move with the data.
│ │ ├── backfill_graph_nodes.py # Layer-4 corpus backfill: Event (free) + Person/Organisation (NIM).
│ │ │ # DRY RUN by default — pass --apply to write. Idempotent (MERGE).
│ │ │ # Stamps Document.entity_backfill_at on a model-backed pass so a
│ │ │ # doc mentioning nobody is not re-extracted forever; --force resets.
│ │ ├── redate_demo.py # Shift demo event dates so the newest is "yesterday" (`make redate-demo`).
│ │ │ # Golden events only (matched via the loader's mapping). DRY RUN by
│ │ │ # default; --apply writes cloud Supabase + Neo4j. Safe to re-run.
│ │ ├── generate_showcase.py # Write the showcase_* files into dataset/ from the generator (`make generate-showcase`). No store.
│ │ ├── load_showcase.py # Load the showcase_* files in dataset/ (about 160 assets, about 470 documents, 90 days of history)
│ │ │ # into the CURRENT stores (`make load-showcase`). DRY RUN by default; --apply
│ │ │ # also needs SHOWCASE_CONFIRM. Fails if any row lacks a showcase marker or the
│ │ │ # real-mode counts change.
│ │ ├── redate_showcase.py # Shift the showcase's dates forward (`make redate-showcase`); time columns of the
│ │ │ # loader's own rows only. DRY RUN by default. SHOWCASE_AUTO_REDATE runs it daily.
│ │ ├── reset_showcase.py # Put the showcase's state back to its seed (`make reset-showcase`). The only
│ │ │ # delete: marker filters only, never the vault. DRY RUN by default.
│ │ ├── showcase/ # The generator: spec.py (sites, people, assets), docs.py (the documents),
│ │ │ # history.py (events, briefs, governance, knowledge capture), redate.py;
│ │ │ # files.py writes and reads the showcase files in dataset/ and binds it to load time
│ │ ├── purge_test_data.py # Delete test-prefixed rows from all stores (`make purge-test-data`)
│ │ └── wipe_local_stores.py # Empty Neo4j + ES + Qdrant entirely (`make wipe-local` / `reset-local`)
│ └── requirements.txt
├── benchmark/ # Evaluation harness + evidence (mounted at /app/benchmark)
│ ├── run_benchmark.py # Retrieval · answer quality · provenance · KG-linkage · time-to-answer (`make benchmark`)
│ ├── verify_layers.py # Per-layer smoke + latency (`make verify`)
│ ├── questions.json # 25 domain-expert Q&A across 15 categories (grounded in the dataset canon)
│ └── RESULTS.md # Raw output of both scripts (methodology → docs/BENCHMARKS.md)
├── db/ # Database schemas (mounted into Python containers)
│ ├── schema.sql # Consolidated Supabase schema — single source of truth (001–016 folded in, plus the 017 RLS block)
│ ├── migrations/ # Hand-applied SQL not yet run on the live project
│ │ └── 017_enable_rls_remaining_tables.sql # RLS on the 14 tables that lacked it (live RLS was already on, verified 2 Oct; idempotent safety net, see DATABASE.md)
│ ├── maintenance/ # Cloud-Supabase reset SQL (reset_all_data.sql) + CHANGELOG.md (tracked runs)
│ └── neo4j/init_schema.cypher # Neo4j constraints + indices
├── fixtures/ # Shared mock data (mounted into Python containers)
│ └── pid_topology_mock.json
├── infra/ # Infrastructure configs
│ ├── policies/kairos.rego # OPA RBAC rules (active — mounted by kairos-opa)
│ ├── temporal/dynamicconfig.yaml # Temporal server config (active)
│ ├── caddy/Caddyfile # HTTPS reverse proxy (active under --profile prod): 30 MB body cap + security headers
│ └── grafana/dashboards-import/ # Grafana Cloud dashboard JSONs (obs is Grafana Cloud; not mounted by any container)
├── frontend/ # Next.js UI (separate Docker build context)
└── tests/ # Pytest test suite (mounted into backend-api); the test_sec_*.py files cover the 2026-10-01 security pass3. Infrastructure Stack
Moved to
docs/INFRA.md. That file is the single source of truth for all Docker containers, ports, Redis DB allocation, Redis Streams, Qdrant collections, Elasticsearch indices, OTEL pipeline, Grafana dashboards, and dev commands.
4. FastAPI Application
Entry point: backend/api/main.py
create_app() registers:
- CORS middleware (
CORS_ORIGINSfrom settings) OPAMiddleware(enforces RBAC via OPA on writes and sensitive reads)RateLimitMiddleware(per-IP; zero, meaning off, only whenSettings.is_development)- OTEL instrumentation (
setup_telemetry(app)) - All routers with prefix/tag
Every middleware reads request.scope["path"], never request.url.path: the URL is rebuilt from the Host header, so Host: x/health turned /events/work-order into /health/events/work-order, which the OPA skip list treated as exempt. The scope path is what the router matches.
Database errors reaching the global handler return fixed messages (foreign key: A referenced record does not exist.; unique: A record with these identifiers already exists.), never the upstream detail.
Lifespan: On startup, VectorStoreService.ensure_collections() and SearchEngineService.ensure_indices() create Qdrant collections and ES indices if missing.
Dependency Injection (api/dependencies.py)
All backends are injected as FastAPI dependencies. Each is a singleton (module-level global, lazy-initialized).
| Dependency | Type | Notes |
|---|---|---|
SupabaseDep resilience | — | Every PostgREST session (API, workers, scripts) goes through api/supabase_http.py, installed by api/__init__.py: a GET/HEAD on a pooled HTTP/2 connection Supabase already closed is retried once; writes are never replayed. |
Neo4jDep | AsyncDriver | Bolt to NEO4J_URI. Pool hygiene for Aura: liveness_check_timeout=30 + max_connection_lifetime=300 recycle idle connections so a stale one never throws SessionExpired. |
QdrantDep | AsyncQdrantClient | HTTP to QDRANT_URL |
ElasticsearchDep | AsyncElasticsearch | HTTP to ELASTICSEARCH_URL |
RedisDep | aioredis.Redis | DB 0 |
TemporalDep | TemporalClient | gRPC to TEMPORAL_ADDRESS |
SupabaseDep | Client | Service-role key — bypasses RLS |
CurrentUserDep | dict | Decoded JWT: {user_id, email, role, site_id, sub} |
SettingsDep | Settings | Cached settings singleton |
Auth flow in get_current_user:
- If no
Authorizationheader andSettings.dev_bypass_allowed(APP_DEBUG=TrueandAPP_ENV=development) → returns{user_id: "dev-user", role: "engineer"}(dev only).APP_ENVis trimmed and lower-cased, and only the exact valuedevelopmentcounts:production,stagingor a typo are all non-development (Settings.is_development). - If token matches
INTERNAL_API_KEY(hmac.compare_digest) → returns service account{role: "admin"}(Go connector bypass). - Otherwise → calls
supabase.auth.get_user(token)using a fresh anon client (never the service-role client). Role and site are read fromuser.app_metadata(users can edituser_metadata, neverapp_metadata).POST /auth/logoutdrops the token from the verified-token cache (_auth_cache_drop) and revokes the session.site_scope(user, requested)returns the caller's own site (None for admin) and403s a request for another one; it is how every site-scoped read and write in the routers is bounded.
5. Data Models (Pydantic)
All models live in backend/api/models/.
Asset (models/asset.py)
| Model | Purpose |
|---|---|
AssetCreate | POST /assets request body |
Asset | Full asset representation |
Key constraint: confirmed_by_user_id is mandatory in AssetCreate — AI-inferred identities are never accepted.
Document (models/document.py)
| Model | Purpose |
|---|---|
VaultDocument | Full vault record |
DocumentStatus | Extraction job status |
ExtractionResult | NER/OCR result summary |
SearchResult | Single search hit with authority + method |
SearchResponse | Paginated hybrid search response |
SynthesizeRequest | query (max 2000), optional as_of; context (max 50) and query_category are accepted for compatibility and ignored (the server retrieves its own evidence) |
SynthesizeResponse | Answer + sources + confidence + refused flag |
PromoteQuarantineRequest | Quarantine promotion payload |
RCAPackRequest | asset_id, incident_date, failure_code, include_quarantine |
RCAPackResponse | timeline[], hypotheses[], supporting_documents[], confidence, refused, synthesis_available |
Brief (models/brief.py)
| Model | Purpose |
|---|---|
Brief | Full brief with headline, body, action_items, warnings, sources |
SourceCitation | {document_id, document_type, title, authority_level, relevant_excerpt, vault_url, is_quarantine} |
BriefFeedback | rating (accurate | missing_context | incorrect) + optional notes. No brief_id — that comes from the URL path. |
Event (models/event.py)
All events inherit from BaseEvent (event_id, source_system, site_id, occurred_at, received_at).
| Model | Extra Fields | Source |
|---|---|---|
WorkOrderEvent | work_order_id, asset_id, failure_code, description, priority, close_notes | CMMS/EAM |
PTWEvent | ptw_id, work_area, asset_ids[], ptw_type, issuing_engineer_id | PTW system |
ShiftHandoverEvent | outgoing_shift_lead_id, incoming_shift_lead_id, handover_time | Any |
AlarmEvent | alarm_id, asset_id, alarm_tag, severity, acknowledged_by | DCS |
TagOutEvent | asset_id, tag_out_reason, performed_by (optional, a source-system claim), expected_return_date | Field |
InspectionCompleteEvent | asset_id, inspection_type, result, performed_by (optional claim), findings, document_id, confidence (may be lowered, never raised past 0.85 on the evidence edge) | Field/QA |
EventAck | notes; user_id, role, acknowledged_at, signature are optional and ignored (identity, time and signature are server-side) | API client |
DeviationFlagEvent | asset_id, description, affected_topology_path (the reporter is the token's user) | Field inspector |
DeviationFlagResolveRequest | resolution (promoted | disputed), moc_warranted, notes | Engineer/admin |
PlantStateEvent | site_id, state (normal | turnaround | shutdown | emergency), expires_at | Engineer/admin |
close_notes on WorkOrderEvent is used by the attribution worker for execution compliance keyword matching.
6. Services Layer
All business logic lives in backend/api/services/. Routers call services; services never call routers.
GraphService (services/graph.py)
Interface to Neo4j. All writes use MERGE, never CREATE for asset nodes.
Key methods:
topology_node_id(document_id, element_id)— graph node id of a P&ID element,{document_id}:{element_id}. The vision model numbers elements per drawing (TOPO-EQ-001), so a bare id merged two drawings onto one Concept. Supabase rows keep the raw id;verify_topology_elementmatches both the scoped and the legacy bare form, pinned to the document's own edge.create_asset_node(props)— MERGE Asset node with all propertiesget_asset(asset_id)— single asset lookuplist_assets(site_id, equipment_class, skip, limit)— paginatedget_asset_hierarchy(asset_id)— PARENT_OF traversal up to 10 levelsget_asset_knowledge_at(asset_id, as_of)— KNOWLEDGE_EDGE traversal with optional time-travel. Deduped byedge_id— the graph can hold multiple physical relationships sharing one logicaledge_id(CypherDISTINCTcan't collapse them), so the same fact would otherwise repeat.create_knowledge_edge(asset_id, document_id, rel_type, props)— writes edge with all 6 required properties.valid_todefaults to the open-ended sentinel_OPEN_VALID_TO(see below) when not supplied; it is never stored asnull.merge_document_node(document_id, props)— MERGE Document nodedetect_conflict(source_id, source_label, relationship_type, new_document_id, new_authority_level)— dual-track conflict detection. ReturnsNoneimmediately for any type inNON_ASSERTING_RELATIONSHIPS(see below), before the Cypher runs.get_blast_radius(document_id)— traverses(source)-[r:KNOWLEDGE_EDGE {document_id}]->(target)and returns each{edge, source, target}. The affected entity is thesource(e.g. the asset), not the target (the document node). Deduped byedge_id(re-runs leave duplicate relationships).close_validity_window(edge_id, closed_at)— setsvalid_toon a KNOWLEDGE_EDGE (used by MoC webhook and the in-app MoC approve endpoint, via the shared_resolve_moc_conflicthelper)create_concept_node(props)— Concept:Regulation seedlink_concept_to_asset(concept_id, asset_id, props)— compliance framework linkageget_event_timeline(asset_id, window_start_iso)— Event nodes linked to an asset within a date window for RCA pack assembly
NON_ASSERTING_RELATIONSHIPS — what may be called a conflict:
DOCUMENTED_BY, MENTIONS_PERSON, MENTIONS_ORGANISATION, CONTAINS_TOPOLOGY_ELEMENT record
provenance or structure, never a claim, so two of them can never contradict each other — an
archive holding several documents about one pump is the normal state, not a disagreement.
is_asserting_relationship() is the single predicate; routers/governance.py applies the same set
when listing conflicts, because rows written before this guard live in a cloud store and cannot be
deleted.
The honest check would compare the two asserted values, but a KNOWLEDGE_EDGE has no value
property — the six mandatory props are validity, authority, document, confidence and verification
status. That is also why auto-detected conflicts carry no value in source_a/source_b. Adding
one is a schema change plus a backfill of every existing edge, so the correct move at this scale is
to stop asking the question of edges that cannot answer it.
All 6 properties required on every KNOWLEDGE_EDGE write:
valid_from, valid_to, authority_level, document_id, confidence, verification_status
_OPEN_VALID_TO sentinel (datetime(9999, 12, 31, 23, 59, 59, tzinfo=timezone.utc)):
Neo4j silently drops properties set to null. Storing valid_to=None would cause the key to disappear from edge.keys(), silently violating the six-property rule. Instead, create_knowledge_edge() stores (valid_to or self._OPEN_VALID_TO).isoformat() — the far-future sentinel signals "currently active / no supersession date" without dropping the property.
"Active edge" query pattern:
All Cypher queries that filter for currently-active edges use (r.valid_to IS NULL OR r.valid_to > datetime()), not r.valid_to IS NULL alone. The IS NULL branch covers legacy edges written before the sentinel was introduced; the > datetime() branch covers all new sentinel edges. Affected files: graph.py (detect_conflict, close_validity_windows), compliance.py (3 call sites), brief_engine.py, offboarding.py, elicitation_workflow.py.
SearchService (services/search_service.py)
Orchestrates hybrid search across three engines.
hybrid_search(query, collection, asset_id, authority_min, include_quarantine, as_of, limit):
- Neo4j graph traversal — only when
asset_idprovided, and run first: its edges name every document linked to the asset - ES exact search — tag numbers, document IDs, clause refs
- Qdrant semantic search — 1024-dim embedding via
LLMService.embed()
ES and Qdrant run in parallel. With asset_id, both match a document filed under that asset or
one of the graph-linked documents from step 1. A document is indexed under a single primary
asset_id, but often concerns several assets (the EQ-1xx work-order CSV is filed under EQ-102 and
records EQ-101's seal failures; SOP-HE-GEN-11 is filed under no asset). Without the widened scope an
asset-scoped search could never reach them. If the graph lookup fails, search degrades to the
primary-asset scope rather than failing.
Fusion — Reciprocal Rank Fusion (_RRF_K = 60), then authority ordering. Each source's
results are scored 1/(60 + rank) and summed, so a document more than one source agrees on
ranks higher. RRF replaced a direct comparison of ES relevance against Qdrant cosine
similarity: BM25 is unbounded and cosine is 0–1, so comparing them numerically ranked by
whichever source emitted bigger numbers.
Authority remains the primary sort key — level 1 (Regulatory) outranks level 5 (Field) —
because that is a deliberate safety property, not a relevance artefact. Within an authority
level, an asset-scoped search ranks the asset's own documents ahead of documents that are only
graph-linked to it; otherwise a linked shift log could push the asset's own closeout form out of
limit. RRF orders results after that, which is where the scale mismatch actually did damage. The
fused score is written back to relevance_score.
Provenance edges never take a slot on their own (_rankable_graph_hits). A DOCUMENTED_BY or
MENTIONS_* edge states no fact, so alone it renders a content-free stub ranked by the edge's
authority. With extraction linking every document an asset appears in, such stubs (a level-1
regulation, a level-4 PTW) filled the top of an EQ-101 search and pushed out the closeout form holding
the answer. They still widen the text-search scope and still boost a document text search also found.
Merging duplicates (_better): when several sources return the same document_id, the
most authoritative record wins but the longest snippet is kept — collapsing by
document_id used to discard the losing record's text, so a semantic chunk containing the
answer could be replaced by a shorter exact-match excerpt and synthesis never saw the fact.
retrieval_method records every method that surfaced it (e.g. exact+semantic).
Graph hits carry text (_edge_snippet): a graph result used to have snippet="", so it
entered the ranking but gave synthesis nothing to read — a fact existing only as an edge was
invisible to the answer. Edges now render as a readable fact line including validity,
verification status and confidence, and an unverified edge is flagged is_quarantine.
Covered by tests/test_search_fusion.py.
SearchEngineService (services/search_engine.py)
Wraps Elasticsearch.
ensure_indices()— createskairos_documents,kairos_assets, andkairos_eventsindices on startupsearch(query, index, asset_id, limit, include_superseded=False)— ES full-text search with highlightindex_document(doc_id, content, metadata)— index a document chunk
Superseded documents are excluded by default (must_not on status: "superseded").
It is must_not superseded rather than must active deliberately: the query also spans
kairos_assets, whose documents carry no status field, and requiring active would drop
every asset hit. Time-travel callers pass include_superseded=True.
VectorStoreService (services/vector_store.py)
Wraps Qdrant.
ensure_collections()— createskairos_documentsandkairos_knowledgeon startupupsert(collection, point_id, vector, payload)— upsert a single vectorsearch(collection, query_vector, asset_id, limit, include_superseded=False)— ANN search with optional asset filtermark_superseded(collection, document_id)— payload update flagging every chunk of a document superseded. Never a delete: the vault is immutable and time-travel still has to reach the chunks.
Same must_not reasoning as ES — points indexed before status existed have no such key, and
Qdrant treats a missing key as non-matching, so requiring active would silently drop them.
LLMService (services/llm.py)
LLM synthesis + embedding. Never originates knowledge — only assembles retrieved context.
synthesize(query, context, query_category, aliases=None)—nvidia/nemotron-3-ultra-550b-a55bon the first configured tier (NIM by default), falling through the cascade below. A safety gate runs twice: on the evidence before synthesis, and on the result after it (an honest "not specified in the sources" must not render as a hedged answer).evidence_gate(...)/result_gate(...)— those two gates, as separate methods returning a refusal dict orNone. Extracted sosynthesize()andsynthesize_stream()call the same code: a second copy of a refusal rule would drift, and whichever an operator hit would be wrong.synthesize_stream(...)— async generator of(event, payload)behindPOST /search/synthesize/stream. Safety-critical categories emit no answer text at all:CONFIDENCE:arrives afterANSWER:, andresult_gatecan convert a finished answer into a refusal, so streaming it would show text the gate is about to retract. Everything else streamsANSWER:as it arrives. Streams tier 1 only; a mid-stream failure falls back to the cascade, skipping the tier whose stream just failed (retrying it would cost a second full timeout and overrun the frontend's 90 s budget), and emitsrestartso the client discards the partial text.rca_synthesize(query, context)— RCA-specific prompt, returns timeline + hypothesesembed(text, task)— Jinajina-embeddings-v3(1024-dim), bounded LRU cached (_LRU, 512 entries, keyed on(task, text)). Every search embeds its query before touching Qdrant, so a repeated or polled query previously paid a Jina round-trip each time. Embeddings are deterministic per(task, text)for a fixed model, so caching is safe; a failed embedding ([]) is never cached. Process-local — move to Redis if cross-replica hit rate matters.classify_query_category(query)— deterministic classifier mapping free text onto a safety-critical category, orNone. Called byPOST /search/synthesize(and its stream) for every request: the client'squery_categoryis ignored. It fails closed: keyword lists and unit regexes first, then a catch-allsafety_parameter_unspecifiedfor a question that reads like a limit, rating or setting (a number with an engineering unit, a tag plus a parameter word) and no list named. A false positive costs a refusal that points at the sources; a miss costs an ungated answer.query_asset_tags(query, aliases)— the assets a question names, by tag or by confirmed alias (resolved to the canonical id); standards and document prefixes such asOISD-117orSOP-114are not assets._gate_evidence(...)then requires every named asset to be vouched for by same-asset evidence (worst-covered asset's best authority, lowest best confidence).- Prompt hardening.
_prompt(user, system)returns astrsubclass carrying the system message, so the rules ride in the system channel and the user message holds only the escaped<query>and<document index authority_level document_id>blocks (_untrustedescapes text so it cannot close a tag or forge a[Source n | ...]header). Escaping reduces injection, it does not eliminate it, so the gates never rely on what the model says about authority. parse_synthesis_response(answer)— extracts structured fields from LLM output. The model's own trailing lines win (SOURCES_USEDlast match),CONFIDENCEtakes the lowest value, so nothing echoed from a document can raise it;valid_citations(cited, n)drops numbers outside the evidence.result_gatefails closed: no parseableCONFIDENCE, or a citation outside the evidence, is a refusal.parse_rca_response(answer, allowed_sources)— extractshypothesis|evidence_weight|sourceslines; a hypothesis may cite only the document ids the model was shown, and weights are clamped to 1.0
Every tier failing on 429 is reported as such. Each provider tags a
rate_limitedresult; when the whole cascade fails and any tier was rate-limited, the response getsrate_limited: trueplus a message naming them. The second tier is a free-tier Gemini key, so under sustained load answer quality degrades toward zero while retrieval keeps working — configure Ollama as a genuine third tier, move Gemini to paid, or drop the cascade so a NIM failure surfaces as an honest error.Provider cascade (
_synthesize_cascade): Nebius Token Factory → NIM → OpenRouter → Gemini → Ollama. The order and the per-tier settings live in one place,services/model_providers.py(Provider,all_tiers,synthesis_cascade).all_tiersis every tier, keyed or not, and is what the/health/modelprobe looks names up in;synthesis_cascadeis the keyed subset that_synthesize_cascadeiterates. A tier with no API key is not in the list at all, so defaults ship with onlyNVIDIA_NIM_API_KEYset and the cascade is NIM-only, identical to before the registry existed. Every OpenAI-compatible tier goes through one call path,_synthesize_provider(provider, …): same retry on 502/503/504, sameverify_served_modelcheck, same failure shape. Ollama keeps its own method because/api/generateis a different wire format, not a different URL.Token Factory is wired but unkeyed, so it is inert until
NEBIUS_TOKEN_FACTORY_API_KEYis set. It serves the same Nemotron build as NIM, so promoting it changes who serves the model rather than which model answers, and the benchmark countstokenfactoryandnimalike as the pinned model. FillGEMINI_API_KEY(Google's OpenAI-compatible endpoint) to add a generous free-tier cloud fallback; setOLLAMA_BASE_URLto add the local air-gapped tier (intentionally empty by default, because an accidental local Ollama once consumed ~6 GB RAM). Embeddings stay Jina → Ollama.Adding a provider is one entry in
all_tiers()plus its settings.extra_bodycarries provider-specific keys (Nemotron'senable_thinking: false), so a key meant for one vendor never reaches another that would reject it.
Turning on Nebius Token Factory
Everything is built; only the key is missing. Steps, locally or on the server:
- Set
NEBIUS_TOKEN_FACTORY_API_KEYin.env. - Recreate every container that calls a model, because env changes are read at start:
docker compose up -d --force-recreate kairos-backend-api kairos-celery-worker kairos-temporal-activity-worker kairos-elicitation-worker - System Health → AI models → turn on Nebius Token Factory. The probe sends exactly what a real
answer sends, including the thinking-off flag, and reports the model Token Factory says it served.
- OK and no model mismatch: done.
- 400 mentioning
chat_template_kwargs: Token Factory rejects the flag. SetNEBIUS_TOKEN_FACTORY_DISABLE_THINKING=false, repeat step 2. NIM keeps its own switch.
- Ask the Copilot an ordinary question. The line under the answer should read Nebius Token Factory.
If it reads NVIDIA NIM, Token Factory failed and the cascade fell through; the API log names why
(
synthesis.provider_failed,provider=tokenfactory). - Re-run
benchmark/run_benchmark.pybefore quoting any quality figure, because the published one was measured through NIM. A run answered bytokenfactorycounts as the pinned model.
Safety-critical categories:
max_allowable_pressure, isolation_interlock_sequence, torque_specification, electrical_rating, pressure_relief_setting, safety_shutdown_setpoint, safety_parameter_unspecified
The refusal gate clears on either high confidence (≥ 0.7) or an authoritative source
(authority_level ≤ 3 = regulatory / engineering / OEM), and refuses only when both fail.
Both signals are needed: hybrid search and graph facts carry authority_level but no
confidence, so a confidence-only gate read them as 0.0 and would refuse every
safety-critical query. Covered by tests/test_query_category.py and tests/test_sec_llm.py.
BriefEngine (services/brief_engine.py)
Assembles operator briefs from 5 parallel graph+vector+ES+Supabase queries.
assemble_work_order_brief(event)— pulls failure history, open conflicts, procedures, quarantine flags; appends correlated DCS alarms / PTW context. Headline + body are operator-readable prose (grouped record counts, named source documents, ⚠ lines for conflicts/quarantine) — not a raw edge dump. No LLM call (phase discipline); authority/verification stay in the source badges.assemble_ptw_brief(event)— adds isolation topology, regulatory requirementsassemble_shift_handover_brief(event)— pulls active WOs, alarms, open PTWsassemble_recurring_failure_brief(event)— triggered when same-family WO detected in 90-day window; priority=high headline includes recurrence countassemble_tag_out_brief(event)— pulls downstream topology dependencies for the tagged-out assetassemble_inspection_brief(event)— triggered onresult=failedor non-emptyfindings; includes finding text and referenced documentdeliver(brief, redis)— saves tobriefstable, publishes toREDIS_STREAM_BRIEFS, recordskairos.briefs.deliveredmetric. 4-hour cool-down per (recipient, asset, trigger event type)._get_correlated_events(event_id)— fetches all events sharing the samecompound_event_idvia Supabase
EventBusService (services/event_bus.py)
Redis Streams producer + EEMUA 191 push governor.
publish(stream, payload)—XADDto any streampublish_work_order(payload)/publish_ptw(payload)— typed publish helpersis_duplicate(asset_id, event_type, business_id=None)— read-only Redis key check (DEDUP_WINDOW_MINUTESwindow).mark_seen(...)records the event and is called last, after the ingest succeeds, so a failed ingest stays retryable. Passbusiness_id(work_order_id,ptw_id,alarm_id) wherever the event has one. Keyed on(asset_id, event_type)alone it collapses two different permits on one asset into one and the second technician never gets a brief — routine during a turnaround. Wired on all six event routes.mark_seen(asset_id, event_type, business_id=None)— the write half of dedup, called as the last step of each event route (marking at check time turned a connector's retry after a 500 intodeduplicatedand lost the event, and with it a critical PTW brief). Two identical events racing inside one request's runtime can both run; the insert is idempotent and the brief cool-down absorbs the twin.correlate_events(asset_id, event_id, occurred_at, supabase)— assigns sharedcompound_event_idto same-asset events within the dedup windowcheck_governor(user_id, priority, site_id, supabase)— returns True if brief can be sent. PTW (priority="critical") always passes. Checks plant state gate then hourly rolling counter.get_plant_state(site_id, supabase)— queriesplant_operating_states, checksexpires_atrecord_push(user_id)— increments hourly counter with 3600s TTLget_governor_state(user_id)— returns{state, push_count_last_hour, ceiling, next_delivery_allowed_at}
Event routes (routers/events.py)
_store_event(supabase, row)— the one insert intooperational_eventsfor every event route.event_idis client supplied, so it is a plain insert, not an upsert (an upsert let any ingest role overwrite another event's payload, asset and site by posting its id). A duplicate key (Postgres23505) is read back: identical type, asset, site and payload is a retry after a partial failure and carries on; anything else is409and the stored row is untouched.IngestUserDep/_INGEST_ROLES = (engineer, reliability, admin)—require_roleguard on the six ingest routes, mirroring the OPAingest_eventgrant._canonical_asset(asset_id, driver, supabase, current_user)— resolves a confirmed alias, 404s an unknown tag, and withcurrent_useralso 404s an asset on another site (same response, so existence is not disclosed).INSPECTION_EVIDENCE_CONFIDENCE = 0.85— ceiling on the confidence of an inspection-evidence edge.inspection-completestores the event first, validates thatdocument_idis aninspection_reportalready in the vault, then writes the graph edge and any quarantine row.- Acknowledgements (
POST /events/{id}/ack) are built server-side: actor from the token, HMAC-SHA256 signature (_sign_acknowledgment, shared with briefs) over event, user, action and server time, one per user per event (a repeat returnsrepeat: true). - The pending-brief Redis slot (
kairos:brief_pending:{asset_id}:{work_order_id}) belongs to work orders only; tag-out and PTW take none.
SLAService (services/sla_service.py)
Lazy, inline SLA escalation. Called at the top of GET /governance/conflicts and GET /governance/quarantine. No Celery Beat, no scheduled worker.
check_and_escalate(supabase)— idempotent (guarded byescalated_at IS NULL). Queries overdue conflicts (sla_deadline < NOW()) and quarantine items (sla_due_at < NOW()). Writesescalated_atand appendsaudit_logentry withaction=sla_escalatedfor each. Returns{conflicts_escalated, quarantine_escalated, checked_at}.
TopologyVerificationService (services/topology.py)
Layer 3 → Layer 7 gate. Derives per-element verification status from each element's
quarantine_items.review_status, rolls it up per drawing, and applies engineer decisions.
SAFETY_CRITICAL_GROUPS = {isolation_boundaries, instrumentation_loops}—canonical_readyrequires all of them confirmed and none disputed.- Confirming an element promotes the
CONTAINS_TOPOLOGY_ELEMENTedge the pipeline already wrote; it does not create edges. - The manifest row is filtered in Python, not in the query — element rows carry no
element_typekey, so.neq("session_context->>element_type", …)compares against SQL NULL and silently drops every element row.
OtCoverageService (services/ot_coverage.py)
Layer 5 instrumentation coverage, derived from engineer-verified P&ID topology only: verified
instrumentation_loops[].instruments[] are the sensor tags. Supabase-only, so the Celery
attribution worker uses it directly without an HTTP hop.
coverage_type: "none" means no verified drawing establishes instrumentation — not "this
equipment has no sensors". unverified_topology_present separates review backlog from real absence.
Replaces a Go handler that returned hardcoded
{asset}-VIBE/{asset}-TEMP/75%for every asset. Becauseattribution.pygated its telemetry check oncoverage_percent == 0, that value made the check always run and Layer 10's brownfield downgrade unreachable.
TimestampAlignmentService (services/timestamp_alignment.py)
Layer 4 clock alignment. Compares the same correlated event as reported by different source
systems, reusing Layer 8's compound_event_id grouping. Normalises to the best-synchronised clock
(the historian is site-canonical).
Not occurred_at vs ingested_at — a historical document legitimately occurs months before
ingestion, so that comparison flags the whole corpus. The ingestion pipeline still performs that
other comparison. Resolved 2026-08-17: the pipeline now treats that gap as ingest lag — it
records ingest_lag_recorded and never touches valid_from, which uses the source timestamp.
Report-only while TIMESTAMP_DRIFT_ENFORCE=False.
CircuitBreakerService (services/circuit_breaker.py)
SPC circuit breaker: halts graph writes for an asset class when its 7-day override-count z-score exceeds 2.0.
check(asset_class)— returns{halted: bool, z_score: float, reason: str, override_count_7d: int}.reason∈z_score_exceeded | within_normal_range | stats_error.record_override(asset_class, override_type, document_id)— inserts anextraction_overridesrow for SPC tracking.get_all_states()— returns one state dict per distinctasset_classthat has override records (each carriesasset_class+ thecheck()fields).GET /governance/circuit-breakerwraps these as{states[], halted_count}.
NERService (services/ner.py)
Named entity recognition for the extraction pipeline. Cloud-first: NIM primary, then a regex last resort. (A local Ollama tier exists in code but is disabled — OLLAMA_BASE_URL is empty; see §6 LLMService.)
NERService(model=…)— overridesNVIDIA_NIM_NER_MODELfor this instance. The Layer-0 model gate scores a candidate model, so it must be able to pick one; without this the gate always called the env-var model and merely labelled the result with the requested name.- NIM is called at
NVIDIA_NIM_BASE_URL(the same setting synthesis honours; it used to be hard-coded to NVIDIA's public endpoint, so a private gateway still received the key and the document text). An entity returned with a missing, non-numeric or out-of-rangeconfidencegets_DEFAULT_ENTITY_CONFIDENCE = 0.5, below the 0.7 quarantine line, so it goes to review rather than into the graph; one garbled score no longer discards the whole document's entities. extract_entities(text, document_type)— tries NIMmeta/llama-3.2-11b-vision-instruct; falls back to regex ASSET_TAG pattern matching (Ollama tier skipped whileOLLAMA_BASE_URLis empty). Returns[(entity, entity_type, confidence)]- Character offsets are recovered (
_with_spans): the model returns entity text with no positions, sostart/endused to come backNone— leaving the annotation UI unable to highlight an entity in its source document. Each entity is located in the original text, with a per-value cursor so a repeated mention gets successive positions instead of collapsing onto the first. Entities the model paraphrased are left unlocated rather than guessed. This does not affect the Layer-0 F1 metric:workers/model_validation.pymatches on surface-form overlap (_span_match), never on offsets.
OCRService (services/ocr.py)
OCR for the extraction pipeline. Cloud-first for scanned documents; zero-API-cost fast path for native digital PDFs.
extract_text(file_bytes, mime_type)— Fast paths (no API cost):Input mime How Plain text / markdown / CSV text/plain,text/markdown,text/csvUTF-8 decode Spreadsheets …spreadsheetml.sheet(.xlsx),vnd.ms-excel,…opendocument.spreadsheet(.ods)openpyxlread-only streaming → every sheet flattened to tab-separated rows, prefixed# Sheet: <name>; blank rows droppedEmail archives message/rfc822(.eml),application/mbox,text/rfc822-headersstdlib email— Date/From/To/Cc/Subject headers +text/plainbodies; attachments listed by filename, never decoded inline (their bytes belong in the vault as their own documents). An mbox is split on its ownFromdelimiter, so every message is extractedDigital PDFs application/pdfPyMuPDF native text Cloud path:
nvidia/nemotron-ocr-v2for scanned documents and images.Resource ceilings (uploader-controlled files are rasterised or flattened in the one ingestion worker):
MAX_PDF_PAGES = 100, DPI capped at 150 and byMAX_RASTER_PIXELS = 16,000,000per page (capped_dpi; a page too large to stay legible at 30 DPI is rejected),MAX_RASTER_TOTAL_BYTES = 150 MBof PNG across all pages, spreadsheets capped at 50,000 rows, 256 columns and 200 MB declared uncompressed (checked from the zip directory before openpyxl reads it). A file over a ceiling is treated as unreadable and routes to human review, never silently truncated.Spreadsheets and email archives are two of the six source types named in the problem statement.
openpyxlis used rather than hand-rolled zip+XML because xlsx cell typing (dates stored as serial numbers, shared vs inline strings) is exactly the silent corruption that must not reach the graph. Covered bytests/test_ingestion_formats.py.
Important: The OCR model uses the NVIDIA CV API (
https://ai.api.nvidia.com/v1/cv/<model>), NOT the chat completions endpoint (integrate.api.nvidia.com). Request format uses"input": [{"type": "image_url", "url": "data:..."}].Response format is
{"data": [{"text_detections": [{"bounding_box": ..., "text_prediction": {"text": ..., "confidence": ...}}]}]}. This line previously documented alabelkey that the API has never returned —_detection_textread it, every line resolved to"", and the caller reportednim_returned_no_text, which was then read for weeks as the "no handwriting model" Layer-3 limitation. Fixed 2026-08-23;label/textare retained as fallbacks. If you change this parser,ocr.detections_unparsedis the log line that tells you the schema moved — it is deliberately distinct fromocr.no_detections(the model ran and saw nothing).Confidence is the model's own, not a constant.
overall_confidenceis the per-span confidence weighted by span length, reported withmin_span_confidence,low_confidence_spansandspan_count. It was hardcoded to0.95until 2026-08-23, which made a garbled scan indistinguishable from a clean one to every downstream gate — including the< 0.7 → quarantinerule, applied to a number that could never be below 0.7.Inline image limit: 180 KB base64. Pages are rasterized at 96 DPI to stay under it, and an image that still exceeds it is re-encoded by
_shrink_for_inline(JPEG first, then progressive downscale) rather than skipped. It used to return""silently, so the corpus's two degraded scans (11x and 13x over) never reached the model at all. Near-duplicate ofPIDService._fit_b64;_NIM_IMAGE_SIZE_LIMITis defined in both modules and they must move together.
PIDService (services/pid.py)
Layer 3, Path B — P&ID engineering-drawing topology extraction via a cloud vision model. Vision-understanding, not OCR (OCR destroys the drawing's connections).
extract_topology(file_bytes, mime_type) -> dict | None— rasterizes the drawing (150 DPI for PDFs, lowered byocr.capped_dpiso one page never becomes a multi-gigabyte bitmap; an A0 sheet at 150 DPI already exceeds the pixel cap), downscales to fit the inline size cap (Pillow), sends it to NIMmeta/llama-3.2-11b-vision-instruct(chat completions, OpenAI-styleimage_urlcontent) with a schema-locked prompt, and parses the returned topology JSON (equipment_nodes,isolation_valves,instrumentation_loops,isolation_boundaries). ReturnsNoneon any failure so the pipeline falls back to the demo fixture.- Wired into
run_ocrfordocument_type='pid_drawing'. The result carriestopology_source(vision_model|demo_fixture) through the manifest andGET /documents/{id}/topologyso a fixture never masquerades as a real extraction. Every element still routes to element-by-element engineer verification (Layer 7). - Path A (custom YOLOv9 + LayoutLMv3 on GPU) is the documented future upgrade — see
ARCHITECTURE.mdLayer 3.
PIIService (services/pii.py)
PII detection and masking for the DPDP Act 2023 export boundary.
detect(text, person_names) -> [span]— non-overlapping spans, earliest-start wins with longest-match tiebreak. Structured identifiers by regex (EMAIL, PAN, AADHAAR, EMPLOYEE_ID, SHIFT_ID, Indian PHONE:+91/(+91)/91/0prefixes and a 5+5 split; Aadhaar with spaces, dashes or none); PERSON names supplied by the caller fromNERService, plus context patterns (_NAME_CONTEXT) for names the model never saw because NER reads only the first part of a document: after an honorific (Mr,Dr,Shri,Smt, ...) or a sign-off label (Prepared by,Approved by,Operator:,Name:, ...). No second name model.redact(text, person_names) -> {redacted_text, spans, counts, pii_found}— masks each distinct value with a stable pseudonym ([PERSON_1]), so cross-references in the text survive redaction where a blanket[REDACTED]would destroy them. Offsets inspansrefer to the original text.
Runs at export, never at ingestion. Operational knowledge legitimately contains personnel
names — "which technician signed off the EQ-101 seal repair" is a real maintenance question
(benchmark Q15) — so redacting on the way in would destroy retrieval. Equipment tags and part
numbers are deliberately never matched; tests/test_pii.py pins that.
Exposed via GET /documents/{id}/redacted, which also writes a pii_redacted_export row to
audit_log (type counts only, never matched values).
Scope:
ARCHITECTURE.mddescribes this pipeline as gating cross-site knowledge promotion. No cross-site promotion endpoint exists in the codebase, so that wiring is not built; the redaction pipeline itself is.
corpus (services/corpus.py)
Tells real vault documents apart from test artifacts — rows the suite and hand-run sweeps
write into the vault. They carry ordinary random DOC- ids, so only the file name identifies
them, and on 2026-08-23 they were 87 of the 108 active documents. Anything that counts or renders
documents without excluding them reports test hygiene rather than the plant.
is_test_artifact(file_name)— the single predicate. Anchored prefixes:ann_test_,dbtest_,test_,e2e_,kairos_,probe,tmp,# Kairos.e2e_andkairos_were added by decision D8: both name a file after this system or its harness rather than after plant equipment. A_test\.extstem rule was rejected — it would also match a plausible realhydro_test.pdf, and hiding plant evidence is the one failure this predicate must not have.document_rows(supabase, document_ids)— resolves ids todocumentsrows (document_id,file_name) in one bulk lookup (chunked at 200). Read-only. Fails open: on a lookup error it returns[], so a Supabase blip shows extra noise rather than blanking a real graph.partition_test_artifacts(rows)— pure; given those rows, returns the test-artifact ids.TEST_ASSET_PREFIXES/REAL_ASSET_CYPHER— the same idea for assets. Prefixes:QA-TEST-(a manual QA sweep'sQA-TEST-155635, matched 1 of 11 live assets and 0 of the 10 golden ones) plus the integration suite'sASSET-TEST-,ASSET-DEDUP-,ASSET-EV-,ASSET-ACK-,ASSET-FRESH-. The guard is a Cypher fragment overa, placed inside the asset-list, compliance (gaps, dashboard, audit pack) and coverage queries, because filtering a paginated or aggregated result in Python breakstotal.GET /assets/reports what it hid asexcluded_test_assets. The list is deliberately separate fromscripts/purge_test_data.py's: that script deletes, this one only hides, and sharing a list would let a display tweak arm a cloud delete.
Two rules that matter:
- An id absent from
documentsis never an artifact.PROMOTED-<uuid>ids are minted by quarantine promotion for field knowledge that never had a vault document, so "cannot classify" must mean "keep". - Callers report what they excluded.
GET /assets/{id}/knowledgereturnsexcluded_test_documents;run_kg_completeness.pyprints its count. A filter that hides its own effect is how the linkage figure stayed wrong for as long as it did.
Consumed by routers/assets.py and benchmark/run_kg_completeness.py — the harness imports it
rather than keeping a second copy, since benchmark/ already depends on api/.
Covered by tests/test_corpus_filter.py, which pins every real corpus file name against
over-matching.
shared_client (services/http.py)
Pooled outbound HTTP client for model-provider calls, replacing a per-call
httpx.AsyncClient in llm.py, ner.py, ocr.py and pid.py — each of which meant a fresh
TCP + TLS handshake per request, with no keep-alive and socket churn under concurrency.
shared_client(default_timeout) -> AsyncClient— cached per event loop, not globally. AnAsyncClientbinds to the loop that created it, and this code runs under several: the FastAPI loop and a freshasyncio.run()loop per Celery task. A single global client would be handed to a dead loop and raise. Limits: 32 connections, 16 keep-alive, 30 s expiry.close_shared_client()— awaited from the FastAPI lifespan shutdown hook.
Always pass an explicit
timeout=per request. The cached client keeps the first caller's default, so a 30 s embedding call would otherwise cap a long NIM synthesis.
Covered by tests/test_http_pool.py, including the loop-rebinding case.
metrics (services/metrics.py)
OTEL custom metric instruments. All no-ops when MeterProvider is not configured — which means
every process that records one must call setup_telemetry(), not just the API.
briefs.delivered and governor.suppressed are recorded inside Celery tasks (brief_engine,
event_bus). The worker never configured telemetry, so both were permanent no-ops and could not
reach Grafana under any amount of real traffic (confirmed 2026-08-15 by delivering a real brief and
finding kairos_briefs_delivered_total absent). workers/celery_app.py now calls
setup_telemetry() on worker_process_init — per forked child, since an exporter's background
thread does not survive a fork.
Exported names are the OTLP → Prometheus normalisation of the dotted instrument names, e.g.
kairos.briefs.delivered → kairos_briefs_delivered_total,
kairos.ingestion.duration → kairos_ingestion_duration_seconds_{bucket,count,sum}.
| Instrument | Type | Labels |
|---|---|---|
kairos.briefs.delivered | Counter | priority, trigger_event_type |
kairos.governor.suppressed | Counter | user_id |
kairos.ingestion.duration | Histogram (seconds) | document_type |
kairos.conflicts.open | UpDownCounter | track |
7. Temporal Workflows and Activities
DocumentIngestionWorkflow (workflows/document_pipeline.py)
Task queue: kairos-ingestion. Triggered by POST /documents/ingest.
Seven sequential activities. store_in_vault raises a non-retryable ApplicationError(type="BadFile") for a missing registry row or a SHA-256 mismatch (non_retryable_error_types=["BadFile"]: a wrong file stays wrong, and retrying it five times only re-crashes the single ingestion worker). link_to_graph resolves aliases from confirmed rows only, and its own alias proposals are written ignore_duplicates=True.
| Activity | What it does | Output |
|---|---|---|
store_in_vault | Downloads from Supabase Storage, computes SHA-256, sets pipeline_stage=ocr_pending | vault_path |
run_ocr | PyMuPDF text extraction (fast path for digital PDFs) or NIM OCR | text, confidence |
run_ner | NER on extracted text → named entities | entities[] |
link_to_graph | MERGE Document node in Neo4j, MERGE Asset nodes, circuit breaker pre-flight, create KNOWLEDGE_EDGE for each entity | edge_count |
index_vectors | Jina embed + Qdrant upsert (1024-dim) | point_id |
index_text | Elasticsearch index for full-text retrieval | es_doc_id |
mark_complete | Updates extraction_jobs.pipeline_stage=complete, computes timestamp drift | — |
index_vectors and index_text run in parallel.
Canonical valid_from + ingest lag (in mark_complete): KNOWLEDGE_EDGE.valid_from uses the document's occurred_at whenever it is parseable and not future-dated, falling back to ingested_at. A gap beyond TIMESTAMP_DRIFT_TOLERANCE_MINUTES is recorded as audit_log action=ingest_lag_recorded — an observation only.
Until 2026-08-17 this block called the gap "drift" and overwrote
valid_fromwithingested_atpast the tolerance, which would have rewritten every historical document's true date to its upload time and corrupted the validity windows Layer-4 time-travel depends on. It never fired only becauseoccurred_atis NULL across the corpus. Real drift is the same event seen by two source systems —services/timestamp_alignment.py, surfaced atGET /governance/timestamp-drift— andextraction_jobs.timestamp_drift_detectednow means only that.
vault_path safety: workflow gracefully exits with {"status": "failed", "reason": "missing vault_path"} if vault_path is absent from params (guards against stale workflow history replays).
MicroInterviewWorkflow (workflows/elicitation_workflow.py)
Task queue: kairos-elicitation. Triggered by POST /elicitation/trigger.
- Queries Neo4j for gap-filling questions (failure code + asset context)
- Calls LLM to generate 3–7 targeted questions
- Saves session to
elicitation_sessionswithstatus=questions_ready - Waits for
StoreElicitationResponseWorkflowsignal
StoreElicitationResponseWorkflow
Triggered by POST /elicitation/{work_order_id}/responses. Stores Q&A pairs as a quarantine_items row for human review before graph promotion.
8. Celery Workers
App defined in workers/celery_app.py. Broker and result backend: redis://kairos-redis:6379/1.
Six queues: ingestion, extraction, attribution, transcription, elicitation, validation.
All workers use lazy imports inside the task body (never at module level) to avoid Celery fork-time conflicts.
Brief Assembly Worker (workers/brief_assembly.py)
Task: workers.brief_assembly.assemble_brief(event_type, event_dict) on the ingestion queue.
Supported event_type values and their brief assemblers:
work_order_created→assemble_work_order_briefshift_handover_created→assemble_shift_handover_briefptw_created→assemble_ptw_briefequipment_tag_out→assemble_tag_out_briefinspection_complete→assemble_inspection_briefrecurring_failure_detected→assemble_recurring_failure_brief
Triggered with apply_async(countdown=LATE_ARRIVAL_WINDOW_MINUTES*60) for WO, tag-out, and shift-handover events. PTW and inspection (failed/findings) briefs are immediate.
Redis pending key: kairos:brief_pending:{asset_id}:{work_order_id} — stores the Celery task ID so a re-report of the same work order can revoke and re-enqueue its pending brief. A PTW or another work order on the asset never touches it.
Attribution Worker (workers/attribution.py)
Task: workers.attribution.evaluate_outcome(event_id, asset_id)
Triggered from POST /events/work-order when count(WO for same asset in last 30 days) > 1. Its Go historian call carries X-Connector-Secret (CONNECTOR_SHARED_SECRET).
Three independent checks — all must pass for genuine_failure=True:
| Check | What it does | Passes when |
|---|---|---|
_check_telemetry_baseline | Calls OtCoverageService (in-process, Supabase-only) then Go /ot/query. Brownfield downgrade: with no directly instrumented component, telemetry drops to evidence_role: "supporting" and the work-order closeout attestation becomes primary. Otherwise splits baseline/post halves, 2σ deviation. | abs(post_mean - baseline_mean) > 2σ |
_check_failure_code_match | Queries last 5 WOs for asset. Maps failure codes to families via FAILURE_FAMILIES. | Same family in both current and prior WO |
_check_execution_compliance | Reads close_notes from WO payload. Keyword match against action verbs. | At least one action keyword found |
Failure families in api/utils/failure_families.py: mechanical, seal, electrical, process. If genuine_failure=True: writes attribution_flag to audit_log. No automatic confidence adjustment.
Voice Transcription Worker (workers/voice_transcription.py)
Task: workers.voice_transcription.transcribe_voice_note(work_order_id, storage_path, sha256, submitted_by, filename) on the transcription queue.
- Downloads audio from Supabase Storage
- Sends to Groq
whisper-large-v3for transcription - Runs NER on transcript
- Inserts into
quarantine_itemswithinput_type=voice_noteandsession_context={sha256, vault_path, ...}
Off-Boarding Worker (workers/offboarding.py)
Task: workers.offboarding.generate_offboarding_questions(item_id) on the elicitation queue.
Triggered by POST /elicitation/offboarding — one task per equipment family session item, scheduled at the item's scheduled_for time.
- Fetches session item from
offboarding_session_items - Queries Neo4j for equipment family knowledge gaps
- Calls LLM with offboarding-specific prompt to generate 5–8 questions
- Falls back to 5 generic questions if LLM is unavailable
- Updates item
status=questions_readyand saves questions tooffboarding_session_items.questions
Model Validation Worker (workers/model_validation.py)
Task: workers.model_validation.run_model_gate(model_name) on the validation queue.
Triggered by POST /governance/model-gate/run (admin only). model_name is optional — the endpoint defaults it to NVIDIA_NIM_NER_MODEL so the UI can trigger without knowing the model name. The endpoint only enqueues; the task itself runs ~12 min against a 52-row corpus, so the UI shows a "queued" banner and polls history until the run lands.
The "~2.5 min" this section claimed until 2026-08-23 was measured on a run where almost every NER call failed fast on a 429. Once the calls actually reach the model each costs tens of seconds.
time_limit/soft_time_limitare 1860/1800 for that reason — at 600/540 two consecutive runs were killed mid-flight and wrote no history entry at all, which is worse than recording a degraded one. The limit trackscorpus_size × per-call latency; revisit it if the corpus grows.
- Loads validation corpus from
validation_corpus - Runs NER on each unique document via
FallbackCountingNER, which tallies which path produced each extraction (nim/ollama/regex) - Computes precision, recall, F1 per entity type, per asset class and per document type — all
three partitions share one run-scoped extraction cache, so the extra cuts cost zero model
calls. Ground-truth labels outside the prompt's taxonomy are excluded and reported as
unscoreable_by_typerather than scored as failures - Retrieves the incumbent baseline: the most recent run with
validity: "VALID", filtered in Python — a PostgREST.neq()on a missing JSONB key matches nothing, and legacy rows have novaliditykey at all passed = Truewhen no entity type regressed against that baseline (or there is no eligible baseline). Orthogonal tovalidity, which reports whether the run reached the model- Writes result to
audit_logwithaction=model_gate_result
Graph query policy (ARCHITECTURE.md §7)
graph.MAX_TRAVERSAL_DEPTHis the single traversal bound, interpolated into the one variable-length pattern rather than written inline per query. Cypher cannot parameterise a variable-length bound (*1..$nis a syntax error), so it is an f-string over an int constant — never over user input.tests/test_graph_query_policy.pyfails if an unbounded*ever ships.- Authority pre-filtering "before traversal" has nothing to apply to here. The Layer 4 hot path
is a 1-hop expand, where filter-after-expand is the plan:
PROFILEshowsNodeUniqueIndexSeek→Expand(All)→Filter. The requirement bites on multi-hop queries, and none exists. make graph-perf(scripts/verify_graph_perf.py) is the §7 regression check. It asserts plan shape, not dbHits: thresholds move with the corpus, so they would either be loosened until meaningless or go red on ordinary growth. The regression it exists to catch already happened once —asset_id_uniquewent missing and the hot path silently became aNodeByLabelScan, returning correct rows and failing nothing.- Hot-asset Redis precompute is deliberately not built. A precomputed view that goes stale
after a
KNOWLEDGE_EDGEwrite is the silent-staleness failure mode the architecture calls its most dangerous. Build it when aPROFILEon a real corpus justifies it, with explicit invalidation on every edge write.
9. Go OT Connector
Service: kairos-backend-go at http://kairos-backend-go:8090.
Source: backend/connectors/cmd/connector/main.go; auth and boot validation in auth.go.
Authentication. Every route except GET /health is behind requireSecret: the caller must send X-Connector-Secret equal to CONNECTOR_SHARED_SECRET (both sides SHA-256 hashed, then constant-time compared; otherwise 401). validateConfig runs at start and the process exits when CONNECTOR_SHARED_SECRET is unset, and, unless APP_ENV is exactly development, when it or INTERNAL_API_KEY is still the dev default. Callers: the FastAPI /health/connectors proxy and workers/attribution.py read CONNECTOR_SHARED_SECRET from their environment (docker-compose passes the same value to all three). Hardening: request bodies capped at 1 MiB (413), upstream replies read through a 4 MiB limit, a shared http.Client with a 15 s timeout, ReadHeaderTimeout 10 s, upstream and fixture failures logged and answered with generic text, and the PI Web API URL never logged (it may carry credentials). internal/ot/client.go URL-escapes the tag, web id and times, and strips the request URL from transport errors. In the dev override the port binds 127.0.0.1 only.
Endpoints
| Method | Path | What it does |
|---|---|---|
GET | /health | Liveness probe |
GET | /ot/query | Query historian. Uses PIWebAPIClient if PI_WEBAPI_BASE_URL set, else MockHistorianClient (50 sine-wave vibration points, mean≈1.8 mm/s). |
GET | /ot/connectors | Connector registry — every supported historian with config state and the env var that activates it. Replaces /ot/coverage/:asset_id, deleted 2026-08-16 because it returned hardcoded {asset}-VIBE/{asset}-TEMP/75% for every asset. Coverage now derives from verified topology at GET /assets/{id}/ot-coverage. |
POST | /eam/sync | Reads fixtures/sample_assets.json if EAM_ODS_ENDPOINT not set. POSTs each asset to FastAPI POST /assets using INTERNAL_API_KEY. |
POST | /eam/work-order | Proxies incoming JSON body to FastAPI POST /events/work-order. |
External systems — mock by design. The OT historian (PI Web API) and EAM sync (SAP/Maximo) integrate with plant enterprise systems Kairos does not own. With no live plant to connect to, they run on mock/fixture data by design — the intended state, not a gap. The real paths exist (
PIWebAPIClientbuilt; SAP ODS + OPC-UA are stubs) and would activate viaPI_WEBAPI_BASE_URL/EAM_ODS_ENDPOINTif a plant were ever connected. Completion status for every layer:docs/implementation/status.md.
PI Web API Client (internal/ot/client.go)
When PI_WEBAPI_BASE_URL is configured:
GET {baseURL}/search?q={tag}→ resolvesWebID(tag URL-escaped)GET {baseURL}/streams/{webId}/recorded?startTime={from}&endTime={to}→ time series (each value escaped; response bodies capped at 4 MiB)
Basic Auth via PI_WEBAPI_USERNAME / PI_WEBAPI_PASSWORD.
10. Database Schemas
Supabase PostgreSQL (single source of truth: db/schema.sql)
All 16 historical migrations (001–016) are folded into db/schema.sql — apply that one file to a fresh database. The live applied history is tracked by Supabase itself in supabase_migrations.schema_migrations. The tables:
| Table | Purpose | Key Columns |
|---|---|---|
assets | MDM backbone — mirrors Neo4j for relational queries | asset_id (PK), criticality, eam_source, identity_confirmed_by |
asset_alias_map | Tag alias resolution | canonical_asset_id, alias, confidence, confirmed |
documents | Immutable vault registry | document_id, sha256_hash (UNIQUE), authority_level, vault_url, status, occurred_at (TIMESTAMPTZ for timestamp drift) |
document_asset_links | Document↔Asset many-to-many | document_id, asset_id |
extraction_jobs | Pipeline stage tracking | job_id, document_id, pipeline_stage, ocr_confidence, entity_count, timestamp_drift_detected (BOOLEAN) |
operational_events | Event log (WO, PTW, alarm, handover, tag-out, inspection, deviation-flag) | event_id, event_type, event_subtype, asset_id, payload (JSONB), redis_stream_id, compound_event_id (UUID) |
briefs | Delivered operator briefs | brief_id, recipient_user_id, priority, headline, body, sources (JSONB), requires_countersignature, delivery_frozen (BOOLEAN) |
brief_feedback | Operator feedback on brief accuracy | brief_id, rating (accurate|missing_context|incorrect), notes, submitted_by |
knowledge_conflicts | Dual-track governance conflicts | conflict_id, track, parameter, source_a/b, severity, status, sla_deadline, escalated_at, escalated_to |
quarantine_items | Unverified facts pending human review | item_id, asset_id, review_status, input_type, session_context (JSONB), sla_due_at (DEFAULT NOW()+5d), escalated_at |
moc_items | Management of Change records | moc_id, conflict_id, asset_id, description, status, approved_by, approved_at |
audit_log | Immutable audit trail | action, entity_type, entity_id, performed_by, details (JSONB), timestamp (NOT created_at) |
elicitation_sessions | Micro-interview Q&A sessions | session_id, work_order_id, questions (JSONB), status |
ner_annotations | Human NER correction annotations | annotation_id, document_id, entity_type, original_value, corrected_value, is_correct, submitted_by |
extraction_overrides | Circuit breaker override log (SPC z-score per asset class) | id, asset_class, document_id, override_type (CHECK: manual_correction|quarantine_rejection|annotation_correction), created_at |
plant_operating_states | Site plant state history | id, site_id, state (CHECK: normal|turnaround|shutdown|emergency), set_by, set_at, expires_at |
offboarding_sessions | Off-boarding interview programme headers | id, personnel_id, personnel_email, retirement_date, total_sessions, session_interval_days, status, created_by |
offboarding_session_items | Individual session items per equipment family | id, session_id, session_number, equipment_family, status, questions (JSONB), scheduled_for, completed_at |
validation_corpus | Ground-truth NER samples for model gate | id, entity_type, text, expected_entities (JSONB), authority, created_at |
quarantine_items.input_type CHECK constraint allowed values (migration 013):
field_observation, voice_note, elicitation_response, deviation_flag, offboarding_response
audit_log uses timestamp column, not created_at.
RLS policies (migration 004):
briefs— service-role key bypasses; user JWTs see only own rowsquarantine_items— read-only for field_worker role
Neo4j Graph Schema (db/neo4j/init_schema.cypher)
Node labels: Asset, Document, Event, Person, Concept, Organisation
Relationship types:
KNOWLEDGE_EDGE— primary knowledge relationship, carries all 6 temporal propertiesPARENT_OF— asset hierarchy (parent → child)LINKED_TO— document↔asset linkMENTIONS— event references assetRESOLVED_BY— conflict resolution linkINSPECTION_RECORD— inspection result linked to asset
Required KNOWLEDGE_EDGE properties (all 6, every write):
| Property | Type | Semantics |
|---|---|---|
valid_from | ISO8601 datetime | When this fact became valid |
valid_to | ISO8601 datetime or null | When it was superseded (null = current) |
authority_level | int 1–5 | 1=Regulatory, 2=Engineering, 3=OEM, 4=Procedure, 5=Field |
document_id | string | Source document in the vault |
confidence | float 0–1 | NER extraction confidence |
verification_status | string | verified, unverified, disputed |
11. Configuration Reference
All settings in api/config.py via pydantic-settings. Source: .env file.
App
| Key | Default | Description |
|---|---|---|
APP_ENV | development | Trimmed and lower-cased. development is the only value that enables any dev convenience (Settings.is_development): the unauthenticated mock user, the OPA pass-through, no rate limit, the default admin key. production, staging and a typo all count as non-development, so an unrecognised value fails closed |
APP_DEBUG | True | Enables dev auth bypass (no token required) only together with APP_ENV=development (dev_bypass_allowed). Must be false outside development |
APP_VERSION | 0.1.0 | Included in health response |
APP_SECRET_KEY | CHANGE_ME_IN_PRODUCTION | Change in prod |
CORS_ORIGINS | ["http://localhost:3000","http://localhost:8000"] | Allowed origins |
INTERNAL_API_KEY | <dev-internal-key> | Service-to-service auth token |
DEMO_LLM_ACTIONS_PER_HOUR | 120 | Hourly ceiling, shared by every demo login, on the demo role's model-backed actions (Copilot, RCA, ingest, event briefs, voice). A real account is never counted; a Redis outage fails open |
SHOWCASE_AUTO_REDATE | false | When true the API shifts the showcase plant's dates forward about once a day (scripts/showcase/redate.py); time columns of the loader's own rows only |
SHOWCASE_VISIBLE_TO_ALL | true | Every login reads the showcase plant as well as the real one, and a site-pinned role reads its own site plus the showcase sites (never another real site, and a write never widens). False: only the demo role sees it and real accounts see real data alone. The demo role writes only to showcase rows either way (services/tenant.py) |
MOC_WEBHOOK_SECRET | None | HMAC secret for POST /governance/moc/webhook. Unset is accepted only in development; boot refuses without it outside development |
RATE_LIMIT_PER_MINUTE | 120 | Per-IP cap, enforced whenever APP_ENV is not development (0 = off) |
MAX_UPLOAD_MB | 25 | Reject document and voice-note uploads larger than this (Caddy caps the raw body at 30 MB) |
KAIROS_SEED_PASSWORD_ADMIN, _ENGINEER, _FIELD_WORKER, _RELIABILITY, _COMPLIANCE | blank | Read by scripts/seed_users.py, tests/conftest.py and tools/e2e_flows.sh from .env (not Settings); the seeder refuses to run if any is blank |
Boot guard (_no_insecure_defaults_in_prod). Outside development the API refuses to start while INTERNAL_API_KEY or APP_SECRET_KEY is a dev default, the Supabase service-role key or JWT secret is empty, MOC_WEBHOOK_SECRET is unset, or APP_DEBUG is true. The Go connector applies the same idea to CONNECTOR_SHARED_SECRET.
Supabase
| Key | Description |
|---|---|
SUPABASE_URL | Project URL |
SUPABASE_ANON_KEY | Anon key (used for auth verification only) |
SUPABASE_SERVICE_ROLE_KEY | Service role key (bypasses RLS — backend only) |
SUPABASE_JWT_SECRET | JWT secret for ES256 token verification |
SUPABASE_STORAGE_BUCKET | kairos-vault |
Critical: Never use the service-role key for
auth.sign_in_with_password()orauth.get_user(). Always use a freshcreate_client(url, ANON_KEY)for auth operations.
Neo4j
| Key | Default |
|---|---|
NEO4J_URI | bolt://localhost:7687 |
NEO4J_USERNAME | neo4j |
NEO4J_PASSWORD | <dev-neo4j-password> |
NEO4J_DATABASE | neo4j |
Qdrant
| Key | Default |
|---|---|
QDRANT_URL | http://localhost:6333 |
QDRANT_COLLECTION_KNOWLEDGE | kairos_knowledge |
QDRANT_COLLECTION_DOCUMENTS | kairos_documents |
Elasticsearch
| Key | Default |
|---|---|
ELASTICSEARCH_URL | http://localhost:9200 |
ELASTICSEARCH_INDEX_ASSETS | kairos_assets |
ELASTICSEARCH_INDEX_DOCUMENTS | kairos_documents |
ELASTICSEARCH_INDEX_EVENTS | kairos_events |
Redis
| Key | Default |
|---|---|
REDIS_URL | redis://localhost:6379 |
REDIS_DB_CACHE | 0 |
REDIS_DB_CELERY | 1 |
REDIS_DB_STREAMS | 2 |
REDIS_STREAM_BRIEFS | kairos:events:briefs |
REDIS_STREAM_WORK_ORDERS | kairos:events:work_orders |
REDIS_STREAM_TAG_OUT | kairos:events:tag_out |
REDIS_STREAM_INSPECTIONS | kairos:events:inspections |
Docker note: Inside containers,
localhostis not the Redis host.docker-compose.ymlexplicitly setsREDIS_URL=redis://kairos-redis:6379/0andCELERY_BROKER_URL=redis://kairos-redis:6379/1.
LLM
| Key | Default | Description |
|---|---|---|
NVIDIA_NIM_API_KEY | "" | Required for NIM synthesis, NER, and OCR |
NVIDIA_NIM_MODEL | nvidia/nemotron-3-ultra-550b-a55b | LLM synthesis (sent with chat_template_kwargs.enable_thinking=false, see NVIDIA_NIM_DISABLE_THINKING) |
NVIDIA_NIM_NER_MODEL | meta/llama-3.2-11b-vision-instruct | NER extraction. Was mistralai/ministral-14b-instruct-2512, deprecated by NVIDIA — the endpoint hangs until timeout and NERService degrades silently to its regex fallback (ASSET_TAG only). Verify any replacement responds before switching. |
NVIDIA_NIM_NER_TIMEOUT | 120.0 | NER's own per-attempt cap (3 attempts on a timeout or 5xx). Separate from NVIDIA_NIM_TIMEOUT, which must stay under the 90 s synthesis budget; NER runs in background ingestion, and a miss drops the document to the regex fallback. |
NVIDIA_NIM_OCR_MODEL | nvidia/nemotron-ocr-v2 | OCR for scanned docs/images |
NVIDIA_NIM_VISION_MODEL | meta/llama-3.2-11b-vision-instruct | P&ID drawing → topology JSON (Layer 3, Path B) |
NVIDIA_NIM_MAX_TOKENS | 4096 | Set to 512 to avoid ReadTimeout |
JINA_API_KEY | "" | Required for Jina embeddings |
JINA_EMBED_MODEL | jina-embeddings-v3 | 1024-dim output, primary embeddings |
GROQ_API_KEY | "" | Required for voice transcription |
GROQ_WHISPER_MODEL | whisper-large-v3 | STT via Groq API |
NEBIUS_TOKEN_FACTORY_API_KEY | "" | Tier 1 of the cascade when set, ahead of NIM. Empty ⇒ skipped, which is the shipped default. |
NEBIUS_TOKEN_FACTORY_BASE_URL | https://api.tokenfactory.nebius.com/v1 | OpenAI-compatible endpoint |
NEBIUS_TOKEN_FACTORY_MODEL | nvidia/nemotron-3-super-120b-a12b | The same Nemotron build NIM serves, so a Token Factory answer is not a fallback |
NEBIUS_TOKEN_FACTORY_DISABLE_THINKING | true | Sends Nemotron's thinking-off flag to Token Factory. Its own switch, separate from NIM's, so a 400 there is fixed without touching NIM |
NEBIUS_TOKEN_FACTORY_TIMEOUT | 25.0 | A fallthrough costs this cap plus NIM's, so 25 + 60 = 85 s stays inside the frontend's 90 s synthesis budget. NIM answers in ~1.5 s at p50, so 25 s only cuts off a Token Factory call that is already failing |
OPENROUTER_API_KEY | "" | Tier 3 of the cascade (tier 2 while Token Factory is unkeyed). Empty ⇒ skipped. |
OPENROUTER_BASE_URL | https://openrouter.ai/api/v1 | OpenAI-compatible endpoint |
OPENROUTER_MODEL | meta-llama/llama-3.1-70b-instruct | A different model from tier 1 since NVIDIA retired llama-3.1-70b; an answer from here counts as a fallback in the benchmark verdict. Gemini (tier 3) is a different family and does. |
OPENROUTER_TIMEOUT | 60.0 | Its own cap rather than reusing NVIDIA_NIM_TIMEOUT, so tuning NVIDIA's ceiling cannot silently retime a different vendor. |
GEMINI_API_KEY | "" | Optional LLM fallback, tier 3 (Google OpenAI-compatible). Empty ⇒ disabled. |
GEMINI_BASE_URL | https://generativelanguage.googleapis.com/v1beta/openai | Gemini OpenAI-compatible endpoint |
GEMINI_MODEL | gemini-2.5-flash-lite | Gemini fallback model (15 RPM / 1000 RPD free tier) |
OLLAMA_BASE_URL | "" (empty in .env) | Local fallback LLM/NER/embeddings — disabled: empty URL ⇒ ollama_available is False, so all inference stays cloud-only. Set a URL only to re-enable the local path. |
OLLAMA_MODEL | qwen2.5:14b | Fallback synthesis model (unused while OLLAMA_BASE_URL empty) |
OLLAMA_NER_MODEL | llama3.1:8b | Fallback NER model (unused while OLLAMA_BASE_URL empty) |
OLLAMA_EMBED_MODEL | nomic-embed-text | Fallback embedding model (unused while OLLAMA_BASE_URL empty) |
Temporal
| Key | Default |
|---|---|
TEMPORAL_ADDRESS | localhost:7233 |
TEMPORAL_TASK_QUEUE | kairos-ingestion |
TEMPORAL_TASK_QUEUE_ELICITATION | kairos-elicitation |
EEMUA 191 Governor
| Key | Default | Description |
|---|---|---|
MAX_PUSH_PER_USER_PER_HOUR | 6 | Hard ceiling per operator |
BRIEF_COOLDOWN_HOURS | 4 | Same (recipient, asset, trigger type) cool-down |
DEDUP_WINDOW_MINUTES | 10 | Event dedup window + event correlation window |
LATE_ARRIVAL_WINDOW_MINUTES | 5 | Countdown before delayed brief assembly fires |
PLANT_STATE_DEFAULT | normal | Fallback plant state |
TIMESTAMP_DRIFT_TOLERANCE_MINUTES | 60 | Max drift before flagging. Read by two different checks — the ingestion-pipeline occurred_at vs ingested_at comparison, and the Layer 4 cross-source TimestampAlignmentService. Declared once in config.py (it used to be declared twice, and Pydantic silently kept the last one). The conflict between the two checks is tracked in implementation/status.md § Pending. |
TIMESTAMP_DRIFT_ENFORCE | False | When false, cross-source drift is logged and surfaced but opens no conflict row. Report-only by design — leave off unless deliberately enabled. |
MODEL_GATE_ENFORCE | False | When true, a per-asset-class Layer 0 regression halts extraction for that class via the circuit breaker. Off by default: on a small corpus a class can fail on noise, and an enforcing gate would halt extraction mid-demo. |
KAIROS_PHASE | 3 | Layer 12 deployment phase. 1 = retrieval only (no synthesis), 2 = synthesis on / proactive push off, 3 = everything. Default 3, so behaviour is unchanged unless a deployment deliberately steps back. |
Go Connector
| Key | Default |
|---|---|
GO_CONNECTOR_PORT | 8090 |
PI_WEBAPI_BASE_URL | "" (uses mock when empty) |
PI_WEBAPI_USERNAME | "" |
PI_WEBAPI_PASSWORD | "" |
EAM_ODS_ENDPOINT | "" (uses fixture when empty) |
FASTAPI_URL | http://kairos-backend-api:8000 |
INTERNAL_API_KEY | <dev-internal-key> |
CONNECTOR_SHARED_SECRET | kairos-connector-dev-secret (required; callers send it as X-Connector-Secret; the dev default is refused outside APP_ENV=development) |
EAM_FIXTURE_PATH | /app/fixtures/sample_assets.json |
OpenTelemetry
| Key | Default |
|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT | http://localhost:4317 |
OTEL_SERVICE_NAME | kairos-api |
12. Auth and Authorization
JWT Flow (Supabase Auth)
- Client calls
POST /auth/login→ getsaccess_token(ES256 JWT) - Client includes
Authorization: Bearer <access_token>on all requests get_current_usercallssupabase.auth.get_user(token)using a fresh anon client- Role extracted from
user.app_metadata.role(notuser_metadata, which the user can edit, and not the top-levelrolefield) POST /auth/logoutrevokes the session server side (scope="local"); the frontend calls it on sign-out and on forced expiry, best effort
app_metadata is writable only with the service-role key. Users created before this rule keep their role in user_metadata and would all resolve as field_worker with no site, so run scripts/migrate_roles_to_app_metadata.py (dry run, then --apply) before deploying the code that reads app_metadata only. It never overwrites an existing app_metadata key and does not touch user_metadata. services/identity.display_name reads the display name from app_metadata for the same reason (a name on a PTW sign-off must not be user-editable).
Test Users
Created by docker exec kairos-backend-api python scripts/seed_users.py.
Password (env var in .env) | Role | |
|---|---|---|
admin@example.com | KAIROS_SEED_PASSWORD_ADMIN | admin |
engineer@example.com | KAIROS_SEED_PASSWORD_ENGINEER | engineer |
field_worker@example.com | KAIROS_SEED_PASSWORD_FIELD_WORKER | field_worker |
reliability@example.com | KAIROS_SEED_PASSWORD_RELIABILITY | reliability |
compliance@example.com | KAIROS_SEED_PASSWORD_COMPLIANCE | compliance |
demo@example.com | KAIROS_SEED_PASSWORD_DEMO | demo |
All five roles in the OPA policy are loginable. reliability and compliance are the two personas
that actually demonstrate governance — quarantine promotion and read-only audit — so a demo that
skips them skips the point.
Role Permissions (OPA Policy policies/kairos.rego)
| Role | Permissions |
|---|---|
field_worker | read_search, read_briefs, ack_brief |
engineer | All above + ingest_document, ingest_event, read_governance, read_nonconformance, read_compliance, read_audit, read_documents, read_events, resolve_admin_conflict, read_assets, write_assets |
reliability | Engineer's reads and ingests (ingest_document, ingest_event) + promote_quarantine, countersign_brief, resolve_admin_conflict (no ack_brief, no write_assets) |
compliance | read_search, read_compliance, read_audit, read_nonconformance, read_events |
admin | * (all) |
demo | The public one-click demo identity, which works the showcase plant. Every read_* action an admin has, plus synthesize, rca_pack, answer_feedback and the write actions (write_api, ingest_document, ingest_event, write_assets, promote_quarantine, resolve_admin_conflict). Policy is the coarse layer: dependencies.demo_write_fence refuses a demo write on any route not in tenant.DEMO_WRITE_ALLOWED, each of those handlers guards its target to a showcase row (tenant.guard_asset, guard_site, guard_row, guard_work_order, guard_person), and the model-gate run and provider probes stay closed (it satisfies a role gate naming engineer or reliability, never admin only). Model-backed actions share an hourly cap (DEMO_LLM_ACTIONS_PER_HOUR). tests/test_tenant_isolation.py and tests/test_authz_boundary.py pin the registry, the fence and the guards. |
Engineers deliberately cannot
promote_quarantineorcountersign_brief. That is what makes the one-way quarantine gate and the PTW dual signature real: the second signature can never come from the issuing role._sensitive_actionsin the policy lists both, plusresolve_admin_conflict,write_assets,ingest_document,ingest_eventand all sixread_*actions — without that listing the catch-all rule would grant them to every authenticated role.
read_nonconformanceis narrower thanread_governanceon purpose. The compliance auditor's/compliance/nonconformancepage reads/governance/conflicts+/governance/quarantine, so it needs those two children without reaching the model gate, MoC approvals or the circuit breaker. These grants mirror the frontend route table incomponents/use-role.ts; keep the two in step.
Where a role comes from.
dependencies.resolve_tokenreadsrole,site_idandnamefrom the token user'sapp_metadata, which only the service role can write. It never trustsuser_metadata(users can edit that). The role migration (scripts/migrate_roles_to_app_metadata.py) has been applied to every account.
OPA Middleware
OPAMiddleware in api/middleware/opa.py intercepts all POST/PUT/PATCH/DELETE requests and
GET/HEAD on the sensitive read prefixes (/audit-log, /compliance, /governance,
/documents, /events), except /health, /auth, /docs, and the one signed webhook POST /governance/moc/webhook (_SIGNED_WEBHOOKS: authenticated by its own HMAC, and OPA needs a user the plant's MoC system does not have). Maps route prefix to OPA action name; the six event-ingest routes are listed by exact route as ingest_event (not by the /events prefix, so deviation flags, acks and plant-state stay on the generic write action),
calls http://kairos-opa:8181/v1/data/kairos/authz/allow. 403 if denied.
Five things about it are load-bearing, each of which was a live defect:
It reads
request.scope["path"], neverrequest.url.path, which is rebuilt from theHostheader (a craftedHostmade a gated route look like/health/..., which the skip list exempts).It fails closed.
_ask_opareturnsself.debugwhen OPA is unreachable, never a bareTrue.It does not verify tokens itself — it calls
dependencies.resolve_token, the app's single verifier. Supabase issues ES256 here, so the old hand-rolled HS256 decode rejected every real token and the middleware degraded to the dev pass-through.OPA_URLmust be the compose service name.localhost:8181inside the container is the API, not OPA; combined with fail-open that made the policy decorative.OPTIONSis never gated. This middleware is outermost, so it sees the CORS preflight — which carries noAuthorizationheader — beforeCORSMiddlewaredoes.
/events/plant-state is exempt from read_events: the app shell renders plant state for every
persona, and a field worker who cannot see that the plant is in shutdown is a safety regression.
Verify with tools/verify_authz_policy.sh (39-case decision matrix against a throwaway OPA) —
and separately confirm the layer is reached, by probing the live API with a restricted persona's
token and checking for a 403.
Rate-limit Middleware
RateLimitMiddleware in api/middleware/ratelimit.py (outermost) caps requests per client IP using a Redis fixed-window counter (RATE_LIMIT_PER_MINUTE, default 120). Enforced whenever APP_ENV is not development (0 = off in development so bursts never trip it). Fails open if Redis is unreachable; /health* exempt. Trusts the first X-Forwarded-For hop (behind Caddy). Pairs with the MAX_UPLOAD_MB (25) cap on /documents/ingest — both are public-exposure abuse guards.
Request hardening (2026-10-01 security pass)
Behaviour that is easy to regress, each pinned by a tests/test_sec_*.py case. Detail and status per finding: implementation/status.md § Accepted risks and deploy checklist.
- Documents (
routers/documents.py):DOCUMENT_TYPES(7 values),ALLOWED_MIME_TYPES(anything else is stored asapplication/octet-stream),safe_filenamefor the storage path,parse_occurred_at(not future, not older than 30 years), andAUTHORITY_ASSERT_ROLES = {admin, reliability}: any other role asking for authority 1 to 3 is capped to 4 and the response reportsauthority_capped. Superseding a document of authority 1 to 3 is limited toSUPERSEDE_GATED_ROLESand needs an approved MoC (deterministic idsupersede_moc_id); the first request returns202 pending_moc_approvaland a repeat after approval applies it. Signed artifact URLs carrydownload=so a stored file is never rendered in the storage origin. - MoC webhook (
routers/governance.py):verify_moc_webhookchecks HMAC-SHA256 over"{X-Webhook-Timestamp}." + raw bodyinX-Webhook-Signature, with a 5-minute skew window (MOC_WEBHOOK_MAX_SKEW_SECONDS), fail-closed when no secret is configured outside development, and a replay of an already approved MoC is409. - Briefs: an acknowledgement is final (
409on a second one); a PTW brief is readable by any staff role; the inbox filter uses.in_()over_brief_recipients, never a string built from thesite_idclaim. - Annotations: only
reliabilityandadminfeedvalidation_corpus; 60 corrections per user per hour; repeat corrections on one document count once toward the circuit breaker. - Elicitation: the submitter is always the token's user; off-boarding programmes are visible to staff and the person concerned only (
_require_staff_or_subject); voice-note storage paths are sanitised (_safe_segment) and read in 1 MB chunks againstMAX_UPLOAD_MB. - Site scope: asset routes use
scoped_asset(one404for missing or other-site), events, the RCA pack, the audit pack and the coverage and pending-alias lists filter bysite_scope. - Dependencies (
requirements.txt):fastapi==0.135.1withstarlette>=1.3.1(fastapi 0.111.1 capped starlette at 0.37.x, which carries a BadHost advisory and multipart DoS advisories; 0.135.1 is the newest that still accepts the pinned pydantic 2.7.4) andanyio==4.14.2. Removed as unused:python-jose(and itsecdsa),passlib,flower,rich,tenacity,python-dateutil,pytz.numpystays pinned only becauseqdrant-client1.9.1 needs it. The CIpip-auditstep now ignores nothing.
Internal Service Auth
Go connector and service-to-service calls use Authorization: Bearer <INTERNAL_API_KEY>, compared in constant time (hmac.compare_digest). The connector's own inbound routes are separately protected by X-Connector-Secret (§9). resolve_token recognizes the key and returns a service admin account without calling Supabase — which is also what keeps the fail-closed OPA middleware from 401ing every connector and Celery write, since the key is not a JWT.
13. Observability
Pipeline, metrics & dashboards
Moved to
docs/INFRA.md§6–7 — the OTEL trace/metric pipeline, the custom Prometheus metrics table (kairos_briefs_delivered_total,kairos_governor_suppressed_total,kairos_ingestion_duration_seconds,kairos_conflicts_open), and the Grafana dashboards/credentials.
App-level instrumentation: FastAPIInstrumentor, RedisInstrumentor, HTTPXClientInstrumentor are set up
in api/middleware/telemetry.py (setup_telemetry(app)), called from create_app().
Structured Logging
All logs via structlog. Never use print() or stdlib logging. Notable log events:
governor.suppressed— user_id, count, ceilinggovernor.plant_state_suppression— user_id, site_id, plant_statebrief_engine.delivered— brief_id, recipient, priorityattribution.complete— event_id, asset_id, genuine_failure, actioningest.complete— document_id, sha256, job_idevent_bus.compound_event_linked— compound_event_id, event_idsactivity.ingest_lag_recorded— document_id, lag_minutes (observation;valid_fromunaffected)activity.occurred_at_in_future/activity.occurred_at_unparseable— fall back to ingest timerca_pack.generated— asset_id, failure_code, timeline_countdocument_pipeline.missing_vault_path— document_id (graceful early exit)offboarding.programme_created— session_id, personnel_id, sessions
14. Non-Negotiable Rules
These apply to every code change in this codebase. Violations are bugs.
All 6 KNOWLEDGE_EDGE properties on every write:
valid_from,valid_to,authority_level,document_id,confidence,verification_statusVault is permanent. Never delete. Never overwrite. Supersede by closing
valid_to. Supabase Storage objects are immutable.Quarantine is a one-way gate.
confidence < 0.7or unresolved entity →quarantine_items. Human action only to promote. No auto-promotion ever.Asset nodes:
MERGE (a:Asset {asset_id: $id}) SET a += $props— neverCREATE.Authority pre-filter before graph traversal. Always:
WHERE r.authority_level <= $max_level AND r.valid_from <= $as_ofSafety-critical parameter queries — explicit refusal when confidence < 0.7. Never hedge. Return sources directly.
Phase discipline. Phase 2 LLM synthesis lives only in
POST /search/synthesize(and its stream). Never auto-triggered from routers or workers. The evidence and the safety category are produced server-side; a request'scontextandquery_categoryare ignored.EEMUA 191 Governor. Call
EventBusService.check_governor(user_id)before every brief delivery. PTW (priority="critical") always exempt.Secrets via env vars. All config through
api/config.pySettings. Never hardcode.OT data is ephemeral. Query historian data in memory, reason with it, discard. Never store time-series in Kairos.
structlog only. Never
print(), neverimport logging.workflow.loggerin Temporal is a stdlib logger — use f-strings for message formatting, not keyword args.Supabase client hygiene. Never call
auth.sign_in_with_password()orauth.get_user()on the global service-role client. Use a freshcreate_client(url, ANON_KEY).Docker is the only runtime. No
pythonorpip installoutside containers. Hot-reload is active — edits apply immediately. Rebuild only when new pip deps are added.Lazy imports in Celery workers. Import
api.services.*inside the task body, never at module level. Addsys.path.insert(0, "/app")at the top of each worker file.
Dev commands and service URLs moved to
docs/INFRA.md— Section 9.