Kairos — API Reference
For AI coding agents: Every HTTP endpoint is documented here. Base URL:
http://localhost:8000. All endpoints except/health/,/docs,/openapi.json,POST /auth/loginandPOST /auth/refreshrequireAuthorization: Bearer <access_token>.Also read
docs/BACKEND.mdfor architecture, service internals, and non-negotiable rules before modifying any endpoint.
Table of Contents
- Authentication
- Health
- Assets (MDM)
- Documents (Vault + Pipeline)
- Search
- Events (Operational)
- Briefs (Operator Intelligence)
- Governance
- Compliance
- Elicitation
- Annotations
- Audit Log
- Go OT Connector (port 8090)
- Error Codes
- Auth Quick-Reference
1. Authentication
Prefix: /auth
Supabase issues ES256 JWTs. Tokens expire after 1 hour. Role and site are stored in app_metadata (role, site_id), which users cannot edit, not in user_metadata and not the top-level Supabase role. Seed passwords come from KAIROS_SEED_PASSWORD_* in .env (gitignored).
Test credentials (seed via 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 |
The demo account backs the login page's "Explore the live demo" button. It can read everything an admin can read and use the Copilot, but every other write returns 403 (see the role table in BACKEND.md). Its password is public by design (NEXT_PUBLIC_DEMO_PASSWORD), so never reuse it.
Dev mode: When APP_DEBUG=True and APP_ENV == "development" (the only value that enables any dev bypass; production, staging or a typo all count as non-development, and the value is trimmed and lower-cased), any request without an Authorization header is treated as {user_id: "dev-user", role: "engineer", site_id: "SITE_001"}. Both conditions are required — see Settings.dev_bypass_allowed. The same rule governs the OPA pass-through, the per-IP rate limit (RATE_LIMIT_PER_MINUTE, off only in development) and the boot guard that refuses default secrets.
Service bypass: Bearer token matching INTERNAL_API_KEY (default: <dev-internal-key>, compared in constant time) returns a service admin account without calling Supabase. Used by the Go connector and Celery workers.
Token verification has exactly one implementation: dependencies.resolve_token, which delegates to Supabase and is shared by the route dependency and the OPA middleware. Never decode these JWTs by hand — they are ES256, so an HS256 decode silently rejects every one of them.
Authorization — writes and sensitive reads
POST/PUT/PATCH/DELETE are policy-checked, and since 2026-08-17 so are GET/HEAD on the sensitive read prefixes. Roles below mirror the frontend route table in components/use-role.ts.
| Read prefix | OPA action | Roles allowed |
|---|---|---|
/audit-log | read_audit | engineer · reliability · compliance · admin |
/compliance | read_compliance | engineer · reliability · compliance · admin |
/governance/conflicts · /governance/quarantine | read_nonconformance | engineer · reliability · compliance · admin |
/governance (everything else) | read_governance | engineer · reliability · admin |
/documents | read_documents | engineer · reliability · admin |
/events | read_events | engineer · reliability · compliance · admin |
field_worker gets 403 on all six. /search, /briefs, /assets, /elicitation and /annotations reads stay open to every authenticated role. Two deliberate exemptions: OPTIONS is never gated (the CORS preflight carries no token, and this middleware is outermost), and /events/plant-state is exempt from read_events because every persona's app shell renders plant state.
Site scope is derived from the token, not the query string. site_id on GET /assets/ and the two /compliance reads narrows within the caller's own site; requesting another site is a 403 (with SHOWCASE_VISIBLE_TO_ALL a read may also name a showcase site), and an account with no site_id gets nothing rather than everything. admin keeps the cross-site view. The same scoping applies by id: every /assets/{asset_id}/... read and the alias confirm and reject routes return one 404 for an asset that does not exist or sits on another site (the response does not reveal which); GET /assets/coverage and /assets/aliases/pending keep only the caller's site; GET /events/ and GET /events/{event_id} filter on site_id; POST /search/rca-pack and GET /compliance/audit-pack drop other sites' evidence; and event ingest routes refuse a payload whose site_id or asset belongs to another site.
Event ingest needs ingest_event. POST /events/work-order, /ptw, /shift-handover, /alarm, /tag-out and /inspection-complete are listed by route in the OPA action map and granted to engineer, reliability, admin and the internal key (which resolves to admin). field_worker and compliance get 403. They create critical-priority briefs and compliance evidence. Field-worker flows (deviation-flag, {event_id}/ack) and the engineer-only plant-state stay on the generic write action. POST /governance/moc/webhook is the one write exempt from OPA, because it is authenticated by its own HMAC (section 8). OPA is asked about request.scope["path"], never a path rebuilt from the Host header.
POST /auth/login
Exchange email + password for a JWT pair.
Auth required: No
Request body:
{
"email": "admin@example.com",
"password": "<KAIROS_SEED_PASSWORD_ADMIN>"
}Response 200:
{
"access_token": "<ES256 JWT>",
"refresh_token": "<opaque token>",
"token_type": "bearer",
"user_id": "uuid"
}Errors: 401 invalid credentials (the same generic Invalid credentials whether the account exists or not; the upstream reason is logged, never returned), 400 malformed payload
Critical: This handler uses a fresh anon Supabase client (never the service-role client). Using the service-role client for auth operations contaminates its session and causes RLS violations on all subsequent table writes.
POST /auth/refresh
Exchange a refresh token for a new JWT pair.
Auth required: No
Request body:
{ "refresh_token": "<token>" }Response 200: Same shape as /auth/login. A bad refresh token is a 401 with the generic Invalid refresh token.
POST /auth/logout
Revoke the caller's Supabase session server side (scope="local": this device only, so signing out of one browser does not sign the shared demo account out of the others). Deletes the session behind the access token, which also kills its refresh token, and drops the token from the verified-token cache.
Auth required: a Bearer token must be presented (401 Bearer token required without one), but it is not verified: an expired token has nothing left to revoke and the client must still be able to finish signing out. This route is outside the OPA map.
Response 200: { "status": "ok" }. Revocation is best effort; a failure is logged (auth.logout_revoke_failed) and still returns ok.
GET /auth/me
Return the current user's profile decoded from the JWT.
Auth required: Yes (any role)
Response 200:
{
"user_id": "uuid",
"email": "admin@example.com",
"role": "admin",
"site_id": "SITE_001"
}2. Health
Prefix: /health
GET /health/
Liveness probe. Returns 200 if the API process is running.
Auth required: No
Response 200:
{
"status": "healthy",
"service": "kairos-api",
"version": "0.1.0",
"timestamp": "2024-01-01T00:00:00Z"
}GET /health/detailed
Readiness probe. Checks all 5 downstream services concurrently.
Auth required: Yes, any signed-in user (CurrentUserDep). It makes five store round-trips per call, so it is not public; /health/ stays open as the cheap liveness probe. /health is outside the OPA map, so the dependency is the gate. /docs and /openapi.json stay public. The deploy workflow therefore checks /health, not /health/detailed.
Response 200:
{
"status": "healthy",
"services": {
"neo4j": "ok",
"qdrant": "ok",
"elasticsearch": "ok",
"redis": "ok",
"temporal": "ok"
},
"timestamp": "2024-01-01T00:00:00Z"
}If any service is unreachable, its value is "error: <message>".
GET /health/model
Opt-in liveness probe for a rate-limited model provider. Makes the smallest possible real call (1 token / 1 embedding / a models list) so the System Health page can show live model status.
Auth required: Yes — admin only. Not polled by default (each call spends provider quota).
Query params: provider — any synthesis tier (tokenfactory | nim | openrouter | gemini, read from the same registry as the cascade, services/model_providers.py) or jina | groq.
Response 200:
{ "provider": "nim", "ok": true, "status": 200, "model": "nvidia/nemotron-3-ultra-550b-a55b", "served_model": "nvidia/nemotron-3-ultra-550b-a55b", "model_mismatch": null, "latency_ms": 2603, "detail": null }For a synthesis tier the probe sends the tier's own provider-specific body keys (Nemotron's thinking-off flag), so a provider that rejects them fails here rather than on every real answer. served_model is the model the provider says it ran; model_mismatch is null when it matches the pin (casing ignored) and a record otherwise. Both are null for Jina and Groq.
ok: false with detail: "not configured" when the provider's API key is unset; detail carries the
error text when the upstream call fails. A known tier with no key is not configured, not an error; 400 only for a name outside the list.
GET /health/connectors
OT historian connector registry (Layer 5) — makes "new connector types are added without changing the core layer" inspectable rather than asserted. Passthrough to the Go connector.
Every supported historian is listed with its configuration state and the env var that activates it. An unconfigured connector reports itself as unconfigured; it never fabricates a reading and never fails silently.
Response 200: {connectors: [{name, protocol, status, config_var, configured, detail}], active_count, serving_historian: {mock, note}}
status is active (configured + implemented) · not_configured (implemented, no endpoint) ·
registered (in the connector layer, client not implemented in this build). 503 if the
connector service is unreachable — not an empty registry, which would read as "no connectors supported".
3. Assets (MDM)
Prefix: /assets
Asset MDM: Neo4j (graph) + Supabase (relational) + Elasticsearch (search). Asset identities are human-confirmed — no AI-inferred canonical IDs are accepted. All writes use MERGE in Neo4j, never CREATE.
Required role for POST /assets/: admin or engineer (OPA enforced)
POST /assets/
Register a new canonical asset.
Auth required: Yes — admin or engineer. A non-admin may only register an asset on their own site (site_scope: another site is 403).
Request body:
{
"asset_id": "P-101",
"tag_number": "P-101",
"name": "Feed Pump Alpha",
"equipment_class": "pump",
"site_id": "SITE_001",
"parent_asset_id": null,
"criticality": "safety_critical",
"design_pressure_bar": 12.5,
"design_temperature_c": 180.0,
"manufacturer": "Flowserve",
"model_number": "PVXD-250",
"eam_source": "SAP_PM",
"eam_asset_id": "10001234",
"confirmed_by_user_id": "admin@example.com",
"additional_properties": {}
}Key fields:
asset_id— canonical identifier (tag number). Must be globally unique.criticality—safety_critical | critical | non_criticalconfirmed_by_user_id— mandatory (AI-inferred identities are rejected), but not trusted: the confirmer recorded inidentity_confirmed_byandaudit_log.performed_byis the authenticated caller. The body value is used only if the session carries no user id. Trusting it let a caller attribute a confirmation to someone else.eam_source—SAP_PM | IBM_Maximo | other
Response 201:
{
"asset_id": "P-101",
"tag_number": "P-101",
"status": "created"
}Errors: 409 Asset '<id>' is already registered. Registration is create-only: the Supabase write is an insert (a unique violation is the 409), and an id that already exists in the graph on a different site is refused before anything is written. Re-posting an id used to upsert the Supabase row while Neo4j kept the old node, leaving the two stores disagreeing.
POST /assets/bulk
Bulk-import assets from an EAM golden record — Layer 1's deterministic bootstrap. POST /assets/
registers one asset at a time; this is the path for loading a plant.
Auth required: Yes — admin or engineer
Request body:
{
"assets": [
{
"asset_id": "P-101",
"tag_number": "P-101",
"name": "Feed Pump Alpha",
"equipment_class": "pump",
"criticality": "safety_critical",
"site_id": "SITE_001",
"facility_id": "FAC_001",
"parent_asset_id": null,
"eam_source": "SAP_PM"
}
]
}- No
confirmed_by_user_idper row. The confirming authority is the caller, taken from the verified token. A per-row field would let one import be attributed to someone else. asset_idmay be omitted — one is generated, and such a row can never collide.- 1–5000 rows. Split larger exports so a single failed request never costs a whole plant.
Response 200 — partial success is the contract, not a fallback. One malformed row in a
500-row export must not cost the other 499, so every row that did not land is reported with its
index in the submitted payload:
{
"submitted": 4,
"created": 2,
"created_asset_ids": ["P-101", "ASSET-7QK2M4XB"],
"already_present": [{"row": 1, "asset_id": "EQ-101"}],
"duplicate_in_payload": [{"row": 2, "asset_id": "P-101"}],
"site_forbidden": [{"row": 3, "asset_id": "EQ-201", "site_id": "SITE_002"}],
"failed": []
}| Bucket | Meaning |
|---|---|
already_present | Asset exists. Skipped, never overwritten — a re-import cannot replace identity_confirmed_by. |
duplicate_in_payload | Same asset_id twice in one file. First wins; usually means the export was joined wrong. |
site_forbidden | Row targets a site the token does not cover. Checked before existence, so the response cannot leak which ids exist in an unreadable site. Admins are cross-site. |
failed | The write itself errored. Row-level, so one bad row is one bad row. |
Re-posting the whole file after fixing rows is safe: creation is idempotent, so rows that already
succeeded return as already_present rather than duplicating.
GET /assets/
List all registered assets.
Auth required: Yes
Query params:
| Param | Type | Default | Description |
|---|---|---|---|
site_id | string | — | Filter by site |
equipment_class | string | — | Filter by class (pump, vessel, …) |
offset | int | 0 | Pagination offset |
limit | int | 50 | Page size (max 500) |
Test assets (QA-TEST-* and the integration suite's ASSET-* ids, see services/corpus.py) are
excluded in the query, so total counts real assets only; the response reports how many were
hidden as excluded_test_assets.
Response 200:
{
"items": [
{
"asset_id": "EQ-101",
"name": "Boiler Feed Pump A",
"equipment_class": "pump",
"criticality": "safety_critical",
"site_id": "SITE_001",
"open_work_orders_count": 5,
"compliance_gap_count": 59
}
],
"total": 5,
"limit": 50,
"offset": 0
}
open_work_orders_count/compliance_gap_countare on every row (added 2026-08-23; the list previously omitted them and onlyGET /assets/{asset_id}carried them, which blocked two columns the design review asked for). Definitions are shared with the detail endpoint —operational_eventsof typework_order_created, andknowledge_conflictswithstatus = "open"— so the list and the detail page can never disagree about the same number.
0means no rows;nullmeans unknown. A failed Supabase lookup returnsnullfor that field on every row of the page, logged asasset.list_counts_failed, and the UI renders it as "—". It was0until 2026-09-22, which made a failed lookup read as "0 compliance gaps".Cost is two queries per page regardless of page size — the counts are fetched in bulk for the page's asset ids and tallied in the router, not queried per asset. Do not "simplify" this into the detail handler's per-asset
count="exact"pair: that is an N+1 costing 100 round trips on a 50-row page. A server-sideGROUP BYwould be better still, but PostgREST aggregates are disabled on this project (PGRST123).
GET /assets/coverage
Knowledge-coverage matrix across all assets — what backs the /overview/coverage heatmap.
Per asset: facts held, how many are authoritative, how many are human-verified, linked documents
and pending quarantine items.
Read-only and model-free — no OCR/NER/embedding call, so it spends no provider quota.
Declared before
/assets/{asset_id}inrouters/assets.pyon purpose: FastAPI matches in declaration order, so the reverse would makecoverageresolve as anasset_id.
Response 200:
{
"items": [
{
"asset_id": "EQ-101",
"fact_count": 5,
"authoritative_count": 2,
"verified_count": 1,
"document_count": 4,
"quarantine_pending": 21
}
],
"total": 10
}GET /assets/{asset_id}
Get a single asset by its canonical ID. Enriched with 3-parallel live counts.
Auth required: Yes
Response 200:
{
"asset_id": "P-101",
"tag_number": "P-101",
"name": "Feed Pump Alpha",
"equipment_class": "pump",
"site_id": "SITE_001",
"criticality": "safety_critical",
"open_work_orders_count": 2,
"compliance_gap_count": 0,
"last_inspection_date": "2024-03-01T00:00:00Z"
}open_work_orders_count, compliance_gap_count, and last_inspection_date are fetched in parallel. Each is null when its lookup fails (never a 0 that reads as a clean record); last_inspection_date is the newest INSPECTION_RECORD edge, else the newest inspection_complete Event node, and null when neither exists. 404 if not found.
GET /assets/{asset_id}/aliases
List all known tag aliases (alternate tag numbers, legacy names) for an asset.
Auth required: Yes
Response 200:
[
{
"alias": "PUMP-101",
"confidence": 0.95,
"confirmed": true,
"source": "P&ID Rev C"
}
]Aliases proposed by the extraction pipeline arrive confirmed: false. Only confirmed aliases
resolve a tag to its canonical asset, so an unconfirmed candidate is inert until a human signs it
off with the endpoint below.
POST /assets/{asset_id}/aliases/{alias}/confirm
Confirm a tag alias the NER pipeline proposed, making it usable for tag resolution. This is the human half of Layer 1's rule that AI-assisted linking is permitted only after human confirmation.
Auth required: Yes (engineer or admin)
Response 200:
{
"status": "confirmed",
"alias": "PUMP-101",
"canonical_asset_id": "P-101",
"confirmed_by": "user-uuid"
}Idempotent — re-confirming returns {"status": "already_confirmed", ...} rather than an error.
404 if no such alias was proposed for that asset. Writes an asset_alias_confirmed audit row.
GET /assets/{asset_id}/hierarchy
Get the parent–child asset hierarchy (up to 10 levels deep via Neo4j PARENT_OF traversal).
Auth required: Yes
Response 200:
{
"asset_id": "P-101",
"parent": null,
"children": [
{ "asset_id": "P-101-SEAL", "name": "Mechanical Seal" }
]
}| POST | /documents/{document_id}/extract-form | Read a form or checklist's fields (PDF table cells and label: value lines) into quarantine items, for one document, when a person asks. engineer, reliability, admin (and demo on a showcase document). Returns {document_id, fields, status} with status quarantined, no_fields or no_text. 409 when that document's form fields are already in the queue, 404 for an unknown document. Deterministic, no model call; writes an audit row (form_fields_extracted). Nothing reaches the graph: promotion stays a human decision in the quarantine queue. |
| GET | /assets/cross-site-patterns | Failure families that repeat on one equipment class across sites, from work_order_created events in the last 180 days. A pattern needs 2 work orders in one family at one site and the class on 2 or more sites; kind is shared (seen at 2 or more sites) or advisory (one site has it, a sister site runs the class). Counts, failure families, equipment classes and site ids only, never text or people. engineer, reliability, admin (and demo); a site-pinned caller sees its own site plus the showcase sites while they are visible, so with one site the list is empty. Returns patterns, total, sites, window_days, min_events, work_orders_considered, truncated (true at the 1000-row page). Read-only and model-free. |
| GET | /assets/coverage | Knowledge-coverage matrix across all assets — facts, authoritative facts, verified facts, linked documents, pending quarantine. Read-only and model-free (spends no provider quota). Declared above /{asset_id} so the literal path is not swallowed by the path parameter. |
GET /assets/{asset_id}/graph
The asset and its surroundings, two hops out, as nodes and edges for the graph page. Accepts a canonical id or an alias, like /knowledge.
Auth required: Yes. Query: as_of (ISO8601, default now) limits the documents to those valid at that moment.
What it returns, each kind capped so a hub asset cannot return hundreds of nodes: the unit above the asset and the instruments below it (PARENT_OF), up to 12 documents (best authority, newest first), the people and organisations those documents mention (the 8 mentioned by the most of them), the other assets that the same documents cover (up to 8, joining the documents into a network), and the latest 6 events (OCCURRED_ON). Edges are marked structural: true for the hierarchy and events, which are not knowledge facts and carry no authority or validity window. Test-artifact documents are left out and counted in excluded_test_documents.
{ "asset_id": "DEMO-P-1101A", "as_of": "now", "excluded_test_documents": 0,
"nodes": [{ "id": "DOC-WQJHXNN63FD4", "kind": "Document", "label": "HFS-SB-2026-05-minimum-flow", "properties": {} }],
"edges": [{ "id": "doc-DOC-WQJHXNN63FD4", "source": "DEMO-P-1101A", "target": "DOC-WQJHXNN63FD4", "label": "DOCUMENTED_BY",
"structural": false, "authority_level": 3, "verification_status": "unverified",
"valid_from": "2026-09-01T00:00:00", "valid_to": "9999-12-31T23:59:59", "document_id": "DOC-WQJHXNN63FD4", "confidence": 0.95 }] }GET /assets/{asset_id}/knowledge
Get all temporal graph facts linked to this asset from Neo4j. Accepts a canonical id or a confirmed tag alias — P-101 resolves to EQ-101 via asset_alias_map (resolve_canonical_asset_id); the response echoes requested_id and resolved_from_alias. Facts are deduped by edge_id (the graph can hold physical duplicate relationships).
Auth required: Yes
Facts sourced from test/sweep artifacts are excluded by default and counted in
excluded_test_documents. The classifier is the vault file_name (services/corpus.py), which is
a Supabase column — the Neo4j node carries only document_id — so this cannot be a Cypher filter.
A document_id with no vault row (e.g. PROMOTED-<uuid>) is kept: unclassifiable is not
disposable.
Auth required: Yes
Query params:
| Param | Type | Default | Description |
|---|---|---|---|
as_of | ISO8601 datetime | now | Time-travel: return facts valid at this timestamp |
include_test_data | bool | false | Include facts whose source document is a test artifact |
Response 200:
{
"asset_id": "EQ-101",
"requested_id": "P-101",
"resolved_from_alias": true,
"as_of": "now",
"fact_count": 7,
"excluded_test_documents": 53,
"facts": [
{
"edge": {
"edge_id": "EQ-101_DOCUMENTED_BY_DOC-FYBIYN95D7JH_2026-08-22T06:51:22Z",
"relationship_type": "DOCUMENTED_BY",
"authority_level": 3,
"document_id": "DOC-FYBIYN95D7JH",
"confidence": 0.95,
"valid_from": "2026-08-22T06:51:22Z",
"valid_to": "9999-12-31T23:59:59Z",
"verification_status": "unverified"
},
"target": {
"document_id": "DOC-FYBIYN95D7JH",
"document_type": "oem_manual",
"authority_level": 3
}
}
]
}Each fact is
{edge, target}— the six mandatory edge properties plus the node the edge points at. There is novalueon an edge; seeGET /governance/conflictsfor what that costs.
GET /assets/{asset_id}/ot-coverage
Instrumentation coverage map (Layer 5) — which components on this asset are actually monitored.
Derived from engineer-verified P&ID topology only: verified instrumentation_loops[].instruments[]
are the sensor tags. Layer 10 uses this to decide whether a repair can be judged by telemetry or
needs human closeout attestation.
Replaces a Go handler that returned hardcoded
{asset}-VIBE/{asset}-TEMP/75%for every asset on both branches, including the one labelledsource: "knowledge_graph". That route is deleted.
Response 200:
{
"asset_id": "V-247",
"coverage_type": "direct | macro | none",
"has_direct_sensors": true,
"sensor_tags": ["FT-3047", "FV-3047"],
"verified_loops": 1, "total_loops": 1,
"derived_from": "verified_pid_topology",
"source_documents": ["DOC-..."],
"unverified_topology_present": false
}coverage_type: "none" means no verified drawing establishes instrumentation — not a claim that
the equipment has no sensors. unverified_topology_present distinguishes review backlog from
genuine absence.
4. Documents (Vault + Pipeline)
Prefix: /documents
Documents are immutable. Once ingested, they can only be superseded (version chained), never deleted or overwritten. SHA-256 deduplication prevents duplicate ingestion.
POST /documents/ingest
Ingest a document into the vault and trigger the full pipeline: OCR → NER → graph linking → vector indexing → text indexing.
Auth required: Yes (engineer, reliability or admin; OPA action ingest_document)
Request: multipart/form-data
| Field | Type | Required | Description |
|---|---|---|---|
file | binary | Yes | Max MAX_UPLOAD_MB (25 MB); Caddy refuses anything over 30 MB first |
document_type | string | Yes | One of oem_manual, procedure, inspection_report, ptw, shift_log, regulation, pid_drawing (7 values, DOCUMENT_TYPES). Anything else is 422: the value becomes a storage path segment and feeds brief and compliance matching |
authority_level | int 1–5 | No (default 4) | 1=Regulatory, 2=Engineering, 3=OEM, 4=Procedure, 5=Field. Levels 1 to 3 may only be asserted by reliability or admin; for any other role the level is capped to 4, never rejected (see below) |
asset_id | string | No | Canonical asset id to link the document to; an unknown id is 422 |
source_system | string | No | Default manual_upload, e.g. SAP_DMS, SharePoint |
occurred_at | ISO8601 string | No | Source document date; sets valid_from and feeds timestamp drift detection. More than a day in the future, or more than 30 years in the past, is 422 |
Content type. The stored mime_type is the upload's content type only if it is a format the pipeline can read (text/plain, text/markdown, text/csv, application/pdf, PNG, JPEG, TIFF, WebP, the .xlsx, .xls and .ods spreadsheet types, message/rfc822, application/mbox, text/rfc822-headers). Anything else, notably text/html and image/svg+xml, is stored as application/octet-stream, because the stored type is served back through signed URLs. The storage path is built from vetted parts only ({document_type}/{document_id}/{safe_filename}: directory parts dropped, [A-Za-z0-9._-] kept, never dot-led); the original name is kept as file_name.
Response 202:
{
"status": "accepted",
"document_id": "DOC-ABC123DEF456",
"job_id": "job-uuid",
"sha256": "…",
"vault_path": "procedure/DOC-ABC123DEF456/sop.pdf",
"authority_level": 4,
"authority_requested": 2,
"authority_capped": true,
"workflow": "triggered",
"message": "Document queued for extraction. Poll /documents/DOC-ABC123DEF456/status for progress."
}authority_capped is true when the stored level differs from the one requested. The audit_log document_ingested row records authority_level (stored), authority_requested, authority_asserted_by (the uploader) and uploader_role, so a capped upload is disclosed rather than silent. workflow is triggered, or workflow_pending when Temporal was down (vault row and job are committed and the workflow can be re-triggered). Storage and registration failures return a fixed message (Vault storage upload failed.), not the upstream exception text.
SHA-256 deduplication: if an identical file was previously ingested, returns the existing document_id with status: "duplicate".
GET /documents/
List vault documents.
Auth required: Yes
Query params:
| Param | Type | Default |
|---|---|---|
asset_id | string | — |
document_type | string | — |
status | string | active |
authority_level | int | — |
skip | int | 0 |
limit | int | 50 |
Response 200:
{
"items": [...],
"total": 12
}Each item includes asset_links: ["EQ-101", ...] — the asset ids the document is linked to (batch-joined
from document_asset_links, one query, no N+1). Consumers such as the projects portfolio classify documents
by their linked assets' equipment class; without this every doc would read as "Unclassified".
GET /documents/{document_id}/artifact-url
Get a short-lived Supabase signed URL to open the raw vault artifact in a browser.
Auth required: Yes
The stored vault_url points at Supabase's /object/authenticated/ endpoint, which requires an
Authorization header a plain link/window.open cannot send (Supabase returns 400 headers must have required property 'authorization'). This endpoint mints a signed URL instead — the token rides in the query
string, so it opens without a header. The frontend "Open artifact" button fetches this (authenticated) then
window.opens the result.
Response 200:
{
"signed_url": "https://…supabase.co/storage/v1/object/sign/kairos-vault/<path>?token=…",
"expires_in": 3600
}The signed URL carries download=<safe filename>, so the storage server answers with Content-Disposition: attachment and an artifact is never rendered inside the storage origin, whatever content type it was uploaded with.
Errors: 404 document not found · 422 artifact path unavailable · 502 signing failed
GET /documents/{document_id}/status
Poll the extraction pipeline status for a document.
Auth required: Yes
Response 200:
{
"document_id": "doc-uuid",
"pipeline_stage": "complete",
"progress_percent": 100,
"ocr_confidence": 0.903,
"ner_entity_count": 12,
"graph_edges_created": 8,
"review_items_pending": 2,
"error": null,
"updated_at": "2024-01-01T00:00:00Z"
}Pipeline stages (in order): pending → ocr_pending → ocr_complete → ner_pending → ner_complete → graph_pending → graph_complete → index_pending → complete (or failed).
ocr_confidence is the model's own per-span confidence weighted by span length — not a constant. It was hardcoded to 0.95 for every OCR extraction until 2026-08-23. Native-text paths (digital PDF, plain text, spreadsheet, email) report 1.0 because nothing was read off an image. A value below 0.5 sets pipeline_stage: review_required; the < 0.7 quarantine rule applies downstream.
GET /documents/{document_id}/extraction
What extraction wrote for this document. Nothing is re-extracted: entities are read back from the
graph edges carrying this document_id (the endpoint that is not the document — an Asset for
DOCUMENTED_BY, a Person for MENTIONS_PERSON, …), one per value at its strongest confidence.
review_items are the quarantine_items whose session_context.document_id names this document —
the low-confidence entities that never reached the graph. A graph outage degrades to no entities.
Auth required: Yes
Response 200:
{
"document_id": "DOC-JTJELSCENM6M",
"extraction_model": "meta/llama-3.2-11b-vision-instruct + nvidia/nemotron-ocr-v2",
"entities": [
{
"entity_type": "asset_tag",
"value": "EQ-101",
"normalized_value": null,
"confidence": 0.95,
"linked_asset_id": "EQ-101",
"requires_review": true
},
{"entity_type": "person", "value": "S. Yadav", "confidence": 0.95, "linked_asset_id": null, "requires_review": true}
],
"graph_edges_created": 6,
"vector_chunks_indexed": 0,
"review_items": [
{"item_id": "uuid", "content": "Low-confidence entity: 'HE301X' (confidence=0.52)", "review_status": "pending", "submitted_at": "2026-07-14T10:00:00Z"}
],
"extraction_path": "native",
"handwriting_suspect": false
}Entity types: asset_tag, person, organisation, topology_element, otherwise the lower-cased
relationship type. requires_review is true until the edge is verified.
GET /documents/{document_id}
Get vault document metadata.
Auth required: Yes
Response 200:
{
"document_id": "doc-uuid",
"sha256_hash": "abc123...",
"file_name": "OISD-117.pdf",
"file_size_bytes": 1048576,
"mime_type": "application/pdf",
"document_type": "regulation",
"authority_level": 1,
"source_system": "OISD",
"vault_url": "https://supabase-url/storage/v1/...",
"ingested_at": "2024-01-01T00:00:00Z",
"ingested_by": "admin@example.com",
"status": "active",
"version_chain": null,
"asset_links": ["P-101", "V-201"],
"access_tags": []
}status values: active | superseded | archived | disputed
GET /documents/{document_id}/topology
Get the parsed P&ID / engineering drawing topology for a drawing document.
Auth required: Yes
Only available for documents with document_type = "pid_drawing" — all other types 404 by design. Topology is extracted at ingest time (OCR is skipped) by the Layer 3 vision model (PIDService, Path B); the response includes topology_source (vision_model when the model ran, demo_fixture when it fell back). Every element routes to element-by-element engineer verification before it becomes canonical.
Response 200:
{
"document_id": "DOC-TS4FXYKHCQEF",
"manifest_item_id": "3c0e469e-…",
"verification_status": "unverified",
"topology": {
"title": "Feed Section P&ID - Pump P-101",
"revision": "Rev-4",
"drawing_id": "P-2301",
"equipment_nodes": [
{"id": "TOPO-EQ-001", "tag": "P-101", "type": "centrifugal_pump", "equipment_class": "pump", "service": "Feed Pump A", "design_temp_c": 80, "design_pressure_kpa": 800}
],
"isolation_valves": [
{"id": "TOPO-VLV-001", "tag": "XV-203", "type": "gate_valve", "service": "P-101 Suction Isolation", "normally_open": true, "last_inspected": "2024-06-15", "inspection_interval_months": 18}
],
"isolation_boundaries": [
{"id": "TOPO-ISO-001", "boundary_id": "ISO-P101-MAINT", "ptw_type": "mechanical", "primary_isolations": ["XV-203", "XV-204"], "bleed_vents": ["PG-18"], "regulatory_ref": "OISD-117-6.2", "requires_engineer_signoff": true, "requires_double_block_bleed": true}
],
"instrumentation_loops": [
{"id": "TOPO-LOOP-001", "loop_id": "FIC-3047", "type": "flow_control", "instruments": ["FT-3047", "FIC-3047", "FV-3047"], "design_range_m3h": "0-120", "alarm_low_m3h": 5, "alarm_high_m3h": 110}
]
},
"extracted_at": "2026-07-10T11:52:56Z"
}⚠️ Elements are grouped under a nested
topologyobject in four category arrays (equipment_nodes,isolation_valves,isolation_boundaries,instrumentation_loops) — there is no flatequipment/valves/loops/boundarieslist and no explicit edges. The frontendgetDocumentTopologyfetcher flattens these into{nodes, edges}, synthesising boundary→valve/bleed edges fromprimary_isolations+bleed_vents.
404 for any non-pid_drawing document, or if topology not yet extracted.
The response also carries the verification roll-up, derived from each element's review state
(it was previously a hardcoded "unverified" literal, so no reviewer action could change it):
{
"verification_status": "unverified | partially_verified | verified",
"elements_total": 4, "elements_verified": 4, "elements_disputed": 0,
"safety_critical_total": 2, "safety_critical_verified": 2,
"canonical_ready": true,
"elements": { "TOPO-LOOP-001": { "verification_status": "verified", "element_group": "instrumentation_loops", "reviewed_by": "...", "reviewed_at": "..." } }
}POST /documents/{document_id}/topology/verify
Element-by-element engineer verification — the Layer 3 → Layer 7 gate the architecture calls
non-negotiable regardless of model accuracy. Confirming an element promotes the
CONTAINS_TOPOLOGY_ELEMENT edge the ingestion pipeline already wrote from unverified to
verified; it does not create new edges.
Auth required: Yes — engineer, reliability, or admin.
Request body:
{ "decisions": [ { "element_id": "TOPO-LOOP-001", "decision": "confirmed | corrected | rejected", "note": "optional" } ] }Response 200: the refreshed roll-up plus applied: [...] and unknown_elements: [...].
Unknown element ids are reported, never silently ignored. 400 if no decision applied.
canonical_ready turns true only when every safety-critical element (isolation boundaries,
instrumentation loops) is confirmed and none are disputed.
POST /documents/{document_id}/ocr-review/release · POST /documents/{document_id}/ocr-review/reject
The human decision on a document the OCR confidence gate held (pipeline_stage: review_required — see
D1: a mean below 0.5 or any span below 0.7 holds the scan). The reviewer opens the original scan first.
Auth required: Yes · Role: reliability or admin (the same authority that promotes quarantine)
Body (optional): {"note": "Pressure values legible on the original"} — max 1000 chars, recorded in the audit trail.
- release re-runs
DocumentIngestionWorkflowfrom the vault artifact withocr_reviewed_byset, sorun_ocrlogs the gate as a human override (activity.ocr_gate_released) and continues. What is read still enters the graph asunverifiededges and low-confidence entities still quarantine — releasing the scan does not verify its content. A newextraction_jobsrow is created; the held one is kept.200{status: "released", document_id, job_id}·503if the workflow cannot start (the document stays held) - reject closes the held job as
pipeline_stage: rejectedwith the note. The artifact is never deleted; a legible rescan is ingested as a new document.200{status: "rejected", document_id}
Both write an audit_log row (ocr_review_released / ocr_review_rejected) with the gate reason.
409 if the document's latest job is not held · 403 for any other role.
GET /documents/{document_id}/redacted
Export the document's extracted text with personal identifiers masked — the DPDP Act 2023 export boundary.
Auth required: Yes
Redaction runs at export only, never at ingestion. Operational knowledge legitimately contains personnel names ("which technician signed off the EQ-101 seal repair" is a real maintenance question the vault must answer), so stripping names on the way in would destroy retrieval. The vault copy is never modified.
PERSON names come from the Person nodes the pipeline already linked to the document
(MENTIONS_PERSON) — the same NER output, computed once at ingestion, so an export takes well
under a second instead of re-running NER for up to two minutes. A document with no graph edges
falls back to live NERService. Structured identifiers (email, phone, Aadhaar, PAN,
employee ID, shift ID) are matched by pattern in services/pii.py: Aadhaar as 1234 5678 9012, 1234-5678-9012 or unspaced; Indian mobile numbers with an optional +91, (+91), 91 or 0 prefix, optionally split 5+5; and PERSON names the NER model never saw (it reads only the first part of a document) by context, either after an honorific (Mr, Mrs, Ms, Dr, Shri, Smt, Er and similar) or after a sign-off label (Operator:, Prepared by, Approved by, Witnessed by, Performed by, Name and similar). Masks are stable
pseudonyms within a document — the same name is always [PERSON_1] — so cross-references
in the text survive redaction, which a blanket [REDACTED] would destroy.
Every call writes a pii_redacted_export row to audit_log with PII type counts only,
never the matched values.
Response 200:
{
"document_id": "DOC-TS4FXYKHCQEF",
"document_type": "shift_log",
"redacted_text": "Shift handover. [PERSON_1] reported abnormal vibration on EQ-101. Contact [EMAIL_1].",
"pii_found": true,
"pii_counts": {"PERSON": 2, "EMAIL": 1, "SHIFT_ID": 1},
"pii_span_count": 4,
"note": "DPDP Act 2023 export boundary. Vault original is unmodified and retains full text."
}404 if the document has no indexed text yet (still in extraction).
Scope: this is a per-document export boundary.
ARCHITECTURE.mddescribes redaction as gating cross-site knowledge promotion; there is no cross-site promotion endpoint in the codebase, so that wiring does not exist yet. The redaction pipeline itself is real.
POST /documents/{document_id}/supersede
Mark a document as superseded by a newer version. Closes valid_to on all Neo4j KNOWLEDGE_EDGE relationships sourced from this document.
Auth required: Yes (any role OPA lets through the generic write action; authority 1 to 3 documents are further gated, see below)
Also flags the old version status: "superseded" in Elasticsearch and every Qdrant chunk, so it
stops surfacing in default retrieval (ARCHITECTURE.md §8). Nothing is deleted from either store — a
time-travel query (as_of) still reaches it.
Request body:
{
"new_document_id": "doc-new-uuid"
}Gate for authority 1 to 3 (regulatory, engineering, OEM). Closing the validity window on these facts changes what briefs and safety answers rely on, so only reliability and admin may request it (any other role: 403), and the first request only creates a MoC in pending_approval:
Response 202:
{
"status": "pending_moc_approval",
"old_document_id": "doc-old-uuid",
"new_document_id": "doc-new-uuid",
"moc_required": true,
"moc_id": "MOC-SUP-3F9A1C2B7D",
"message": "Approve the MoC, then repeat this request to apply the supersession."
}Nothing is closed, flagged or re-indexed yet; the old document stays current. The moc_id is deterministic for the (old, new) pair (MOC-SUP- plus a SHA-256 prefix), so repeating the request finds the same record. Approve it with POST /governance/moc/{moc_id}/approve, then repeat the same request to apply the supersession, which returns the 200 below. A rejected MoC makes the repeat a 409. Documents below authority 3 supersede immediately.
Always: a document cannot supersede itself (400), the replacement must exist (404) and be active (409).
Response 200:
{
"status": "superseded",
"old_document_id": "doc-old-uuid",
"new_document_id": "doc-new-uuid",
"edges_closed": 8,
"blast_radius": { "document_id": "doc-old-uuid", "affected_count": 6, "affected": [] },
"moc_required": true,
"moc_id": "MOC-A1B2C3D4",
"index_errors": []
}moc_id is non-null when any affected edge carried authority_level <= 3.
index_errors is the one field to check. It names the stores that failed (elasticsearch, qdrant); the error detail is in the server log, never in the response. Non-empty means the vault row is superseded but a
search index still serves the old version as current — re-run the supersede once that store is
reachable. It is reported rather than raised because Supabase is the source of truth.
5. Search
Prefix: /search
Hybrid search across three engines in parallel. Results are authority-ranked (level 1 Regulatory > level 5 Field).
GET /search/
Parallel hybrid search: ES exact + Qdrant 1024-dim semantic + Neo4j graph.
Auth required: Yes
Query params:
| Param | Type | Default | Description |
|---|---|---|---|
q | string | required | Search query |
asset_id | string | — | Scope to one asset: documents filed under it or linked to it in the knowledge graph (a work-order log filed under EQ-102 that records EQ-101 failures matches EQ-101). Within an authority level, the asset's own documents rank first. |
limit | int | 10 | Max results per engine |
include_quarantine | bool | false | Include unverified quarantine items |
as_of | ISO8601 datetime | now | Time-travel: return only facts valid at this time |
authority_min | int 1–5 | 1 | Exclude sources below this authority level |
Response 200:
{
"query": "max allowable pressure P-101",
"results": [
{
"document_id": "doc-oisd-6.4",
"asset_id": "P-101",
"document_type": "regulation",
"title": "OISD-117 Section 6.4 — Pressure Limits",
"snippet": "Maximum allowable working pressure: 12.5 bar at 180°C",
"authority_level": 1,
"status": "active",
"relevance_score": 0.95,
"retrieval_method": "graph",
"is_quarantine": false,
"vault_url": "https://..."
}
],
"total": 1,
"retrieval_methods": ["exact", "semantic", "graph"]
}retrieval_method per result:
exact— Elasticsearch tag number / document ID matchsemantic— Qdrant 1024-dim ANN (Jina embeddings)graph— Neo4j KNOWLEDGE_EDGE traversal (only whenasset_idprovided)
GET /search/assets/{asset_id}
Search within a specific asset's knowledge. Same engines but asset pre-filtered.
Auth required: Yes
Query params: Same as GET /search/ minus asset_id.
Response 200: Same shape as GET /search/.
POST /search/synthesize
Phase 2. Synthesize a natural-language answer from retrieved context using NVIDIA NIM (primary) or Ollama (fallback). Never originates knowledge — assembles only what the server retrieved.
Auth required: Yes
Request body:
{
"query": "What is the maximum allowable pressure for P-101?",
"as_of": "2026-01-01T00:00:00Z"
}query is required (max 2000 characters). as_of is optional (ISO8601, max 64 characters; a bad value is 422) and time-travels the retrieval. context and query_category are still accepted (so older clients keep working, context capped at 50 items) but are ignored. The server runs its own hybrid search for the query (top 6, quarantine excluded, _server_evidence in routers/search.py), derives the safety category itself with LLMService.classify_query_category, and for isolation questions adds engineer-verified P&ID topology. The gate used to read authority_level, confidence and the category from the request body, so any signed-in role could clear it with a made-up document. A client category that differs from the derived one is logged (synthesis.client_category_ignored) and not used. The classifier fails closed: a question that reads like a safety parameter but fits no named category (a number with an engineering unit, "can HE-302 handle 45 bar?", "how tight should the flange bolts be?") lands in safety_parameter_unspecified and is gated like the rest.
Prompt-injection hardening: the rules go in a system message, and the query and every retrieved document are escaped and wrapped in tags as untrusted data. A document's index, authority and id come only from its tag attributes, never from text inside it. When the model echoes several CONFIDENCE: lines the lowest wins, and sources_used citing a source number the answer was not given is dropped.
When every provider tier fails and any returned HTTP 429, the response carries
rate_limited: true and a message naming the exhausted providers. An exhausted quota is an
operational limit with a fix, not the model being wrong — unlabelled the two are
indistinguishable, and a dead free tier reads as poor answer quality. (This is not
hypothetical: repeated benchmark runs exhausted the Gemini free tier and dragged measured
answer quality from 24/25 to 13/25 before the cause was visible.)
The refusal gate clears on either of two signals, and refuses only when both fail:
| Signal | Clears the gate when |
|---|---|
| Evidence confidence | any source has confidence ≥ 0.7 |
| Source authority | any source has authority_level ≤ 3 (regulatory / engineering / OEM) |
Both are needed because the hybrid-search and graph retrieval paths carry authority_level
but no confidence — a confidence-only gate read those as 0.0 and would refuse every
safety-critical query. On refusal the response carries refused: true, answer: null, and
the retrieved sources for direct verification.
Only relevant, same-asset evidence may clear the gate. An authoritative document about a different asset cannot vouch for this answer, and every asset the question names must be vouched for by evidence filed under it (an asset can be named by tag or by a confirmed alias such as "Feed Pump A"): the gate takes the worst-covered asset's best authority and lowest best confidence. Server-retrieved evidence is always scored, so the looser unscored branch is not reachable from HTTP.
The gate runs twice — once on the evidence, once on the result. The pre-gate above cannot know
whether the model actually found the parameter. Observed live: a torque query for a non-existent
asset retrieved an unrelated authority-3 bulletin, cleared the pre-gate, and the model honestly
answered "not specified in the provided source documents" — which rendered as a hedged
low-confidence answer, the one outcome the architecture forbids for a safety-critical parameter.
So a synthesized answer whose own self-reported CONFIDENCE: is below threshold is converted into
a refusal. This gate fails closed: an answer with no parseable CONFIDENCE: line, or one citing a
source number outside the evidence, is refused too, because a prompt-injected document is exactly what asks the model to
leave the line out.
Safety-critical categories (refusal when confidence < 0.7):
max_allowable_pressure · isolation_interlock_sequence · torque_specification · electrical_rating · pressure_relief_setting · safety_shutdown_setpoint · safety_parameter_unspecified (the fail-closed catch-all)
Response 200 — normal:
{
"answer": "The maximum allowable working pressure for P-101 is 12.5 bar at 180°C, per OISD-117 Section 6.4.",
"sources": [{"document_id": "doc-oisd-6.4", "authority_level": 1}],
"confidence": 0.98,
"refused": false,
"safety_critical": true,
"sources_used": [0],
"uncertainty": "No post-2024 revision of OISD-117 §6.4 was found in the vault.",
"model": "nim",
"rate_limited": false,
"pending_moc": []
}uncertainty carries the model's own statement of what it could not establish; rate_limited is
true only when every provider tier returned HTTP 429 (see above). model names the provider tier
that actually answered — nim (Nemotron 3 Ultra 550B), openrouter (Llama 3.1 70B) or gemini — and
served_model, when present, is the exact model id the provider reported.
pending_moc — the change-under-review warning. Non-empty when an asset cited in the answer has
an open engineering-track conflict awaiting MoC sign-off. While a conflict sits in the MoC queue the
canonical graph is deliberately not updated, so the answer may be reporting a value that is under
formal dispute — architecture Layer 7 and Flow C both require the query to say so:
"pending_moc": [
{
"conflict_id": "uuid",
"asset_id": "HE-301",
"parameter": "max_operating_pressure",
"severity": "major",
"moc_id": "MOC-A1B2C3D4",
"moc_status": "pending_approval"
}
]Populated on refusals too, so a refusal that hands back source documents still discloses that those
documents are contested. The copilot renders it as a banner above the answer — after it, a
technician has already read the number. moc_id is null when a conflict is flagged but no MoC draft
has been raised yet.
Scope: currently returned by POST /search/synthesize only, not by GET /search/ or
POST /search/rca-pack.
Response 200 — refused:
{
"answer": null,
"refused": true,
"refusal_reason": "Safety-critical parameter query refused: evidence confidence 0.35 is below threshold 0.70 for category max_allowable_pressure. Return sources directly.",
"sources": [...],
"safety_critical": true
}An audit_log entry is written on every synthesis call, streamed or not (query, query_category, sources_used, evidence_document_ids, confidence, refused, model). In Phase 1 (KAIROS_PHASE < 2) the endpoint returns the server-retrieved sources with answer: null and a message.
POST /search/synthesize/stream
Same answer as POST /search/synthesize, delivered progressively as Server-Sent Events. Identical
request body (the same server-side retrieval, context and query_category ignored), and the terminal payload has the same shape as SynthesizeResponse, including pending_moc. The done payload is a whitelist of those fields (the provider's raw response never reaches the client), and the stream writes the same audit row as the non-streaming route; it once had neither the audit row nor the MoC disclosure.
A separate endpoint rather than a flag on the existing one: the ANSWER:/CONFIDENCE:/… parse
contract has two consumers (routers/search.py, workflows/elicitation_workflow.py) and a measured
answer-quality figure attached, so this adds a surface instead of altering one.
| Event | Meaning |
|---|---|
status | Pipeline stage. stage: "gating", then stage: "synthesizing" carrying streaming_text and, when false, a reason to display. |
delta | A chunk of answer text — {"text": "…"}. Never sent for a safety-critical category. |
restart | Discard everything received so far. The answer was re-synthesized by the fallback cascade; concatenating would splice two different answers. |
done | Terminal, always sent. Same fields as POST /search/synthesize. |
error | Terminal, only on unexpected failure. |
doneis authoritative;deltatext is provisional.CONFIDENCE:arrives afterANSWER:, and the post-synthesis safety gate can convert a finished answer into a refusal based on it. The sixSAFETY_CRITICAL_CATEGORIEStherefore emit nodeltaevents at all — they stream status only, so an operator is never shown a claim the gate is about to retract. A client must not renderdeltatext as a final answer, and must drop it whendonearrives.
Authorization is identical to POST /search/synthesize (both map to the write_api catch-all).
The response sets X-Accel-Buffering: no; without it a buffering proxy delivers the whole stream in
one write, which reproduces exactly the blank-screen wait this endpoint exists to remove.
event: status
data: {"stage": "synthesizing", "streaming_text": false, "reason": "Safety-critical category — the answer is withheld until the post-synthesis gate has cleared it."}
event: done
data: {"answer": "The maximum allowable pressure for HE-301 is 16.2 bar. [Source 1]", "refused": false, "safety_critical": true, ...}POST /search/feedback
Record the single-tap rating on a synthesized answer. Architecture Layer 12 Phase 2 treats this as direct input to outcome attribution and Layer 0 validation, not UX research — it is the trust-building mechanism the phase is built around.
Auth required: Yes
Request body:
{
"query": "What is the maximum allowable pressure for P-101?",
"rating": "accurate",
"note": "Matches the bulletin we have on file.",
"sources_used": [0],
"model": "nim"
}rating must be one of accurate · missing_context · incorrect. note, sources_used and
model are optional.
Response 200: {"status": "recorded", "rating": "accurate"}
Written to audit_log as action synthesis_feedback, alongside the synthesis row the query
already writes — no separate table. The copilot calls this fire-and-forget: a failed rating never
surfaces as an error over the answer, and the UI clears the selection rather than claiming a save
that did not happen.
POST /search/rca-pack
Layer 11 RCA synthesis. Assembles a failure timeline, ranked hypotheses, and supporting documents for a specific asset incident.
Auth required: Yes
Three parallel retrieval passes:
- Neo4j — Event nodes linked to the asset in a 90-day window (chronological timeline)
- Qdrant — Semantic search on
failure_code + asset_classagainstkairos_knowledge - Supabase —
operational_events(work orders, alarms, PTWs) in the same 90-day window
Combined evidence is passed to LLMService.rca_synthesize(). Falls back to raw timeline + documents when LLM is unavailable.
Safety-critical queries: refused=True when the failure code matches a safety-critical category. Sources returned directly.
Request body:
{
"asset_id": "P-101",
"incident_date": "2026-07-02T09:00:00Z",
"failure_code": "SEAL-FAIL",
"include_quarantine": false
}Response 200:
{
"asset_id": "P-101",
"incident_date": "2026-07-02T09:00:00Z",
"failure_code": "SEAL-FAIL",
"timeline": [
{
"event_type": "vibration_alarm",
"occurred_at": "2026-04-05T06:12:00",
"description": "Elevated vibration on P-101",
"source": "neo4j"
}
],
"hypotheses": [
{
"hypothesis": "Bearing wear caused by insufficient lubrication",
"evidence_weight": 0.82,
"sources": ["doc-p101-maint-record"]
}
],
"supporting_documents": [
{
"document_id": "doc-p101-failure-hist",
"title": "P-101 Failure History",
"authority_level": 2,
"confidence": 0.91
}
],
"confidence": 0.85,
"refused": false,
"synthesis_available": true
}synthesis_available: falsewhen NIM/Ollama is unavailable — timeline and documents still returned.refused: truewith emptyhypotheseswhen the failure code is safety-critical.- Every call writes an
audit_logentry withaction=rca_pack_generated. failure_codeis capped at 200 characters. A caller on another site than the asset gets404 Asset not found(an asset with no registry row fails closed for non-admins; admin sees every site). A hypothesis may cite only document ids the model was shown; any other id is dropped. A missing or unparseableCONFIDENCEcounts as low, so a safety-keyword failure code is refused rather than answered.
6. Events (Operational)
Prefix: /events
Event ingestion for CMMS work orders, Permit-to-Work, shift handovers, DCS alarms, equipment tag-outs, and inspections. The six ingest routes need the ingest_event action (engineer, reliability, admin, and the internal key, which is admin); field_worker and compliance get 403. They also enforce site scope: a non-admin whose token site differs from the payload's site_id gets 403, and an asset on another site is the same 404 ... is not registered. as an unknown one. Each event:
- Is checked for a duplicate (read-only) against a 10-minute Redis key:
asset_id+event_type, or the business id (work_order_id,ptw_id,alarm_id, inspection type) when the event carries one - Is written to
operational_events(Supabase) through one helper,_store_event - Is published to the appropriate Redis Stream
- Triggers brief assembly asynchronously
- Is recorded as seen (
mark_seen) last, so an ingest that fails part-way stays retryable instead of reading asdeduplicatedon the retry
Duplicate event_id. event_id is client supplied, so the insert is not an upsert. A repeat of the same event (same type, asset, site and payload) is accepted as a retry and carries on; a duplicate event_id with a different payload, asset, site or type is 409 An event with this event_id already exists. and the stored row is untouched.
The actor is the token, not the body. performed_by on tag-out and inspection events is a source-system claim only: the audit actor and quarantine submitter are the token's user_id (tag-out keeps the claim as details.reported_performed_by). reported_by is no longer accepted on deviation flags and user_id, role, acknowledged_at and signature are ignored on acknowledgements.
GET /events/
Paginated operational-event feed (the /events workspace reads this).
| Param | Type | Default | Notes |
|---|---|---|---|
event_type | string | — | Optional filter, e.g. work_order_created |
limit | int | 50 | 1–200 |
offset | int | 0 | ≥ 0 |
Ordered by occurred_at descending. Returns event_id, event_type, event_subtype, asset_id,
site_id, occurred_at and payload per item, with an exact total.
POST /events/work-order
Ingest a CMMS work order.
Auth required: Yes (ingest_event: engineer, reliability, admin)
Request body:
{
"work_order_id": "WO-2024-001",
"asset_id": "P-101",
"failure_code": "VIBE-HIGH",
"description": "Elevated vibration on feed pump",
"priority": "high",
"assigned_technician_id": "tech-uuid",
"planned_start": "2024-01-02T08:00:00Z",
"close_notes": null,
"source_system": "SAP_PM",
"site_id": "SITE_001",
"occurred_at": "2024-01-01T22:00:00Z"
}close_notes is populated when the WO is closed — used by the Celery attribution worker's execution compliance check.
assigned_technician_id is optional — if absent, brief is addressed site-wide.
Side effects:
- Recurring failure detection: if same asset had ≥1 prior WO in the last 90 days with the same failure family,
event_subtype=recurringis set and arecurring_failure_detectedevent is published. A high-priority brief is assembled immediately. - Attribution trigger: if same asset had a prior WO in the last 30 days, a Celery
evaluate_outcometask is queued. - Elicitation check: if failure code is rare, asset is uninstrumented, or priority is critical/urgent, a
MicroInterviewWorkflowmay be triggered on thekairos-elicitationqueue.
Response 202 — first event for asset:
{
"event_id": "evt-uuid",
"status": "accepted",
"brief_task_id": "celery-task-uuid",
"brief_due_in_seconds": 300,
"stream_id": "1704067200000-0",
"recurring_detected": false
}Brief assembly is delayed by LATE_ARRIVAL_WINDOW_MINUTES (default 5 min) via apply_async(countdown=...) to allow correlated events to arrive first.
Response 200 — duplicate within dedup window: {"event_id": "...", "status": "deduplicated"}
POST /events/ptw
Ingest a Permit-to-Work event.
Auth required: Yes (ingest_event)
Request body:
{
"ptw_id": "PTW-2024-042",
"work_area": "Pump Bay A",
"asset_ids": ["P-101", "P-102"],
"ptw_type": "hot_work",
"issuing_engineer_id": "eng-uuid",
"valid_from": "2024-01-02T06:00:00Z",
"valid_to": "2024-01-02T18:00:00Z",
"source_system": "PTW_System",
"site_id": "SITE_001",
"occurred_at": "2024-01-02T06:00:00Z"
}PTW events always receive priority: critical. The EEMUA 191 governor always delivers PTW briefs regardless of push count. The PTW handler assembles immediately and leaves other events' pending briefs alone (they go to other recipients).
Response 202: Same shape as work order.
POST /events/shift-handover
Ingest a shift handover event.
Auth required: Yes (ingest_event)
Request body:
{
"outgoing_shift_lead_id": "lead-uuid-1",
"incoming_shift_lead_id": "lead-uuid-2",
"handover_time": "2024-01-02T06:00:00Z",
"site_id": "SITE_001",
"source_system": "DCS",
"occurred_at": "2024-01-02T06:00:00Z"
}Response 202: Same shape.
POST /events/alarm
Ingest an alarm acknowledgment.
Auth required: Yes (ingest_event: engineer, reliability, admin. This used to be open to field_worker.)
Request body:
{
"alarm_id": "ALM-0042",
"asset_id": "P-101",
"alarm_tag": "P-101-VIBHI",
"alarm_description": "Vibration high alarm — P-101 exceeds 4.5 mm/s",
"severity": "high",
"acknowledged_by": "tech-uuid",
"source_system": "DCS",
"site_id": "SITE_001",
"occurred_at": "2024-01-01T22:05:00Z"
}Response 202: Same shape.
POST /events/tag-out
Ingest an equipment tag-out event. Used when a physical tag-out (lockout/tagout) is applied to an asset.
Auth required: Yes (ingest_event)
Request body:
{
"asset_id": "P-101",
"tag_out_reason": "Planned maintenance — seal replacement",
"performed_by": "tech-uuid",
"expected_return_date": "2024-01-05T08:00:00Z",
"source_system": "SAP_PM",
"site_id": "SITE_001",
"occurred_at": "2024-01-02T06:00:00Z"
}Side effects:
- Inserts into
operational_eventsand publishes tokairos:events:tag_outstream - Writes
audit_logentry withaction=equipment_tag_out - Triggers delayed brief assembly (same countdown as WO). It does not hold a pending slot: a tag-out neither cancels nor is cancelled by another event's brief
- The
audit_logactor is the caller'suser_id; the body'sperformed_byis kept asdetails.reported_performed_by
Response 202:
{
"status": "accepted",
"event_id": "evt-uuid",
"stream_entry_id": "1704067200000-0",
"brief_task_id": "celery-task-uuid",
"brief_due_in_seconds": 300
}POST /events/inspection-complete
Ingest an inspection completion event. Optionally creates a Neo4j knowledge edge if a supporting document is provided. Low-confidence findings (<0.7) are automatically quarantined.
Auth required: Yes (ingest_event)
Request body:
{
"asset_id": "P-101",
"inspection_type": "visual",
"result": "failed",
"performed_by": "inspector-uuid",
"findings": "Seal showing wear, recommend replacement within 30 days",
"document_id": "DOC-INSP-P101-2024",
"confidence": 1.0,
"source_system": "SAP_PM",
"site_id": "SITE_001",
"occurred_at": "2024-01-02T10:00:00Z"
}result values: passed | failed | conditional
document_id, when given, must be an existing vault document of type inspection_report, otherwise 422 document_id must be an inspection_report already in the vault. The edge is compliance evidence, so it cannot point at an id the caller made up. confidence is capped: the reporter may lower it (below 0.7 routes the finding to quarantine) but the evidence edge never exceeds INSPECTION_EVIDENCE_CONFIDENCE (0.85) and stays unverified until a human verifies it. performed_by is optional and informational.
Side effects:
- The event is stored first, as the system of record, so a retry after a later failure finishes the job
- If
document_idprovided: createsINSPECTION_RECORDNeo4j edge with all 6 required properties - If
confidence < 0.7: inserts intoquarantine_itemswithinput_type=field_observation - If
result = "failed"orfindingsnon-empty: triggers immediate brief assembly - Correlates with other events for the same asset via
compound_event_id - Publishes to
kairos:events:inspections(normal) orkairos:events:work_orders(failed/findings)
Response 202:
{
"status": "accepted",
"event_id": "evt-uuid",
"stream_entry_id": "1704067200000-0",
"edge_id": "neo4j-edge-id",
"quarantine_item_id": null,
"brief_task_id": "celery-task-uuid"
}edge_id is null when no document_id was provided. quarantine_item_id is populated only when confidence < 0.7.
POST /events/deviation-flag
Report a physical deviation from the last known state for an asset. Freezes all unacknowledged briefs for the asset until resolved. Carries a 24-hour SLA (overrides default 5-day quarantine SLA).
Auth required: Yes (any authenticated role; this is the field-worker flow and stays on the generic write action). The reporter is always the token's user_id.
Request body:
{
"asset_id": "P-101",
"description": "Bypass valve observed open — not reflected in DCS state",
"affected_topology_path": "P-101 → XV-101 → V-201"
}Response 202:
{
"item_id": "q-item-uuid",
"asset_id": "P-101",
"status": "quarantined",
"briefs_frozen": 3,
"stream_id": "1704067200000-0"
}Side effects:
- Inserts item into
quarantine_itemswithinput_type=deviation_flagandsla_due_at = NOW() + 24h - Sets
delivery_frozen=trueon all unacknowledged briefs for this asset - Publishes to
kairos:events:alarmsstream - Writes to
audit_log
POST /events/deviation-flag/{item_id}/resolve
Resolve a physical deviation flag. Unfreezes briefs and optionally creates an MoC record.
Auth required: Yes — engineer or admin (OPA enforced)
Request body:
{
"resolution": "promoted",
"moc_warranted": true,
"notes": "Bypass confirmed open per field inspection — MOC initiated"
}resolution values: promoted | disputed
Response 200:
{
"item_id": "q-item-uuid",
"status": "resolved",
"briefs_unfrozen": 3,
"moc_id": "MOC-XXXXXXXX"
}moc_id is only present when moc_warranted=true.
POST /events/plant-state
Set the current plant operating state for a site. Non-critical briefs are suppressed during turnaround, shutdown, or emergency states.
Auth required: Yes — engineer or admin. An engineer may only set their own site's state (403 otherwise).
Request body:
{
"site_id": "SITE_001",
"state": "turnaround",
"expires_at": null
}state values: normal | turnaround | shutdown | emergency
expires_at (optional ISO8601 datetime) — if set, the state automatically reverts to PLANT_STATE_DEFAULT after this time.
Response 202:
{
"status": "set",
"site_id": "SITE_001",
"state": "turnaround"
}Side effects: Upserts plant_operating_states row; writes audit_log entry with action=plant_state_changed.
GET /events/plant-state/{site_id}
Get the current plant operating state for a site.
Auth required: Yes. Admin may read any site; everyone else only their own (site_scope).
Response 200:
{
"site_id": "SITE_001",
"state": "turnaround"
}Returns PLANT_STATE_DEFAULT (default: "normal") if no state has been set or if the latest state has expired.
GET /events/{event_id}
Get a single operational event with event correlation metadata.
Auth required: Yes
Response 200:
{
"event_id": "evt-uuid",
"event_type": "work_order_created",
"asset_id": "P-101",
"site_id": "SITE_001",
"occurred_at": "2026-07-02T09:00:00Z",
"payload": {},
"compound_event_id": "compound-uuid",
"correlated_event_ids": ["alarm-evt-uuid"]
}compound_event_id and correlated_event_ids are set when this event was correlated with other same-asset events within DEDUP_WINDOW_MINUTES.
404 if not found, or if it sits on another site than the caller's (admin sees every site).
POST /events/{event_id}/ack
Acknowledge receipt of an operational event. Writes a server-signed brief_acknowledged row to audit_log.
Auth required: Yes
Request body: only notes is read. user_id, role, acknowledged_at and signature are accepted so older clients still parse, and ignored: who, in what role, when and the HMAC-SHA256 signature all come from the verified token and the server clock.
{ "notes": "Acknowledged, will investigate" }Response 200: {"status": "acknowledged", "event_id": "...", "user_id": "<token user>"}. Repeating it returns the first acknowledgement with "repeat": true and writes nothing (one per user per event).
Errors: 404 the event does not exist or is on another site. 403 Not a recipient of this event.: staff (engineer, reliability, admin) may acknowledge any event on their site; any other role only if it is the recipient (themselves or their site-{site_id} address) of a brief that event triggered. The repeat check is check-then-insert, so two simultaneous first acknowledgements can both write; a unique index on audit_log would close that and is a schema change.
7. Briefs (Operator Intelligence)
Prefix: /briefs
Operator intelligence briefs assembled from 5 parallel queries (Neo4j graph + Qdrant vectors + Elasticsearch + Supabase event history + regulatory graph). Governed by EEMUA 191: hard ceiling ≤6 push events/operator/hour, 4-hour cool-down per (recipient, asset, trigger event type).
GET /briefs/
Get pending briefs for the current user. Also returns site-wide briefs. Calls record_push per returned brief (counts against governor).
Scoped by recipient, with no role override: an admin sees only briefs addressed to them or to site-{site_id}. The demo loader addresses its briefs to engineer@, reliability@ and field_worker@, so an admin login shows an empty list; that is the scoping working, not missing data.
Auth required: Yes
Query params:
| Param | Type | Default | Description |
|---|---|---|---|
unacknowledged_only | bool | true | When true, filters to unacknowledged briefs only |
limit | int | 10 | Max briefs (max 50) |
Response 200:
{
"briefs": [
{
"brief_id": "brief-uuid",
"recipient_user_id": "tech-uuid",
"priority": "high",
"trigger_event_type": "work_order_created",
"headline": "P-101: Elevated vibration — 3 prior failures in 18 months",
"body": "...",
"action_items": ["Check coupling alignment", "Review last overhaul report"],
"warnings": ["Active PTW in same area expires at 18:00"],
"sources": [
{
"document_id": "DOC-P101-FAILURE-HIST",
"document_type": "maintenance_record",
"title": "P-101 Failure History Report",
"authority_level": 2,
"relevant_excerpt": "Three vibration-related failures in 18 months...",
"vault_url": "https://...",
"is_quarantine": false
}
],
"requires_countersignature": false,
"delivery_frozen": false,
"delivered_at": "2024-01-01T22:10:00Z"
}
],
"total_pending": 1,
"suppressed_count": 0,
"suppressed_held": [],
"governor_state": {
"push_count_last_hour": 1,
"ceiling": 6,
"state": "normal"
},
"next_delivery_allowed_at": null
}Suppression rules (in priority order):
- EEMUA 191 governor: If
push_count_last_hour >= ceiling, all non-critical briefs suppressed. - Plant state gate: If plant state for the user's
site_idisturnaround,shutdown, oremergency, non-critical briefs suppressed. Critical (PTW) briefs always pass. - Frozen briefs: Briefs with
delivery_frozen=true(set by deviation flag) are included withfrozen=trueandfreeze_reasonbut excluded from governor push counting.
suppressed_held — what is being withheld, not just how many. Carries the suppressed briefs
themselves (ranked, capped at limit), each tagged suppressed: true with a suppression_reason
of either "Hourly push limit reached" or "Plant state — deliveries paused". suppressed_count
alone told an operator "3 held" with no way to judge whether the held one concerned their asset.
They are deliberately not in briefs: the handler records an EEMUA push for everything it
delivers, so folding them in would spend governor budget on briefs the governor is withholding — the
governor would end up suppressing its own disclosure. Withholding delivery is the point;
withholding knowledge that something is withheld is just an opaque counter.
GET /briefs/governor/status
Get current push governor state for the authenticated user.
Auth required: Yes
Response 200:
{
"user_id": "tech-uuid",
"state": "normal",
"push_count_last_hour": 3,
"ceiling": 6,
"next_delivery_allowed_at": null
}state values: normal | suppressed
GET /briefs/{brief_id}
Get a specific brief. Recipient-scoped: returns the brief only if it's addressed to the caller (recipient_user_id == user_id) or to their site (recipient_user_id == site-{site_id}) — otherwise 404. Site-wide briefs (recipient site-{site_id}) are readable by any user at that site; without the site clause they were unopenable by anyone. Same scoping applies to POST /briefs/{brief_id}/ack. The one extension: a PTW brief (requires_countersignature) is also readable by any staff role (engineer, reliability, admin), because a permit is a posted safety document and the countersigner is not the recipient. The list endpoint filters with .in_("recipient_user_id", ...) over the same recipient set (never an .or_() filter string built from the site_id claim, which could smuggle in a condition of its own).
Auth required: Yes
Response 200: Full brief object. · 404 if not found or not addressed to the caller / their site.
POST /briefs/{brief_id}/ack
Acknowledge a brief. Required for PTW briefs and any brief with requires_countersignature: true.
Auth required: Yes
No request body. The signer is the authenticated caller — accepting a user_id in the body
would let one user sign as another, which is exactly what an acknowledgment record must prevent.
For PTW briefs (requires_countersignature: true) this records the first signature only.
acknowledged_at is deliberately left null and the response status is pending_countersignature —
the brief is not complete until a second, distinct authority countersigns (see below).
An acknowledgement is final. A second POST /briefs/{brief_id}/ack on an already acknowledged brief is 409 Brief '<id>' is already acknowledged. Re-acking used to overwrite acknowledged_by (for a PTW, swapping the acknowledger the countersigner is checked against) and replace the signed audit row.
Response 200:
{
"brief_id": "...",
"status": "acknowledged",
"acknowledged_by": "tech-uuid",
"signature": "9f2c…"
}signature is an HMAC-SHA256 over brief_id | user_id | action | timestamp, keyed by
APP_SECRET_KEY — the "cryptographically signed with the user's identity" requirement in
architecture Layer 8. It is stored in the immutable audit_log row (with signature_alg), not on
the briefs table, so the signed record lives where the audit trail already is and no schema
migration is needed. Changing any one bound fact produces a different signature, so an
acknowledgment captured on one brief cannot be replayed onto another.
POST /briefs/{brief_id}/countersign
Second of the two signatures a PTW brief requires (architecture Flow B). Sets acknowledged_at,
because that is the moment both signatures exist.
Auth required: Yes — reliability or admin (OPA can_countersign_brief). Engineers
deliberately cannot countersign, so both signatures can never come from the issuing role.
Request body: none. Identity comes from the session, never a typed name.
Rules enforced:
- The countersigner must be a different user than
acknowledged_by→403. - The brief must already be acknowledged →
409. - Already countersigned →
409. Not a PTW brief →400. - Not scoped by recipient. The countersigner is by definition not the person the brief was
delivered to; scoping this read by recipient made every countersign return
404.
Response 200:
{
"status": "acknowledged",
"brief_id": "...",
"acknowledged_by": "engineer-uuid",
"countersigned_by": "reliability-uuid",
"countersigned_at": "2026-08-16T09:12:00Z"
}Writes its own HMAC to the brief_countersigned audit row, computed over the countersigned
action so it can never collide with the acknowledger's signature. The two signatures together are
the dual sign-off evidence for the permit.
POST /briefs/{brief_id}/feedback
Submit feedback on brief accuracy.
Auth required: Yes
Request body:
{
"rating": "incorrect",
"notes": "Pressure value shown is for a different vessel"
}rating values: accurate | missing_context | incorrect
When rating = "incorrect": writes confidence_recheck_queued to audit_log with source_document_ids.
Response 200: {"brief_id": "...", "feedback_recorded": true, "confidence_recheck_queued": true}
Same visibility rule as reading the brief: feedback on a brief the caller cannot open is 404.
8. Governance
Prefix: /governance
Knowledge conflict detection, quarantine management, Management of Change, SLA tracking, circuit breaker, validation corpus, and model gate.
GET /governance/conflicts
List open knowledge conflicts. Runs lazy SLA escalation before returning results.
Auth required: Yes (engineer, admin, reliability)
Query params: status (open | pending_moc | resolved | all, default open), track (administrative | engineering), asset_id, include_non_asserting (bool, default false), limit, offset
Conflicts whose parameter is a provenance or structural relationship type
(GraphService.NON_ASSERTING_RELATIONSHIPS — DOCUMENTED_BY, MENTIONS_PERSON,
MENTIONS_ORGANISATION, CONTAINS_TOPOLOGY_ELEMENT) are excluded by default. Two documents
describing one asset is what an archive is, not a contradiction. detect_conflict no longer
creates them; rows written before that guard are filtered here, in the query — count and
range are computed by PostgREST, so filtering in Python would report a total the page can never
reach. include_non_asserting=true returns them.
Note the shape difference this exposes: only conflicts that record an actual disagreement carry
value on their sources. Auto-detected conflicts never did, because a KNOWLEDGE_EDGE has no
value property to compare — which is precisely why they should not have been conflicts.
Response 200:
{
"items": [
{
"conflict_id": "conf-uuid",
"track": "engineering",
"asset_id": "P-101",
"parameter": "max_allowable_pressure",
"source_a": {"document_id": "doc-a", "value": "12.5 bar", "authority_level": 1},
"source_b": {"document_id": "doc-b", "value": "15.0 bar", "authority_level": 2},
"severity": "critical",
"status": "open",
"sla_due_at": "2024-01-06T00:00:00Z",
"is_overdue": false,
"escalated_at": null,
"detected_at": "2024-01-01T00:00:00Z"
}
],
"total": 1,
"limit": 50,
"offset": 0
}Conflict tracks — assigned by detect_conflict, not by the shape of the disagreement:
engineering— the new edge is authority ≤ 3 and the parameter name contains a safety-critical keyword (pressure,temperature,inspection,isolation,material). 24 h SLA, resolvable only through the MoC webhook.administrative— everything else. 5-day SLA, resolvable in-app viaPOST /governance/conflicts/{id}/resolve.
sla_due_at maps to the existing sla_deadline column. is_overdue is computed inline. escalated_at is populated when SLA escalation fires.
GET /governance/conflicts/{conflict_id}
Get conflict detail + blast-radius report.
Auth required: Yes
Response 200: Full conflict object + blast_radius: [{"asset_id": ..., "affected_facts": 5}]
POST /governance/conflicts/{conflict_id}/resolve
Resolve an administrative-track conflict. Engineering-track conflicts must be resolved via POST /governance/moc/webhook.
Auth required: Yes — admin or engineer (OPA enforced)
Request body:
{
"resolution": "accept_source_a",
"notes": "OISD-117 takes precedence per engineering manager sign-off",
"resolved_by": "admin@example.com"
}Response 200: {"conflict_id": "...", "status": "resolved"}
GET /governance/quarantine
List items in the quarantine layer. Runs lazy SLA escalation before returning results.
Auth required: Yes
Query params: asset_id, reviewer_id, review_status (pending | promoted | disputed | archived), limit, offset
Default review_status is pending.
Response 200:
{
"items": [
{
"item_id": "q-uuid",
"asset_id": "P-101",
"content": "Seal clearance 0.15mm from voice note",
"input_type": "voice_note",
"submitted_by": "tech-uuid",
"review_status": "pending",
"session_context": null,
"sla_due_at": "2024-01-06T00:00:00Z",
"is_overdue": false,
"escalated_at": null,
"quarantined_at": "2024-01-01T00:00:00Z"
}
],
"total": 1,
"limit": 50,
"offset": 0,
"note": "All items are unverified field inputs — not reviewed by engineering authority."
}Items enter quarantine when confidence < 0.7 or entity resolution fails. sla_due_at defaults to NOW() + 5 days; deviation flags override to NOW() + 24h.
POST /governance/quarantine/{item_id}/promote
Promote a quarantine item to the canonical graph. Human action only — no auto-promotion, ever.
Auth required: Yes — reliability or admin only (OPA can_promote_quarantine; engineer and field_worker get 403). The frontend hides the Promote button accordingly (PROMOTE_ROLES).
Request body:
{
"promoted_by": "reliability@example.com",
"notes": "Verified against P&ID Rev D",
"authority_level": 3,
"valid_from": "2024-01-01T00:00:00Z"
}On promotion, detect_conflict() runs. If a conflict is detected, kairos.conflicts.open metric increments.
Response 200 — no conflict:
{
"item_id": "q-uuid",
"status": "promoted",
"conflict_detected": false,
"edge_id": "neo4j-edge-id"
}Response 200 — conflict detected:
{
"item_id": "q-uuid",
"status": "promoted",
"conflict_detected": true,
"conflict_id": "conf-uuid",
"conflict_track": "engineering"
}Errors — shared by all four quarantine-by-id routes (promote, dispute, request-info, and
POST /events/deviation-flag/{item_id}/resolve, which reads the same table):
| Status | When |
|---|---|
404 | No such item — including a malformed item_id. quarantine_items.item_id is a UUID column, so a non-UUID path segment made PostgREST raise 22P02 before the handler's own 404 branch was reached, and the global handler turned that into a 500. dependencies.valid_quarantine_item_id now rejects it up front. 404 rather than 422 on purpose: to a reviewer a malformed id and an absent one are the same situation, and splitting one outcome across two status codes on id shape would leak the column type into the API contract. |
409 | The item is no longer pending (already promoted, disputed, or archived). |
400 | The item has no asset_id, so it cannot be linked to the graph (promote only). |
POST /governance/quarantine/{item_id}/dispute
Mark a quarantine item as incorrect. Prevents accidental promotion.
Auth required: Yes
Request body: {"disputed_by": "engineer@example.com", "reason": "Value is for V-201, not P-101"}
Response 200: {"item_id": "...", "status": "disputed"}
POST /governance/quarantine/{item_id}/request-info
Layer 6's fourth review action — a reviewer asks for clarification instead of promoting or disputing. The item stays pending (still actionable); the request + note are recorded to the audit log (action=info_requested).
Auth required: Yes (same gate as promote — reliability/admin)
Request body: {"note": "Which seal face — inboard or outboard?"}
Response 200: {"item_id": "...", "status": "info_requested", "note": "..."}
Errors: 404 item not found · 409 item is not pending
GET /governance/sla-report
SLA escalation report for all overdue conflicts and quarantine items. Runs lazy escalation then returns full overdue inventory.
Auth required: Yes
Response 200:
{
"checked_at": "2024-01-06T12:00:00Z",
"escalated_this_run": {
"conflicts": 1,
"quarantine_items": 0
},
"overdue_conflicts": [
{
"conflict_id": "conf-uuid",
"track": "engineering",
"asset_id": "P-101",
"sla_deadline": "2024-01-05T00:00:00Z",
"escalated_at": "2024-01-06T12:00:00Z",
"status": "open"
}
],
"overdue_conflicts_total": 1,
"overdue_quarantine_items": [],
"overdue_quarantine_total": 0
}Escalation is idempotent — escalated_this_run is 0 on subsequent calls for already-escalated items.
GET /governance/moc
List Management of Change records.
Auth required: Yes
Query params: status (draft | pending_approval | approved | rejected)
Response 200:
{
"items": [...],
"total": 3
}GET /governance/moc/{moc_id}
Get one MoC item enriched for the detail view. The moc_items table stores only the linkage; parameter, source_a, and source_b are joined from the linked knowledge_conflicts row, and blast_radius_count is computed from the affected document's graph traversal (best-effort).
Auth required: Yes
Response 200:
{
"moc_id": "MOC-2024-007",
"asset_id": "EQ-101",
"parameter": "max_allowable_pressure",
"source_a": { "value": "1200 psi", "document_id": "DOC-...", "edge_id": "..." },
"source_b": { "value": "1400 psi", "document_id": "DOC-..." },
"blast_radius_count": 3,
"status": "pending",
"created_at": "2024-02-20T10:00:00Z",
"draft_content": "..."
}Response 404: {"detail": "MoC '<id>' not found"}
POST /governance/moc/{moc_id}/approve
In-app MoC sign-off (engineer/admin authority — mirrors OPA can_resolve_moc). Marks the MoC approved, closes the superseded edge's validity window, and resolves the linked engineering-track conflict — the same effect as an approved MoC webhook, human-initiated. Shares the conflict-closing helper with the webhook path.
Auth required: Yes (engineer or admin)
Request body: { "note": "optional engineer note" }
Response 200: {"status": "approved", "moc_id": "..."}
Response 409: {"detail": "MoC already approved."} · 404: {"detail": "MoC '<id>' not found"}
POST /governance/moc/webhook
Receive an MoC resolution webhook from the plant MoC system.
Auth required: HMAC, not a user token. The plant's MoC system has no Kairos login, so this one route is exempt from OPA (_SIGNED_WEBHOOKS) and its signature is mandatory wherever auth is enforced.
Signature (verify_moc_webhook). Two headers are required:
| Header | Value |
|---|---|
X-Webhook-Timestamp | unix seconds the sender signed at |
X-Webhook-Signature | hex HMAC-SHA256, keyed by MOC_WEBHOOK_SECRET, over "{timestamp}." + raw request body |
The signature covers the body bytes exactly as received (the old check re-serialised the parsed JSON and did not cover a timestamp). A timestamp more than 5 minutes from the server clock, either way, is 401 Stale webhook timestamp, so a captured body cannot be replayed later; a missing header is 401; a bad signature is 401. With no MOC_WEBHOOK_SECRET the route is 503 anywhere auth is enforced, and the only unsigned path is development (APP_ENV=development with APP_DEBUG). Outside development the API refuses to boot without the secret. A body that is not a JSON object is 400. An already approved MoC is 409 (final, like the in-app approve route; this also stops an in-window replay flipping it to rejected).
Request body:
{
"moc_id": "MOC-2024-007",
"status": "approved",
"approved_by": "chief.engineer@plant.com",
"effective_date": "2024-03-01T00:00:00Z"
}status values: approved | rejected
On approved: closes the old validity window on the conflicting Neo4j edge and resolves the linked knowledge_conflicts row.
Response 200: {"status": "received", "moc_id": "...", "resolution": "approved"}
GET /governance/circuit-breaker
Get the current SPC circuit breaker state for all monitored entity types.
Auth required: Yes
One state per asset class that has recorded extraction overrides in the last 30 days. An asset class trips to halted: true when its 7-day override-count z-score exceeds 2.0; new extractions for that class are then queued for human review rather than written to the graph. An empty states array means no class has any override records yet (all ingestion paths open).
Response 200:
{
"states": [
{
"asset_class": "pump",
"halted": false,
"z_score": 1.2,
"reason": "within_normal_range",
"override_count_7d": 3
}
],
"halted_count": 0
}⚠️ Each state carries
asset_class+ booleanhalted(notentity_type/ astatusstring).reasonisz_score_exceeded | within_normal_range | stats_error. There is nooverride_rate,total_extractions_7d, orthresholdfield.
GET /governance/blast-radius/{document_id}
Get the blast-radius report for a proposed document change.
Auth required: Yes
Response 200:
{
"document_id": "DOC-TS4FXYKHCQEF",
"affected_count": 11,
"affected": [
{
"edge": {
"relationship_type": "DOCUMENTED_BY",
"edge_id": "PG-18_DOCUMENTED_BY_DOC-…_2026-07-17T20:37:03Z",
"authority_level": 5,
"confidence": 0.9,
"verification_status": "unverified",
"valid_from": "2026-07-17T20:37:03Z",
"valid_to": "9999-12-31T23:59:59Z",
"document_id": "DOC-…"
},
"source": {
"asset_id": "PG-18",
"name": "Local Gauge Bypass",
"equipment_class": "instrument_bypass"
},
"target": {
"document_id": "DOC-…",
"document_type": "shift_log"
}
}
]
}⚠️ Each pair is
{edge, source, target}+affected_count. The affected entity is the edgesource(e.g. the asset whose knowledge derives from this document);targetis usually the document node itself. Results are deduped byedge_idserver-side (re-runs can leave duplicate relationships). Nodes are heterogeneous graph nodes; read a label fromsource.name ?? source.tag_number ?? source.asset_id. The frontendgetBlastRadiusfetcher flattens each pair into{item_id, item_type, description, asset_id, flagged_for_review}(flagged = edgeverification_status != "verified").
GET /governance/validation-corpus/stats
Return validation corpus coverage statistics used by the model gate.
Auth required: Yes
Response 200:
{
"total_corpus_size": 42,
"by_entity_type": {
"ASSET_TAG": 18,
"process_parameter": 12,
"FAILURE_MODE": 12
},
"last_updated_at": "2024-01-05T09:00:00Z"
}The corpus is populated automatically when quarantine items are promoted (authority=human_promotion) or when annotations with is_correct=True are submitted (authority=annotation_correction).
POST /governance/model-gate/run
Trigger NER model gate evaluation against the validation corpus.
Auth required: Yes — admin only
Query params:
| Param | Type | Required | Description |
|---|---|---|---|
model_name | string | No | NER model to evaluate. Defaults to NVIDIA_NIM_NER_MODEL when omitted, so the UI can trigger a run without knowing the model name. |
Response 200:
{
"task_id": "celery-task-uuid",
"model_name": "meta/llama-3.2-11b-vision-instruct",
"status": "queued"
}The endpoint only enqueues the task and returns immediately. The Celery task runs on the validation queue and takes ~12 min on the 52-row corpus (one NIM call per unique document; the three partition cuts share a cache and add none). time_limit/soft_time_limit are 1860/1800 — the earlier 600/540 was calibrated on a run where nearly every call failed fast on a 429, and killed two real runs mid-flight. Results are written to audit_log with action=model_gate_result. The gate compares F1 against the incumbent baseline; if lower, the run is marked failed. The UI polls history and auto-refreshes when the run lands.
GET /governance/model-gate/history
Return the last 20 model gate run results.
Auth required: Yes
Response 200:
{
"items": [
{
"id": "uuid",
"entity_id": "meta/llama-3.2-11b-vision-instruct",
"details": {
"model_name": "meta/llama-3.2-11b-vision-instruct",
"precision": 0.7234,
"recall": 0.85,
"f1": 0.7816,
"passed": true,
"validity": "VALID",
"fallback_extractions": 0,
"extraction_paths": {"nim": 27},
"corpus_size": 52,
"scored_labels": 40,
"unscoreable_labels": 12,
"unscoreable_by_type": {"COMPONENT": 12},
"by_entity_type": {"ASSET_TAG": {"precision": 1.0, "recall": 0.8333, "f1": 0.9091, "count": 30}},
"by_asset_class": {"he-3xx_series": {"precision": 1.0, "recall": 0.9, "f1": 0.9474, "corpus_size": 10}},
"by_document_type": {"oem_manual": {"precision": 0.4615, "recall": 0.8571, "f1": 0.6, "corpus_size": 7, "scored_labels": 7}},
"enforcement": "advisory_only",
"regressed_asset_classes": [],
"blocked_asset_classes": []
},
"timestamp": "2026-08-23T09:05:00Z"
}
],
"total": 1
}Shape is contract-locked to
{items, total}(rawaudit_logrows) bytests/test_contract.pyandtests/test_governance.py. The frontendgetModelGateHistoryadapter flattens each row'sdetailsinto the UIModelGateResultshape and returns{ history: [...] }.
Reading a run — three fields decide whether the score means anything.
validity—"VALID"only when every extraction reached the model. The NER service degrades to a regex last resort that emitsASSET_TAGand nothing else, so a"SUSPECT"run is scoring the fallback under the model's name. A run withoutvalidity: "VALID"is not a measurement. Entries written before 2026-08-23 have novaliditykey at all and must not be quoted; only aVALIDrun is eligible to serve as the baseline a later run is compared against.scored_labelsvscorpus_size—f1coversscored_labels, not the whole corpus. Ground truth whoseentity_typeis outside the extractor's 10-type prompt taxonomy cannot be scored against it and is reported inunscoreable_by_typeinstead of counted as failure. Quote the F1 with its denominator.passed— means "no regression against the baseline", and is orthogonal tovalidity. With no eligible baseline it istrueby default, which is a fresh-install state rather than a pass.
by_document_type is the cut the problem statement asks for and costs no extra model calls: all
three partitions share the run's extraction cache.
GET /governance/push-volume-gate
EEMUA 191 pilot monitoring gate — the architecture's Phase 3 precondition.
Computes peak per-operator-per-hour push volume over a rolling window (an average would hide exactly the bursts EEMUA 191 exists to prevent).
enforcement: "advisory_only" — this never blocks Phase 3 at runtime. A deployment with under
30 days of history would otherwise be unable to deliver briefs at all, which is a worse failure than
the one it prevents. Phase activation stays a deliberate KAIROS_PHASE decision, informed by this.
Response 200: {window_days, ceiling_per_operator_per_hour, peak_per_operator_per_hour, breach_count, breaches[], briefs_delivered, within_eemua_norms, current_phase, enforcement}
GET /governance/timestamp-drift
Cross-source clock drift (Layer 4). Compares the same correlated event as reported by different
source systems, reusing the compound_event_id grouping Layer 8 already builds.
Deliberately not
occurred_atvsingested_at— a document that occurred months before it was ingested is history, not skew, and that comparison would flag an entire golden corpus.
Report-only while TIMESTAMP_DRIFT_ENFORCE=false: drift is surfaced but opens no conflict row.
Response 200: {compound_events_checked, drift_detected_count, tolerance_minutes, enforcement, items[]}
9. Compliance
Prefix: /compliance
Regulatory gap detection against 12 seeded frameworks (OISD-117, ISO 45001, and 10 others). Seed via docker exec kairos-backend-api python scripts/seed_regulations.py.
GET /compliance/gaps
Test assets are excluded, and the count is reported. This endpoint,
GET /compliance/dashboard,GET /compliance/audit-packandGET /assets/coverageskip assets whose id starts with a test prefix (QA-TEST-and the integration suite'sASSET-*, seeservices/corpus.py) inside the query, and each response carriesexcluded_test_assets: how many were hidden (site-scoped where the query is).
Evaluates every applicable (clause × asset) pair and returns the ones that are not
cleared. A clause is covered for an asset when the asset has an active, non-superseded
edge to a Document of the evidence type that clause requires (requires_document_type,
seeded per clause in scripts/seed_regulations.py).
Two kinds of finding are returned:
status | Meaning |
|---|---|
gap | No document of the required type is linked to the asset |
unverified_evidence | Such a document exists, but no human has verified the edge |
Covered pairs are not returned. severity derives from the clause's authority_level:
level 1 → critical, level 2 → major, everything else → minor.
Auth required: Yes (compliance or admin)
Query params: asset_id, framework, severity (critical | major | minor),
status (gap | unverified_evidence), limit (≤500), offset
Response 200:
{
"items": [
{
"concept_id": "OISD-117-4.1.1",
"framework": "OISD_117",
"clause_id": "4.1.1",
"requirement_text": "Rotating equipment (pumps) shall have documented maintenance procedures...",
"applies_to": "pump",
"requires_document_type": ["procedure"],
"authority_level": 1,
"asset_id": "EQ-103",
"tag_number": "EQ-103",
"equipment_class": "rotating_centrifugal_pump",
"site_id": "SITE-1",
"evidence_count": 0,
"verified_count": 0,
"status": "gap",
"severity": "critical"
}
],
"total": 52,
"gap_total": 37,
"unverified_total": 15,
"limit": 100,
"offset": 0,
"framework": null,
"last_scan": "realtime"
}Accuracy of this endpoint is measured by
benchmark/run_compliance_eval.py, which scores gap precision/recall against ground truth derived independently from the dataset manifest.
GET /compliance/dashboard
Compliance posture summary: counts by framework, severity and equipment class.
total_gaps counts only true gaps (no evidence of the required type).
total_unverified_evidence counts pairs where evidence exists but is unverified —
conflating the two is what previously made every clause read as non-compliant.
by_framework / by_asset_class break down gaps only.
Auth required: Yes (compliance or admin)
Response 200:
{
"site_id": null,
"total_gaps": {"critical": 12, "major": 40, "minor": 0},
"total_unverified_evidence": {"critical": 3, "major": 12, "minor": 0},
"by_framework": {
"OISD_117": {"critical": 12, "major": 0, "minor": 0},
"ISO_45001": {"critical": 0, "major": 40, "minor": 0}
},
"by_asset_class": {
"pump": {"critical": 6, "major": 0, "minor": 0},
"valve": {"critical": 6, "major": 0, "minor": 0}
},
"last_updated": "realtime"
}⚠️
total_gapsis an object{critical, major, minor}, not a number. Severity buckets arecritical | major | minor(nothigh/medium).by_frameworkandby_asset_classare keyed by those same severity buckets. There are nocleared/open/by_severityfields. Sum the three buckets to get a grand total; never rendertotal_gapsdirectly.
GET /compliance/audit-pack
Generate an audit evidence package (all gaps + evidence + clearance status).
Auth required: Yes (compliance or admin)
Query params: framework (optional filter)
Response 200:
{
"generated_at": "2024-01-01T00:00:00Z",
"framework": "OISD-117",
"total_clauses": 24,
"covered": 14,
"clearance_blocked": false,
"evidence": [
{
"clause_id": "6.4.2",
"status": "covered",
"document_id": "doc-oisd-evidence",
"clearance_blocked": false
}
]
}GET /compliance/frameworks
List all configured regulatory frameworks.
Auth required: Yes
Response 200:
[
{
"framework_id": "OISD-117",
"name": "OISD Standard 117 — Inspection of Pressure Vessels",
"clause_count": 24,
"equipment_classes": ["vessel", "pressure_vessel"]
}
]10. Elicitation
Prefix: /elicitation
Knowledge gap elicitation via AI-generated micro-interviews and off-boarding programmes. Micro-interview responses and off-boarding responses are stored in quarantine_items for human review before graph promotion.
POST /elicitation/trigger
Evaluate whether elicitation should be triggered. Three conditions trigger it:
- Failure code not seen in the last 6 months for this asset
- Asset has no knowledge graph edges (uninstrumented)
- Work order priority is
criticalorurgent
Auth required: Yes
Request body:
{
"work_order_id": "WO-2024-001",
"asset_id": "P-101",
"failure_code": "SEAL-LEAK-EXT",
"priority": "high"
}Response 200 — triggered:
{
"work_order_id": "WO-2024-001",
"triggered": true,
"reason": "rare_failure_code",
"session_id": "session-uuid",
"workflow_id": "temporal-workflow-id"
}Response 200 — not triggered: {"triggered": false, "reason": "failure_code_seen_recently"}
GET /elicitation/{work_order_id}/questions
Get generated interview questions (available after workflow reaches status=questions_ready).
Auth required: Yes
Response 200:
{
"work_order_id": "WO-2024-001",
"status": "questions_ready",
"questions": [
"The previous two failures on this asset were attributed to lubrication intervals. Did the component condition suggest a different root cause this time?",
"Were there operating changes in the days before the leak was noticed?"
]
}Questions are a plain
string[], not objects. Corrected 2026-08-16 — this block previously showed{question_id, text, type}objects, which the endpoint has never returned. The question text is therefore the identifier when submitting answers (seePOST …/responsesbelow).
An empty questions array means the Temporal workflow is still generating them — it makes a model
call per session, so poll rather than treating the first empty response as "no questions".
Returns 404 if no session exists yet. Sessions are event-triggered (POST /elicitation/trigger);
there is no session for an arbitrary work order id.
POST /elicitation/{work_order_id}/responses
Submit Q&A responses. Stored in quarantine_items for expert review before graph promotion.
Auth required: Yes
Request body:
{
"responses": [
{"question": "Did the bearing housing show thermal cycling?", "answer": "Yes — discolouration on the outboard face."},
{"question_index": 1, "answer": "Shares the discharge header with EQ-102."}
],
"submitted_by": "field_worker@example.com"
}Questions are a string[], so the question text is the identifier. question_index is
accepted as an alternative for callers holding the position instead; supply one or the other.
submitted_by is accepted for older clients and ignored: the submitter is always the authenticated user,
as on POST /elicitation/offboarding/{session_id}/responses.
Corrected 2026-08-16. This block previously documented
session_idandquestion_id, neither of which the endpoint has ever accepted. The request model was also an untypedlist[dict[str, str]], so a caller passing an integer got"Input should be a valid string"pointing at a key they had invented, with nothing indicating the real one. It is now a typedElicitationAnswermodel.
Response 200:
{
"item_id": "q-item-uuid",
"status": "quarantined"
}Responses land in quarantine, never the canonical graph — elicitation output requires human promotion (architecture Layer 9). The question context is stored alongside the answers so a reviewer can see exactly what was asked.
POST /elicitation/{work_order_id}/voice
Submit a voice note for a work order. Transcribed via Groq Whisper, NER extracted, stored as a quarantine_items entry.
Auth required: Yes
Request: multipart/form-data
| Field | Type | Required | Description |
|---|---|---|---|
file | binary | Yes | Audio file (WAV, MP3, M4A, FLAC). Over MAX_UPLOAD_MB is 413, read in 1 MB chunks so an unsized upload is never held whole in memory |
submitted_by | string | No | Ignored: the submitter is the authenticated user |
The object path is voice_notes/{work_order_id}/{sha8}_{name} with both segments reduced to [A-Za-z0-9._-] (directory parts and leading dots dropped), so a name such as ../../x cannot escape the folder.
Processing pipeline:
- SHA-256 dedup check — returns existing item if same audio file submitted twice
- Upload to Supabase Storage (
kairos-vaultbucket) - Celery task on
transcriptionqueue → Groqwhisper-large-v3transcription - NER extraction on transcript text
- Insert into
quarantine_itemswithinput_type=voice_note
Response 202:
{
"status": "accepted",
"work_order_id": "WO-2024-001",
"task_id": "celery-task-uuid",
"storage_path": "voice_notes/WO-2024-001/abc12345_note.wav",
"sha256": "abc123...",
"message": "Voice note stored. Transcription and NER running asynchronously."
}POST /elicitation/offboarding
Start an off-boarding interview programme for a departing employee. Identifies the top equipment families from the employee's work order history (up to 6), creates one session item per family, and schedules a Celery task to generate questions for each.
Auth required: Yes — engineer or admin
Request body:
{
"personnel_id": "tech-uuid",
"personnel_email": "john.smith@plant.com",
"retirement_date": "2024-06-30",
"session_interval_days": 12
}session_interval_days — spacing between successive interview sessions (default 12).
Response 201:
{
"session_id": "session-uuid",
"personnel_id": "tech-uuid",
"total_sessions": 6,
"items": [
{
"item_id": "item-uuid",
"session_number": 1,
"equipment_family": "centrifugal_pump",
"scheduled_for": "2024-01-01T00:00:10Z"
}
]
}Session 1 fires in 10 seconds (demo/test). Subsequent sessions are spaced by session_interval_days.
GET /elicitation/offboarding
List all active off-boarding programmes with completion percentage.
Auth required: Yes
Response 200:
{
"items": [
{
"id": "session-uuid",
"personnel_id": "tech-uuid",
"personnel_email": "john.smith@plant.com",
"retirement_date": "2024-06-30",
"total_sessions": 6,
"status": "scheduled",
"sessions_completed": 1,
"completion_pct": 17,
"created_at": "2024-01-01T00:00:00Z"
}
],
"total": 1
}GET /elicitation/offboarding/{session_id}
Get a specific off-boarding programme with all session items and their statuses.
Auth required: Yes
Response 200:
{
"id": "session-uuid",
"personnel_id": "tech-uuid",
"personnel_email": "john.smith@plant.com",
"retirement_date": "2024-06-30",
"total_sessions": 6,
"status": "scheduled",
"session_items": [
{
"id": "item-uuid",
"session_number": 1,
"equipment_family": "centrifugal_pump",
"status": "completed",
"scheduled_for": "2024-01-01T00:00:10Z",
"completed_at": "2024-01-01T00:05:00Z"
}
]
}404 if not found — including when {session_id} is not a well-formed UUID.
{session_id}is validated before the query runs (dependencies.valid_offboarding_session_id, a sibling ofvalid_quarantine_item_id).offboarding_sessions.idis a UUID column, so an unparseable segment previously reached PostgREST as22P02and surfaced as a 500 on a public route —GET /elicitation/offboarding/sessionswas the reported case, since there is no/sessionsroute and the literal was matched by/{session_id}. A well-formed id for an absent programme 500'd too, because.single()raisesPGRST116on zero rows and left each handler's own 404 unreachable; the handlers use.maybe_single(). Both halves apply to all three/{session_id}routes. Fixed 2026-08-23, pinned bytests/test_offboarding_session_id.py.
GET /elicitation/offboarding/{session_id}/questions
Return questions for all session items in this programme (items with status=questions_ready).
Auth required: Yes
Response 200:
{
"session_id": "session-uuid",
"total_items": 6,
"items_ready": 1,
"items": [
{
"id": "item-uuid",
"session_number": 1,
"equipment_family": "centrifugal_pump",
"status": "questions_ready",
"questions": ["..."],
"scheduled_for": "2024-01-01T00:00:10Z"
}
]
}POST /elicitation/offboarding/{session_id}/responses
Submit responses for one session item. Stores in quarantine_items with input_type=offboarding_response.
Auth required: Yes
Request body:
{
"item_id": "item-uuid",
"responses": [
{"question_index": 0, "answer": "I check the mechanical seal weekly for any signs of leakage"},
{"question_index": 1, "answer": "The most common failure is bearing wear, usually after 18 months"}
],
"submitted_by": "tech-uuid"
}question_index is an integer (0-based). submitted_by is accepted and ignored: the submitter is the authenticated user. Off-boarding programmes are restricted to staff (engineer, reliability, admin) and the person being offboarded (matched on id or e-mail): the list returns only programmes the caller may see, and reading, fetching questions for, answering or completing any other programme is 403 (unknown id 404).
Response 200:
{
"session_id": "session-uuid",
"item_id": "item-uuid",
"quarantine_item_id": "q-item-uuid",
"status": "completed",
"message": "Responses stored in quarantine for expert review"
}11. Annotations
Prefix: /annotations
Active learning annotation interface. Allows human reviewers to correct NER extraction errors. Each correction reduces the confidence of the associated quarantine item by 0.1 and is logged for circuit breaker monitoring.
POST /annotations/
Submit a NER correction annotation.
Auth required: Yes (engineer or admin)
Request body:
{
"document_id": "doc-uuid",
"entity_type": "process_parameter",
"original_value": "12.5 bar",
"corrected_value": "15.0 bar",
"is_correct": false,
"notes": "Wrong vessel — this value applies to V-201, not P-101"
}Side effects:
- Inserts annotation into
ner_annotations - If
is_correct=false: reducesquarantine_items.confidenceby 0.1 for any pending quarantine item with matchingdocument_id+entity_type; writesaudit_logentry withaction=confidence_recheck_queued; records a circuit breaker override for the entity type - If
is_correct=true: adds tovalidation_corpuswithauthority=annotation_correction, but only forreliabilityandadmin: the corpus is the ground truth the model gate scores against. Any other role still records the annotation, which just does not become ground truth - Anti-gaming: at most 60 corrections per user per hour (
429). A user's repeat corrections on the same document add one circuit-breaker override, and correcting the same entity again does not lower its quarantine confidence a second time
Response 201:
{
"annotation_id": "ann-uuid",
"document_id": "doc-uuid",
"entity_type": "process_parameter",
"is_correct": false,
"quarantine_confidence_updated": true
}GET /annotations/
List annotations, optionally filtered by document.
Auth required: Yes
Query params: document_id (optional)
Response 200: Array of annotation objects.
GET /annotations/stats
Aggregated annotation statistics for dashboard display.
Auth required: Yes
Response 200:
{
"total_annotations": 42,
"corrections_this_week": 6,
"top_corrected_entity_types": [
{"entity_type": "FAILURE_MODE", "count": 3},
{"entity_type": "process_parameter", "count": 2}
]
}12. Audit Log
Prefix: /audit-log
Immutable audit trail. Every write operation in Kairos appends an entry. Read-only API.
Note: The time column is
timestamp, notcreated_at.
GET /audit-log/
Query the audit log with optional filters.
Auth required: Yes (engineer or admin)
Query params:
| Param | Type | Description |
|---|---|---|
action | string | Filter by action type |
entity_type | string | Filter by entity type (document, brief, asset, …) |
entity_id | string | Filter by entity ID |
limit | int | Max results (default 50) |
Common action values: brief_acknowledged · confidence_recheck_queued · plant_state_changed · rca_pack_generated · timestamp_drift_detected · attribution_flag · quarantine_promoted · quarantine_disputed · info_requested · moc_resolved · moc_webhook_received · circuit_breaker_override · equipment_tag_out · sla_escalated · model_gate_result · offboarding_programme_created · recurring_failure_detected
Response 200:
{
"items": [
{
"id": "uuid",
"action": "rca_pack_generated",
"entity_type": "asset",
"entity_id": "P-101",
"performed_by": "engineer@example.com",
"details": {
"failure_code": "SEAL-FAIL",
"timeline_events": 18,
"synthesis_available": false
},
"timestamp": "2026-07-02T12:05:00Z"
}
],
"total": 1
}13. Go OT Connector (port 8090)
GET /ot/coverage/{asset_id}was deleted (2026-08-16). It returned hardcoded sensor tags for every asset. Instrumentation coverage is now derived from verified topology atGET /assets/{asset_id}/ot-coverage.GET /ot/connectorswas added (see/health/connectors).
Base URL: http://localhost:8090
The Go connector (Gin) bridges OT historian data and EAM sync with the FastAPI backend. Uses INTERNAL_API_KEY as a service bearer token when calling FastAPI.
Authentication. Every route except GET /health requires X-Connector-Secret: <CONNECTOR_SHARED_SECRET> (/ot and /eam groups, constant-time compare; otherwise 401 {"error": "unauthorized"}). The connector refuses to start without CONNECTOR_SHARED_SECRET, and when APP_ENV is not development it also refuses the dev defaults for CONNECTOR_SHARED_SECRET and INTERNAL_API_KEY. The FastAPI side (/health/connectors) and the attribution worker send the header from the same env var. Request bodies are capped at 1 MiB (413) and upstream replies read at 4 MiB; upstream failure text is logged, never echoed to the caller (generic upstream request failed, request failed). In the dev compose override the port is published on 127.0.0.1 only.
GET /health
Connector liveness probe (unauthenticated, no data; used by the Docker healthcheck).
Response 200: {"status": "ok"}
GET /ot/query
Query historian time-series. Uses PIWebAPIClient if PI_WEBAPI_BASE_URL is configured, otherwise MockHistorianClient (50 sine-wave vibration points, mean≈1.8 mm/s, 2-minute span).
Query params:
| Param | Type | Description |
|---|---|---|
tag | string | Historian tag (e.g. P-101.VIB) |
from | ISO8601 | Start time |
to | ISO8601 | End time |
Response 200:
{
"tag": "P-101.VIB",
"from": "2024-01-01T22:00:00Z",
"to": "2024-01-01T22:02:00Z",
"points": [
{"timestamp": "2024-01-01T22:00:00Z", "value": 1.82, "quality": "good"}
],
"count": 50,
"mock": true
}mock: true when using MockHistorianClient. OT data is ephemeral — never stored in Kairos.
POST /eam/sync
Sync EAM assets into Kairos. Reads fixtures/sample_assets.json if EAM_ODS_ENDPOINT is not configured (5 assets: P-101, V-201, HX-301, C-401, T-501). POSTs each to FastAPI POST /assets.
Response 200:
{
"synced": 5,
"failed": 0,
"assets": ["P-101", "V-201", "HX-301", "C-401", "T-501"]
}POST /eam/work-order
Proxy an EAM work order into Kairos event ingestion. Forwards raw body to FastAPI POST /events/work-order.
Request body: Same as POST /events/work-order.
Response: Proxied response from FastAPI.
14. Error Codes
| HTTP Status | When |
|---|---|
200 | Success |
201 | Resource created |
202 | Accepted — async operation started |
400 | Malformed request body / missing required field |
401 | Missing or invalid JWT |
403 | OPA policy denied (wrong role for the route) |
404 | Resource not found |
409 | Conflict (asset id already registered, event_id reused with a different payload, brief already acknowledged, MoC already approved, rejected supersede MoC) |
413 | Upload larger than MAX_UPLOAD_MB (25 MB; document and voice-note uploads). Caddy refuses bodies over 30 MB before they reach the API |
422 | Pydantic validation error (field type mismatch, or a request over a size cap) |
429 | Per-IP rate limit (RATE_LIMIT_PER_MINUTE, enforced whenever APP_ENV is not development), or more than 60 annotation corrections per user per hour |
503 | MoC webhook with no MOC_WEBHOOK_SECRET configured |
500 | Internal server error — check docker logs kairos-backend-api 2>&1 | tail -30 |
OPA 403 response shape:
{
"detail": "Access denied by policy",
"required_action": "promote_quarantine",
"user_role": "field_worker"
}Request size caps (all 422): POST /search/synthesize and /synthesize/stream take a query of at most 2000 characters, as_of at most 64, context at most 50 items; rca-pack failure_code at most 200; POST /assets/bulk 1 to 5000 rows; the feedback note at most 2000 and the OCR review note at most 1000. Error responses never carry upstream exception text: Supabase foreign-key violations return A referenced record does not exist., unique violations A record with these identifiers already exists., and storage and signing failures fixed messages.
Pydantic 422 response shape:
{
"detail": [
{
"type": "missing",
"loc": ["body", "confirmed_by_user_id"],
"msg": "Field required"
}
]
}15. Auth Quick-Reference
# Get a token
TOKEN=$(curl -s -X POST http://localhost:8000/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"admin@example.com","password":"'"$KAIROS_SEED_PASSWORD_ADMIN"'"}' \
| jq -r .access_token)
# Use it
curl -H "Authorization: Bearer $TOKEN" http://localhost:8000/assets/P-101
# Liveness (no auth needed); /health/detailed needs a token
curl http://localhost:8000/health/
curl -H "Authorization: Bearer $TOKEN" http://localhost:8000/health/detailed
# Internal service call (Go connector pattern)
curl -H "Authorization: Bearer <dev-internal-key>" \
http://localhost:8000/assets/P-101
# Dev shortcut: omit Authorization header when APP_DEBUG=True and APP_ENV=development
# Treated as {user_id: "dev-user", role: "engineer", site_id: "SITE_001"}
curl http://localhost:8000/assets/P-101Role permission matrix:
| Role | Can do |
|---|---|
field_worker | Read search, read briefs, ack briefs, flag deviations, read plant state. 403 on audit-log, compliance, governance, documents, the events feed and every event-ingest route |
engineer | Above + ingest documents and events (ingest_event), write assets, read/resolve governance, read documents + events + audit + compliance, start offboarding |
reliability | Engineer's reads and ingests + promote quarantine, countersign briefs, assert authority 1 to 3 at ingest, supersede authority 1 to 3 documents (no asset write) |
compliance | Read search, compliance, audit, non-conformance (conflicts + quarantine) and events. Not the model gate, MoC, circuit breaker or documents |
admin | Everything, including the cross-site view |
Verify the policy's decisions with tools/verify_authz_policy.sh (39 cases against a throwaway
OPA, safe to run while the stack is up). That checks the policy is correct; to check it is
reached, probe the live API with a restricted persona and confirm a 403 —
curl -H "Authorization: Bearer $FIELD_TOKEN" localhost:8000/audit-log/ must not return 200.
Appendix: OpenAPI
- Swagger UI:
http://localhost:8000/docs - ReDoc:
http://localhost:8000/redoc - OpenAPI JSON:
http://localhost:8000/openapi.json