Architecture — AIOps Control Deck
The diagram set: system context, components, the closed loop, the key sequences and the state machines, all generated from the code.
Concept · Updated 2026-10-24 · Describes release 0.1.164
Software design reference for the AIOps platform POC. Narrative + decision record live in ../DESIGN.md; the data model lives in db-schema.md. This file is the diagram set: system context, components, the closed loop, the key sequences, and the state machines — all generated from the code (app/aiops/*, app/api/aiops.py, app/models.py).
Every diagram is Mermaid and renders in the Azure DevOps repo/wiki preview and on GitHub.
1. System context
Who and what touches the platform.
flowchart TB
subgraph actors["People"]
sre["SRE / Operator<br/>(tenant member)"]
approver["Approver<br/>(signs off remediations)"]
clevel["Executive / C-level<br/>(insights chat — read-only,<br/>builtin executive role)"]
staff["Platform staff<br/>(superadmin — NO tenant data<br/>without support grant)"]
end
subgraph sources["Signal sources (per tenant)"]
am["Alertmanager"]
cw["CloudWatch → SNS"]
azm["Azure Monitor"]
graf["Grafana / Datadog"]
end
platform["**AIOps Control Deck**<br/>FastAPI · Jinja/HTMX UI · MCP servers"]
subgraph ext["External services"]
llm["LLM provider<br/>(litellm: Bedrock Nova /<br/>OpenAI-compatible)"]
clouds["Cloud + observability APIs<br/>(AWS/Azure/GCP, Prometheus,<br/>Grafana, k8s via MCP)"]
git["Git host<br/>(GitHub / GitLab / Azure Repos)"]
idp["OIDC IdPs<br/>(platform issuer + per-tenant<br/>TenantIdP: Entra / Keycloak realm)"]
end
db[("Postgres / SQLite")]
sec[["Encrypted secret store<br/>(Fernet)"]]
lakedb[("Per-tenant DuckDB lakehouse<br/>(PII-scrubbed telemetry,<br/>entitlement-gated)")]
sources -- "webhook (token)" --> platform
sre -- "portal / REST / MCP" --> platform
approver -- "approve · deny · edit" --> platform
clevel -- "ask (chat.ask perm)" --> platform
staff -- "admin console + audited support access" --> platform
platform -- "completion + tool calls" --> llm
platform -- "read (signed) / gated write" --> clouds
platform -- "scheduled telemetry pulls" --> clouds
platform -- "gitops_pr = open PR, never touch live" --> git
platform -- "login — issuer discovered<br/>by email domain" --> idp
platform --- db
platform --- sec
platform --- lakedb
2. Components (containers within the single app)
One FastAPI process. app/aiops/runtime.install() is the single wiring seam that connects ingest → engine → API/MCP/scheduler (it wires module-level ENGINE_STARTER/ENGINE_RESUMER seams — it takes no arguments).
flowchart LR
subgraph edge["HTTP surface"]
webhook["ingest.py<br/>/webhook/{source}"]
rest["api/aiops.py<br/>/api/aiops/*"]
ui["ui_aiops.py + Jinja<br/>/portal · /admin"]
mcpt["mcp_tenant.py<br/>/mcp/aiops"]
mcpa["mcp_admin.py<br/>/mcp/admin"]
end
subgraph brain["Agent brain (our IP)"]
runtime["runtime.py<br/>STARTER / RESUMER seams<br/>+ per-tenant semaphore"]
engine["engine.py<br/>tool-calling loop + HITL pause"]
pillars["pillars.py<br/>(tenant,pillar) build factory"]
routing["models_routing.py<br/>tier map → model · ladder ·<br/>escalation triggers · suggestions"]
tools["tools.py<br/>connectors → tools<br/>(api/cli/mcp + cloud natives)"]
remediate["remediate.py<br/>execute_route() (route button)"]
memory["memory.py<br/>feedback → SkillProposal"]
scheduler["scheduler.py<br/>cron pillars + TTL sweeps<br/>+ lake collect/retention ticks"]
collector["collector.py<br/>CollectionSpec pull loop<br/>(feature-gated)"]
pii["pii.py<br/>PII scrubber<br/>(salted pseudonyms)"]
chat["chat.py<br/>insights chat<br/>(perm-filtered registry)"]
xray["xray.py + grounding.py + xray_ledger.py<br/>estate x-ray mission loop<br/>(read-only wrapped, budgeted, grounded)"]
artifacts["artifacts.py<br/>create_artifact chokepoint<br/>(scrub + safe_url on write)"]
lakehouse[("lake.py<br/>per-tenant DuckDB<br/>bronze + silver")]
end
subgraph authz["Auth & tenancy"]
auth["auth.py + oidc_bearer.py<br/>session / PAT / OIDC bearer<br/>(multi-issuer: global + TenantIdP)"]
az["aiops/authz.py<br/>get_tenant_perms · require_perm<br/>(disabled tenant fails closed)"]
secrets["secrets.py<br/>Fernet secret store"]
kcp["aiops/kc_provision.py<br/>realm-per-tenant SSO provisioning<br/>(superadmin one-click)"]
end
db[("DB")]
stream["stream.py<br/>in-proc SSE pub/sub"]
llm["litellm"]
kc["Keycloak admin API<br/>(AIOPS_KEYCLOAK_ADMIN_URL)"]
webhook --> runtime
rest --> az
ui --> az
mcpt --> az
mcpa --> auth
rest --> runtime
mcpt --> runtime
scheduler --> runtime
runtime --> engine
engine --> pillars
engine --> tools
pillars -- "tier → model resolve" --> routing
runtime -- "pre-run escalation" --> routing
rest -- "tier suggest / discovery" --> routing
engine --> stream
runtime --> secrets
remediate --> secrets
remediate --> tools
rest --> remediate
rest --> memory
rest --> chat
rest --> xray
xray --> engine
xray -- "report artifact" --> artifacts
xray -- "forced scrub" --> pii
remediate -- "PR artifact (via rest)" --> artifacts
scheduler --> collector
collector --> pii
collector --> lakehouse
chat --> engine
chat -- "final egress scrub" --> pii
engine -- "lake_query (read-only)" --> lakehouse
engine --> llm
rest -- "provision-keycloak" --> kcp
kcp --> kc
edge --> db
brain --> db
az --> db
stream --> rest
3. The closed loop (pipeline)
flowchart TB
A["Alerts fire<br/>Alertmanager · CloudWatch · Azure Monitor · Datadog"]
B["**ingest.py** — adapter normalizes to canonical labels<br/>dedupe (tenant+fingerprint)"]
C{"correlate:<br/>shared key within<br/>window?"}
D["join existing Incident<br/>child alert **suppressed**<br/>(the 300→1 story)"]
E["new **Incident** + **Investigation**<br/>route pillar (security-ish → secops)"]
F["**engine.py** — agentic loop<br/>LLM ⇄ tools from tenant datasources"]
G{"tool needs<br/>write / approval?"}
H["**ApprovalRequest** — loop PAUSES<br/>incident = awaiting_approval"]
I["human: approve · deny · edit<br/>(portal / REST / MCP)"]
X["**engine.py** executes the approved tool<br/>ToolRegistry.invoke → incident remediating"]
O["**engine wrap-up** → Investigation.rca_markdown<br/>(LLM structured RCA — persisted)"]
P["**memory.py** — feedback → SkillProposal<br/>promote → SKILL.md → next run loads it"]
OP["operator: **Remediate via route** button<br/>(POST .../remediate — the click IS the approval)"]
J["**remediate.py** execute_route()<br/>first RemediationRoute by priority"]
M{"route<br/>matched?"}
K["cli · api · mcp<br/>(throwaway ToolRegistry)"]
L["**gitops_pr**<br/>branch + commit + PR<br/>live infra NEVER touched"]
N["**escalate to human**<br/>(never guess a write path)"]
A --> B --> C
C -- yes --> D
C -- no --> E
D --> F
E --> F
F --> G
G -- no (read) --> F
G -- yes --> H --> I
I -- approve/edit --> X --> F
I -- deny --> F
F -- wrap-up --> O
OP --> J --> M
M -- yes --> K
M -- yes --> L
M -- no --> N
K --> O
L --> O
N --> O
O --> P
P -. "feeds the next investigation" .-> F
Two distinct write paths: (a) a gated tool the agent proposed mid-loop is executed by the engine itself on approval (engine.resume → ToolRegistry.invoke); (b) the operator-triggered Remediate via route button (POST .../incidents/{id}/remediate) runs remediate.execute_route() — the RemediationRoute channels (cli/api/mcp/gitops_pr), where the button click is itself the human approval. rca.py is a deterministic row-backed RCA renderer that ships in the repo but is not yet wired — today the persisted Investigation.rca_markdown is the engine's LLM wrap-up.
Alert resolution auto-closes an incident only from open/investigating — never from awaiting_approval/remediating, where a human holds the loop (ingest.py).
4. Sequence — alert storm → RCA (the demo path)
sequenceDiagram
autonumber
participant Src as Alert source
participant IN as ingest.py
participant DB as DB
participant RT as runtime.py
participant EN as engine.py
participant LLM as litellm
participant SSE as stream.py (SSE)
participant H as Human (portal)
participant RM as remediate.py
participant Cloud as Cloud / Git
Src->>IN: POST /webhook/{source}/{tenant_slug}/{token}<br/>(token via path or Authorization header)
Note over IN: adapter → canonical labels,<br/>dedupe by fingerprint
IN->>DB: 5 Alerts, correlate → 1 Incident<br/>(4 suppressed) + 1 Investigation
IN-->>Src: 200 {incidents_created, suppressed, ...}
IN->>RT: ENGINE_STARTER(inv_id) [detached task,<br/>per-tenant semaphore]
RT->>EN: run(tenant, pillar, incident)
loop until answer or max_steps
EN->>LLM: messages + tool schemas
LLM-->>EN: tool call (e.g. prometheus_query)
EN->>Cloud: read (signed / gated)
Cloud-->>EN: result (truncated)
EN->>SSE: publish tool event
EN->>DB: persist conversation + tool_calls
end
Note over EN: proposes a WRITE (restart / PR)
EN->>DB: ApprovalRequest(pending)<br/>incident → awaiting_approval
EN->>SSE: publish approval event
EN-->>RT: paused_approval
H->>DB: POST .../investigations/{id}/decisions (approve)
DB->>RT: ENGINE_RESUMER(inv_id)
RT->>EN: resume — execute the APPROVED TOOL directly
EN->>Cloud: approved write (ToolRegistry.invoke)
Cloud-->>EN: result → incident remediating
EN->>DB: RCA wrap-up (Investigation.rca_markdown)<br/>incident → resolved / escalated
EN->>SSE: publish done
opt operator uses the Remediate-via-route button (separate path)
H->>RM: POST .../incidents/{id}/remediate → execute_route()
alt route matches
RM->>Cloud: gitops_pr → open PR (no live change)
RM-->>H: {ok, artifact: PR url}
else no match
RM-->>H: escalate
end
end
H->>DB: 👎 + correction → Feedback → SkillProposal
Model tier routing inside this sequence (models_routing.py — deterministic, our code decides, never an LLM): the loop calls run on the tenant's standard-tier model and the RCA wrap-up on reasoning (a PillarAgent.model override pins both and disables switching). A severity=critical or prod-labelled incident starts the loop on the reasoning tier — decided once at start; resume does NOT re-derive it, the recorded routing: system note in the conversation re-applies the switch. Mid-run, 3 byte-identical tool calls or 3 parse-failure turns climb one configured ladder step, and at 70% of the cost cap the loop drops one (once each per run; wrap-up never downgraded, and after a budget downgrade escalation stays off — budget beats escalation). The switch note is inserted at the top of the loop iteration, so it never splits an assistant tool_calls message from its tool results; every switch also lands a model.escalated/model.downgraded audit row, and per-model spend accumulates in Investigation.model_usage.
Structured wrap-ups request a finite 32,768-token completion allowance, including provider reasoning usage, clamped to a smaller known model output limit from the loaded catalogue. A provider length finish reason permits one concise, complete rewrite with the same schema and no tools, subject to cancellation and the run's remaining budget. Both responses are accounted for and their finish reasons recorded. Partial JSON is never accepted as a remediation plan.
5. Sequence — auth + RBAC permission resolution
Three credential types collapse to a User; then a per-tenant permission set decides the call. No superadmin bypass: staff without a membership get 404 on tenant data.
sequenceDiagram
autonumber
participant C as Client (browser / MCP / curl)
participant A as auth.py (current_user)
participant Z as aiops/authz.py
participant DB as DB
alt browser
C->>A: session cookie
A->>DB: AuthSession by sha256(token)
else PAT
C->>A: Authorization: Bearer ntp_...
A->>DB: ApiToken by sha256 → User (+ scope cap)
else OIDC
C->>A: Bearer <JWT>
A->>A: verify iss+aud/azp+exp+RS256 (oidc_bearer.py)
A->>DB: User by oidc_sub only (bearer never auto-links —<br/>email linking happens in the login flow, not here)
end
A-->>Z: User (or 401)
Z->>DB: TenantMember(user, tenant)?
alt no membership (incl. plain superadmin)
Z-->>C: 404 (existence hidden)
else membership (role_name → TenantRoleDef, or legacy enum)
Z->>DB: resolve permission set (expired row → treated absent)
alt perm present
Z-->>C: proceed
else perm missing
Z-->>C: 403 (requires <perm>)
end
end
Note over C,DB: Staff break-glass: POST /{tenant}/support-access →<br/>audited, expiring TenantMember(support=true) the customer can see + revoke
5.1 Sequence — per-tenant SSO login (Company SSO)
A tenant's OWN OIDC issuer (TenantIdP — Entra app, Keycloak realm, Google…) signs its users in. The login page discovers the IdP by email domain; the callback enforces the hardening invariants (no account takeover, email_verified always, no members.manage via SSO — see DESIGN.md §4).
sequenceDiagram
autonumber
participant U as User (browser)
participant P as api/auth.py
participant IdP as Tenant IdP (own issuer)
participant DB as DB
U->>P: POST /oidc/discover (email)
P->>DB: enabled TenantIdP claiming the email's domain?
P-->>U: {found, idp_id, provider_name}
U->>P: GET /oidc/login?idp=(idp_id)
P->>IdP: discovery doc (cached per issuer)
P-->>U: 303 to authorize (state + nonce + PKCE — idp rides the HMAC-signed cookie)
U->>IdP: authenticate with company credentials
IdP-->>U: 303 back with code
U->>P: GET /oidc/callback
P->>IdP: exchange code + fetch userinfo (client secret from the tenant secret store)
Note over P: email_verified ALWAYS required (tenant IdPs ignore the global relax toggle)<br/>email domain must be in the IdP's declared email_domains<br/>sub stored ISSUER-SCOPED — issuer|sub (User.oidc_sub)
alt account with this email exists
Note over P,DB: auto-link ONLY if _tenant_link_ok — never a superadmin,<br/>local-password, cross-tenant or non-oidc account (no takeover) —<br/>else refuse with "ask a tenant owner to invite you"
else no account (auto_provision on)
P->>DB: create User(auth_source=oidc)
end
Note over P: superadmin promotion (oidc_superadmin_emails) NEVER applies on this path
P->>DB: auto-provision TenantMember — group_role_map (first sorted match) else default_role<br/>existing member never overwritten
Note over P,DB: a role carrying members.manage is REFUSED at login —<br/>the backstop behind the config-time gates (_check_idp_roles on PUT + provision,<br/>_reject_if_idp_referenced on role create/edit)
P-->>U: session cookie (a disabled tenant refuses — and fails closed at authz too)
The bearer path (aiops/oidc_bearer.py) mirrors the issuer selection: the token's iss picks the global issuer or an enabled TenantIdP, that issuer's JWKS verifies RS256 and its client_id is checked against aud/azp, and the sub lookup uses the issuer-scoped form — but bearer auth never auto-creates users or memberships (login-flow only).
6. Sequence — multi-cloud connector rendering
How a TenantDatasource becomes an invocable, credential-signed tool. No cloud SDKs — SigV4 / AAD / GCP-JWT are hand-rolled (tools.py).
sequenceDiagram
autonumber
participant EN as engine.py
participant TR as ToolRegistry (tools.py)
participant DT as DatasourceType (catalog)
participant SS as secret store (Fernet)
participant Cred as credential broker
participant API as Cloud API
EN->>TR: build tools for (tenant, pillar)
TR->>DT: config_template + fields_schema
Note over TR,SS: runtime._load_datasources resolves secret_ref via the store,<br/>fills ${field} placeholders — the registry gets already-resolved config
Note over TR: bind by type_key (aws_request / azure_request /<br/>gcp_request / prometheus_query / <name>_request)
EN->>TR: invoke aws_request(service, action, ...)
Note over TR: gate by verb — Describe*/List*/Get*/<br/>GenerateCredentialReport free, everything else → ApprovalRequest
TR->>Cred: _aws_credentials(cfg)
alt role_arn present
Note over Cred: no keys = keyless — signed with the pod's own<br/>workload identity (Pod Identity, else IRSA),<br/>external_id mandatory
Cred->>API: STS AssumeRole (SigV4)
API-->>Cred: temp creds (cached to expiry)
end
Cred-->>TR: signing creds
TR->>API: SigV4-signed request
API-->>TR: response (capped)
TR-->>EN: tool result
Note over TR,API: gitops_pr write path (remediate.py) dispatches on provider:<br/>GitHub /contents+/pulls · GitLab /files+/merge_requests ·<br/>Azure Repos /pushes+/pullrequests — same contract, PR is the remediation
Keyless AWS roles. The External ID is minted per workspace by the platform (grounding.mint_external_id), never typed by a user. Which roles the guided CloudShell setup creates depends on the deployment:
- Staff-prepared (default): platform staff publish an exact read/write role pair per account, plus the workspace's reader where reads run on runners (
PUT /api/aiops/admin/tenants/{id}/cloud-runtime), and the setup creates exactly those. - Any account (
AIOPS_KEYLESS_AWS_ANY_ACCOUNT=true, set in the customer example values since 0.1.109): the control-plane and runner roles may assumearn:aws:iam::*:role/aiops-*(sts:AssumeRole+sts:TagSession), so the setup createsaiops-<slug>-read, trusting both roles, andaiops-<slug>-write, trusting the control plane only, in any account but the platform's own. That account, a workspace with a private-network agent and one with staff AWS setup keep prepared pairs.
Either way the platform IAM in the customer template grants sts:AssumeRole only on roles named aiops-* with no path (in the platform's own account a trust that names the control plane suffices), and the staff API refuses a prepared pair named otherwise outside the control plane's account (422). cloud_setup.aws_setup_gap is the one readiness model the setup preview, the Test refusal text and the portal read.
7. Sequence — telemetry collection → PII scrub → lakehouse
The pull loop (app/aiops/collector.py + app/aiops/lake.py) runs enabled collection specifications for valid enabled workspaces. Lakehouse is included by default; feature_active() no longer checks grants or terms. The scrub is structural: lake.write_rows has no unscrubbed path.
sequenceDiagram
autonumber
participant SCH as scheduler loop (60s tick)
participant CO as collector.py
participant DB as DB
participant DS as Tenant datasource<br/>(prometheus / loki / ES / custom)
participant PII as pii.py
participant LK as lake.py (DuckDB)
SCH->>CO: collect_due()
CO->>DB: select enabled CollectionSpecs (short session)
Note over CO,DB: due = interval elapsed AND<br/>feature_active(lakehouse) for the tenant
loop each due spec
CO->>DB: stamp last_run_at BEFORE the fetch (commit)
Note over CO: no DB session held across HTTP —<br/>a slow connector cannot block the app<br/>or cause double-ingest
CO->>DS: fetch with rendered config + secret store<br/>(SSRF check — the datasource's own hosts allowlisted)
DS-->>CO: payload (bronze) + normalized silver rows
CO->>PII: scrub_json every row — MANDATORY<br/>(keys and values, salted pseudonym tokens)
PII-->>CO: clean rows + findings by kind
CO->>LK: INSERT bronze_records + metrics/logs
alt findings > 0
CO->>DB: upsert compliance Incident<br/>(correlation key pii:{datasource}, deduped while active)
CO->>DB: AuditEvent pii.detected
end
CO->>DB: stamp last_status (ok: N rows / error)
end
Note over SCH,LK: retention sweep — same loop, at most daily per tenant:<br/>DELETE rows older than aiops_lake_retention_days (default 30)
8. Sequence — insights chat turn
One turn of the read-only C-level chat (app/aiops/chat.py). The registry is filtered by the CALLER's permission set at build time — a member can never read via chat what REST would deny.
sequenceDiagram
autonumber
participant U as User (chat.ask perm)
participant API as api/aiops.py
participant CH as chat.py
participant EN as ChatEngine (engine loop)
participant LK as lake.py
participant PII as pii.py
participant DB as DB
participant SSE as stream.py (SSE)
U->>API: POST /api/aiops/{t}/chat (message, session_id?)
API->>DB: ChatSession — create, or load OWN-user only<br/>(another member, superadmin included → 404)
API-->>U: 202 (session_id) — turn runs DETACHED
API->>CH: start_chat_turn(sid, message)
Note over CH: per-session turn lock (busy → 409)<br/>+ the per-tenant concurrency semaphore
CH->>DB: resolve caller perms + tenant PII salt
CH->>CH: build registry FILTERED by caller perms<br/>(read-only sys_* tools — a denied tool is simply absent)
opt lakehouse active AND datasources.view
CH->>LK: register lake_query + catalog into the prompt
end
CH->>DB: Investigation(trigger=chat, status=running)<br/>(hidden from incident APIs/dashboards)
CH->>EN: run() — the real engine loop<br/>(context budget + token/cost accounting apply)
loop tool calls
EN->>DB: sys_* read-only query / lake_query
EN->>SSE: publish tool event (topic chat:{sid})
end
EN-->>CH: final assistant answer
CH->>PII: scrub_text(answer) — FINAL EGRESS GUARD
CH->>DB: append turn + tool_trace to ChatSession<br/>+ AuditEvent chat.ask (question HASH, never the text)
CH->>SSE: publish answer + done
U->>API: GET /chat/{sid}/stream (SSE)
API-->>U: snapshot (persisted messages) then live events
Note over U,API: the snapshot covers the answer-before-connect race —<br/>a turn that finished early is still delivered
MCP chat_ask takes the same path but runs run_turn synchronously (the answer comes back in the tool result); the lock, semaphore, egress scrub and audit are inside run_turn, so both callers share them.
9. Sequence — Estate X-ray scan
The premium one-shot deep scan (app/aiops/xray.py). The phase orchestration is deterministic code at our layer — no LLM meta-agent; each phase is one engine run on an ephemeral Investigation(trigger=xray) (hidden from incident surfaces, like chat turns). The whole scan is read-only by construction and PII-scrubbed with no opt-out.
sequenceDiagram
autonumber
participant U as Operator (datasources.manage)
participant API as api/aiops.py
participant XR as xray.py (run_scan)
participant EN as XrayEngine (engine loop)
participant DS as Datasource tools<br/>(ReadOnlyRegistry-wrapped)
participant PII as pii.py
participant AR as artifacts.py
participant DB as DB
U->>API: POST /api/aiops/{t}/xray (datasource_ids, model?/models?, budget_usd?)
Note over API: perm + feature_active(xray) + budget validation —<br/>positive, FINITE, capped by aiops_xray_max_cost_usd;<br/>running-scan count capped (aiops_xray_max_concurrent)
API->>DB: XrayScan(running)
API-->>U: 201 {scan_id} — the scan runs DETACHED
API->>XR: XRAY_RUNNER(scan_id)
XR->>DB: load grounding snapshot (catalog, datasources,<br/>knowledge, specs, pillars — never secret values)
XR->>DB: Investigation(trigger=xray, status=running)
XR->>EN: ONE mission run under the per-tenant semaphore<br/>(per-step cost cap = REMAINING scan budget;<br/>per-step cancel poll _should_stop)
loop mission steps — the agent self-directs (aiops_xray_mission_steps cap)
EN->>DS: read probe — a gated call is DENIED, never executed<br/>(no ApprovalRequest, no pause)
EN->>PII: scrub tool output (forced on — no settings flag)
EN->>DB: ledger tools (plan_update / record_finding /<br/>record_pillar_coverage / draft_recommendation) —<br/>scrubbed at capture, persisted to the scan row PER STEP,<br/>drafts VALIDATED against grounding (invalid → rejected into the loop)
Note over EN: agent stops calling tools → finish-gate _may_finish:<br/>checklist unmet (plan open / ds untouched / pillars missing)<br/>→ nudge back into the loop (≤ aiops_xray_max_nudges, never past 80% budget)
end
alt mission completed within budget
EN-->>XR: small executive-summary JSON (failure tolerated —<br/>the report never depends on it)
XR->>XR: zero-LLM sweep — map_discoveries (found services →<br/>ready-to-connect suggestions) + deterministic 7-pillar gap table
XR->>XR: render_report_md from the LEDGER (code, no giant prompt)
XR->>PII: scrub_text(report) + scrub_json(recommendations)
XR->>AR: create_artifact(kind=report, source_type=xray)
XR->>DB: status done + AuditEvent xray.finished (totals)
else budget hit mid-step
XR->>AR: partial report FROM THE LEDGER (findings + open plan items)<br/>still becomes an Artifact
XR->>DB: status aborted_budget + AuditEvent xray.finished
else cancelled (POST .../cancel) or feature revoked
XR->>DB: status cancelled — the per-step poll stops the spend<br/>within one step; ledger spend stays recorded
end
U->>API: GET /xray/{scan_id} — live poll target<br/>(per-phase tokens/cost + running totals + report)
U->>API: POST /xray/{scan_id}/apply (kind, index) — per-kind permission
API->>DB: DRAFT rows only — disabled CollectionSpec/RemediationRoute,<br/>INACTIVE [x-ray]-prefixed KnowledgeItem
Note over U,DB: HITL activation is the persistence gate —<br/>knowledge goes live only via POST /knowledge/{id}/activate
The cancel endpoint flips the row terminal immediately (freeing a slot under the running-scan cap); the detached mission notices via its per-step _should_stop poll and stops within one step. Any error marks the scan (and a still-running phase Investigation) failed — run_scan never raises and never leaves a row stuck running. The legacy 3-phase flow (aiops_xray_mission=false) keeps its old phase-boundary checks unchanged.
10. State machines
Incident FSM
Derived from app/aiops/models._INCIDENT_TRANSITIONS. The forward path is the design; the extra edges are legitimate manual/concurrent moves (a deny bounces back; a resumed run pauses on a second gated tool; a human re-triggers a closed incident). transition() is the primary FSM writer (used by ingest.py + engine.py) — it audits every move (incident.status) and skips (never raises) illegal ones. Two operator endpoints, escalate and remediate, set the status directly with their own audit events (incident.escalate / incident.remediate), bypassing the guard.
stateDiagram-v2
[*] --> open: alert / manual
open --> investigating
investigating --> awaiting_approval: agent proposes a write
investigating --> remediating: approved write executes
awaiting_approval --> remediating: approved
awaiting_approval --> investigating: denied (bounce)
remediating --> awaiting_approval: 2nd gated tool
remediating --> investigating: keep probing
remediating --> resolved: fix verified
investigating --> resolved: no action needed
open --> resolved: alert cleared
resolved --> [*]
investigating --> escalated: 20% human path
awaiting_approval --> escalated
remediating --> escalated
open --> escalated
escalated --> [*]
resolved --> investigating: human re-triggers
escalated --> investigating: human re-triggers
note right of open
Also legal (not drawn, to keep it readable):
open may pause straight to awaiting_approval
or remediating for a manually-created incident.
end note
ApprovalRequest FSM
stateDiagram-v2
[*] --> pending
pending --> approved: human approves (execute tool)
pending --> edited: approve w/ modified command (cli only)
pending --> denied: reject (feedback → LLM)
approved --> [*]
edited --> [*]
denied --> [*]
note right of pending
Re-decide is rejected (409): first decision wins.
Unattended > TTL → denied by the scheduler sweep,
incident → escalated.
end note
SkillProposal FSM (the memory rollback path)
stateDiagram-v2
[*] --> draft: feedback / correction
draft --> active: approve (write SKILL.md, reload catalog)
draft --> rejected: reject
active --> retired: retire (delete skill dir — rollback)
rejected --> [*]
retired --> [*]
11. Deployment — compose-first
flowchart TB
subgraph host["docker compose (demo laptop)"]
subgraph core["default services (no profile)"]
app["aiops (FastAPI)"]
pg[("postgres")]
vol[("aiops-data volume — /data<br/>skills · secrets ·<br/>lakehouse: /data/lakehouse<br/>(per-tenant DuckDB files)")]
end
subgraph estate["profile: estate (demo target)"]
prom["prometheus"]
amgr["alertmanager"]
grafana["grafana"]
shop["demo-shop + fault inject"]
end
subgraph sso["overlay: docker-compose.keycloak.yml (optional)"]
kcd["keycloak<br/>(demo realm aiops + provisioned<br/>aiops-* tenant realms;<br/>issuer host kubernetes.docker.internal)"]
end
end
ext["AIOPS_MODEL / AIOPS_API_BASE<br/>(external LLM — or built-in mock)"]
amgr -- "webhook" --> app
app -- "PromQL / dashboards" --> prom
app -- "PromQL" --> prom
app --- pg
app --- vol
app -- "OIDC login (optional)" --> kcd
app -- "admin API — realm provisioning" --> kcd
app -- "completion" --> ext
shop --> prom
prom --> amgr
Production seam: the in-process detached-task + semaphore model (runtime.py) is where a real task queue/worker slots in — one function, no schema change. One control-plane replica by default; leader election (leader.py, a Postgres advisory lock) keeps the scheduler, sealer, sweeps and provisioner on one elected replica when more are run, and multi-replica is blocked today by the ReadWriteOnce /data lake volume and per-pod in-memory state (rate limits, ingest gate, SSE, and the in-process locks behind IdP domain claims, compliance landing and harvest recording) — see availability.md.
12. Endpoint → data map (selected)
| Flow | Endpoint | Writes |
|---|---|---|
| Ingest | POST /webhook/{source}/{tenant_slug}/{token} (token also accepted as a header) |
Alert, Incident, Investigation, AuditEvent |
| Live view | GET /api/aiops/{t}/investigations/{id}/stream |
— (SSE replay + live) |
| Decide | POST /api/aiops/{t}/investigations/{id}/decisions |
ApprovalRequest, resumes engine |
| Remediate | POST /api/aiops/{t}/incidents/{id}/remediate |
route exec → Incident.status, artifact |
| Escalate | POST /api/aiops/{t}/incidents/{id}/escalate |
Incident (→ escalated) |
| Feedback | POST /api/aiops/{t}/investigations/{id}/feedback (feedback.submit; rating and/or 1-10 score; UPSERT per (investigation, user) — DB-unique) |
Feedback (→ SkillProposal draft on first rating / new down-correction) |
| Model tiers | GET|PATCH /api/aiops/{t}/model-tiers (GET any member; PATCH pillars.manage, merge-patch — "" clears a tier) |
Tenant.settings.model_tiers, AuditEvent models.tiers_updated (the generic tenant PATCH rejects this key) |
| Tier suggest | GET /api/aiops/{t}/model-tiers/suggest (pillars.manage) |
— (deterministic suggestions + evidence; HITL — saving via the PATCH is the audited apply) |
| Model discovery | GET /api/aiops/{t}/models (pillars.manage) |
— (gateway /models passthrough; OpenRouter pricing included when present) |
| Skill promote | POST /api/aiops/{t}/proposals/{id}/approve|retire |
SkillProposal, skills dir |
| Members | POST|DELETE /api/aiops/{t}/members... |
TenantMember, AuditEvent |
| Roles | POST|PATCH|DELETE /api/aiops/{t}/roles... |
TenantRoleDef |
| Support access | POST|DELETE /api/aiops/{t}/support-access |
TenantMember(support), AuditEvent |
| SSO discover | POST /oidc/discover (unauthenticated by necessity — reveals only found + provider name) |
— |
| SSO login | GET /oidc/login?idp= → GET /oidc/callback (unauthenticated; idp rides the signed state cookie) |
User (link/create, invariant-guarded), TenantMember (auto-provision; members.manage roles refused), AuthSession, AuditEvent member.auto_provisioned |
| Tenant IdP | GET|PUT|DELETE /api/aiops/{t}/idp (members.manage; PUT validates SSO-assignable roles + reserved/globally-unique domains under a claim lock) |
TenantIdP, secret store (client secret write-only under tenant-idp-{t}), AuditEvent idp.updated/idp.removed |
| IdP test | POST /api/aiops/{t}/idp/test (members.manage) |
— (issuer discovery fetch; SSRF-guarded, never 500s) |
| Keycloak provision | POST /api/aiops/{t}/idp/provision-keycloak (superadmin; 503 unless AIOPS_KEYCLOAK_ADMIN_URL; 409 if an IdP exists) |
Keycloak realm aiops-<slug> + TenantIdP + secret store; realm + secret rolled back if the DB write fails; AuditEvent idp.provisioned (never the secrets) |
| Datasource | POST /api/aiops/{t}/datasources (+ /test) |
TenantDatasource, secret store |
| Knowledge | POST /api/aiops/{t}/knowledge |
KnowledgeItem |
| Benchmark | POST /api/aiops/{t}/benchmarks/{builtin|golden|import} |
BenchmarkRun |
| Legacy feature grant/revoke | POST|DELETE /api/aiops/{t}/features/{f} (superadmin) |
Deprecated authenticated no-ops; historical rows are retained and availability does not change |
| Legacy terms accept | POST /api/aiops/{t}/features/{f}/accept (members.manage) |
Deprecated authenticated no-op; features are included and no agreement or collection spec is created |
| Lake overview | GET /api/aiops/{t}/lake (datasources.view) |
— (stats, catalog, retention, PII totals, specs) |
| Lake specs | GET|POST|PATCH|DELETE /api/aiops/{t}/lake/specs... (+ /seed-defaults) (datasources.manage) |
CollectionSpec, AuditEvent |
| Lake query | POST /api/aiops/{t}/lake/query (datasources.view) |
— (single SELECT/WITH, read-only connection) |
| Lake erase / drop | POST /api/aiops/{t}/lake/erase · DELETE /api/aiops/{t}/lake (members.manage) |
DuckDB rows / whole lake dir; AuditEvent (needle stored as hash only) |
| Chat ask | POST /api/aiops/{t}/chat (chat.ask, 202 detached) |
ChatSession, Investigation(trigger=chat), AuditEvent chat.ask (question hash) |
| Chat sessions | GET /api/aiops/{t}/chat · GET|DELETE /api/aiops/{t}/chat/{sid} (chat.ask, own-user only) |
delete removes the ChatSession |
| Chat live | GET /api/aiops/{t}/chat/{sid}/stream (chat.ask, own-user only) |
— (SSE snapshot + live turn events) |
| X-ray launch | POST /api/aiops/{t}/xray (datasources.manage + active xray feature; budget finite + ≤ aiops_xray_max_cost_usd) |
XrayScan(running), AuditEvent xray.launched; detached scan task |
| X-ray list / detail | GET /api/aiops/{t}/xray · GET /api/aiops/{t}/xray/{id} (datasources.view + feature) |
— (detail = live poll target: per-phase tokens/cost + totals + report) |
| X-ray apply | POST /api/aiops/{t}/xray/{id}/apply (per kind: datasources.manage or knowledge.manage) |
DRAFTS only — disabled CollectionSpec/RemediationRoute, inactive [x-ray] KnowledgeItem; AuditEvent xray.applied |
| X-ray cancel | POST /api/aiops/{t}/xray/{id}/cancel (datasources.manage) |
XrayScan → cancelled (immediate; task bails at next phase boundary), AuditEvent |
| X-ray delete | DELETE /api/aiops/{t}/xray/{id} (datasources.manage; 409 while running) |
removes the XrayScan row (report Artifact survives), AuditEvent |
| Artifacts | GET /api/aiops/{t}/artifacts[?source_type=&source_id=&kind=] · GET .../artifacts/{id} (incidents.view; source_type=xray rows also need datasources.view + active xray feature) |
— (append-only; written only via create_artifact) |
| RCA export | POST /api/aiops/{t}/incidents/{id}/rca-export (incidents.act; idempotent per investigation) |
Artifact(kind=export), AuditEvent artifact.created |
| Knowledge activate | POST /api/aiops/{t}/knowledge/{id}/activate (knowledge.manage) |
KnowledgeItem.active=true (supersedes active same-name), AuditEvent knowledge.activate |
MCP surfaces mirror these through the canonical REST handlers: /mcp/aiops (tenant permissions plus the PAT scope ceiling) and /mcp/admin (superadmin, with admin scope required for every PAT-authenticated tool). Both accept a PAT (ntp_) or OIDC bearer. Workspace-bound URLs select one tenant; they do not turn the PAT into a workspace-restricted credential. The operation reference documents the current names, aliases, nested request bodies and asynchronous results.
13. Governance layer (change hub · risk register · tamper-evident audit · certification)
Added for the enterprise acceptance scope (Aug-2026). Four subsystems close the loop between "the agent proposed something" and "an auditor can verify what happened":
flowchart TD
subgraph hub["Change hub — change.py"]
RR[run_and_record<br/>the ONE execute+record chokepoint] --> CR[ChangeRecord<br/>minted BEFORE the write]
CR --> AZ{authorize<br/>window + tier quorum}
AZ -- unmet L3 --> PA[pending_approval]
AZ -- ok --> EX[execute route / gitops PR]
EX --> LKG[pre-change state capture<br/>pre-image / snapshot / method=none]
EX -- failed --> RB[rollback_sweep_tick<br/>auto restore-PR ≤L2, else escalate]
end
subgraph risk["Risk register — risk.py"]
CF[CloudFinding upsert<br/>ASFF / Defender, scrubbed] --> RD["RiskDecision<br/>accept = Suppress / avoid = Fix / mitigate (no new ones) — HUMAN, append-only"]
RD -- accept --> SUP[suppress re-sends until expiry]
RD -- avoid/mitigate --> CR
end
subgraph audit["Audit chain — audit_chain.py"]
AE[AuditEvent rows] --> SEAL[async sealer<br/>per-tenant sha256 chain, seq + prev_hash]
SEAL --> VER[GET /audit/verify<br/>replay by seq]
end
subgraph cert["Certification — benchmark.py"]
BR[BenchmarkRun full catalogue] --> CERT[certification manifest<br/>bundle_sha, chain-anchored]
CERT --> VER
end
Load-bearing decisions: the ChangeRecord exists before the write so a crash still leaves a ledger row; rollback drafts from agents are never auto-executed; risk decisions are append-only with mandatory statements (supersede, never edit); the audit chain is sealed asynchronously by the single-writer scheduler (≤60s unsealed window is the accepted trade); a certification is a re-verifiable bundle (certification.json) whose sha is itself an audited, chain-anchored event — the pass-rate claim can be replayed, not just quoted.
Posture over time comes from the same register. The leader's scheduler tick first saves one PostureSnapshot per workspace for the UTC day that just closed (risk.snapshot_tick, insert-only; earlier days, and a day saved more than an hour after its close, are rebuilt from the records and marked reconstructed). Posture trend on Compliance findings (GET /api/aiops/{t}/findings/score, by day, ISO week or month, sliced by account and source) reads saved days as they were saved and rebuilds only the days not saved yet.
14. Execution plane v2 (durable claims · per-tenant runners · managed test lab)
flowchart LR
subgraph Control plane — one replica by default, leader-elected singleton loops
API[FastAPI + scheduler + ingest]
Q[(Investigation / XrayScan / FactoryRun rows<br/>ARE the queue: runner_id, lease_expires_at, attempt)]
API --> Q
end
subgraph Runner pod per tenant
CL[claim loop<br/>atomic conditional UPDATE] --> EN[engine run<br/>per-step transcript commits]
EN --> HB[heartbeat lease]
end
Q <--> CL
EN -- crash --> RES[lease expiry → attempt+1<br/>resume from transcript]
API -- AIOPS_EXEC_MODE=remote --> NOP[CP only enqueues<br/>deploys never kill db-mode jobs]
subgraph Managed test lab — testfactory.py
PROV[provision estate<br/>write-role kubectl ds, agent-invisible] --> INJ[inject → grade → heal]
INJ --> PART[per-case rows persisted<br/>AS THEY GRADE — result.per_case_partial]
INJ --> SETTLE[settle gate:<br/>pods green AND alerts silent]
end
Q --> PROV
The factory runs the same claim/lease machinery as investigations; a lab run interrupted by a control-plane restart is failed-and-healed (never resumed), but its graded rows survive incrementally (per_case_partial) so the work is not lost. Two lab-specific validity mechanisms are architectural, not incidental: the settle gate (a lane is released only when the shard's pods are Ready and its alerts have drained — pods-green alone let the previous fault's late alerts poison the next case) and stale-run escalation suppression in the engine (a finished investigation must not reopen an incident that resolved while it ran; the reopen edge belongs to the post-close verify). Both were measured into existence by the acceptance loop — see tasks/overnight-journal.md rounds 8–11 for the evidence.
15. Correlation — how a burst of alerts becomes ONE incident
The single most load-bearing piece of product logic (app/aiops/ingest.py + correlate.py): every noisy estate produces hundreds of alerts per fault, and everything downstream — one investigation, one approval trail, one RCA — depends on folding them into exactly one incident without ever merging two different problems.
flowchart TD
W[Webhook alert arrives<br/>alertmanager / grafana / datadog / cloudwatch<br/>servicenow / jira / securityhub / defender ...] --> N[Adapter normalizes to CANONICAL labels<br/>service, component, severity + tenant label_map]
N --> D{Dedupe: same fingerprint<br/>still active on a joinable incident?}
D -- yes --> T[refresh incident correlation clock<br/>suppressed, no new row]
D -- no --> K[correlation key = service label<br/>fallback fingerprint]
K --> J{Open incident<br/>with same key?}
J -- yes --> JOIN[JOIN it: severity may UPGRADE,<br/>never downgrade; L3 fields re-derive]
J -- no --> F{Label-correlation fold:<br/>another service, same CAUSE<br/>within window?}
F -- hit --> JOIN2[fold into that incident<br/>one cause, many services]
F -- miss --> OPEN[OPEN new incident<br/>severity = max of alerts<br/>criticality = declared max<br/>priority = severity x blast radius,<br/>+1 step if mission-critical<br/>baseline harvested for post-fix verify<br/>suspect change pinned on event time]
OPEN --> G{Dependents of this service<br/>have open incidents<br/>inside the grouping horizon?}
G -- yes --> REROOT[merge them in: the top-most<br/>failing service is the root]
OPEN --> H{Depends on a service<br/>over a foldable edge,<br/>not a P1?}
H -- yes --> WAIT[analysis waits for the grouping wait:<br/>that service's alert takes it in]
Decisions worth defending to an auditor:
- The key is the
servicelabel, first and always. Everything the tenant maps intoservicevialabel_mapbecomes the folding identity. Tickets (ServiceNow/Jira/ADO) are normalized the same way —cmdb_ci/ component becomesservice— so a ticket joins the live telemetry incident instead of opening a parallel one, and the incident adopts the originating ticket (no ticket → incident → ticket loop). - No recency window on the join. An open incident absorbs same-key alerts however old it is — a recency window split one nine-hour problem into siblings. Status is the separator:
resolvedis the only closed state. - Joinable = open, investigating, awaiting_approval, remediating, and escalated. Escalated is included on purpose: while a human works the fix, a re-fire must fold into that incident, not open a duplicate next to it. The dual of this rule: a finished run may not push a resolved incident back to escalated (stale-run suppression, §14) — otherwise a dead investigation could silently re-arm the join target for the next, unrelated fault.
- Grouping follows the service graph, never a count (0.1.147). A fault that cascades across services (DB down → five callers erroring) becomes one incident on the top-most failing service: a caller arriving after its cause folds into it (rule 1 below), and a cause arriving after its callers opens its incident and takes theirs in (
grouping.group_on_arrival, auditedincident.grouped, the merged row keepsmerged_into_id), both through the same fold gates and inside the grouping horizon (aiops_storm_group_window_s+aiops_storm_spread_window_s, 900 s). A caller's analysis waits (the grouping wait, 0.1.148:aiops_grouping_wait_s300 s,grouping.awaited_roots, never for a P1, never for an alert none of whose episodes was analysed), so a cause arriving after a caller's analysis would have escalated still takes it in — an escalated incident is never regrouped;grouping.release_waitsstarts the analysis at the wait's end. A reopen takes rule 1 first, so a caller whose root is open joins it instead of reopening beside it. The grouping agent (grouping.group_triage_tick, one bounded turn per related group) covers the edges that may not fold. A burst of unrelated services opens one incident each (the shared-alert-name fold, 3 services, is the one fold without a relationship); the 15-per-120 s storm collar and its__storm__incident are gone, and a blob an earlier release left open is no longer split. - A dependency folds only when the workspace trusts its source. The same fold step (
correlate.find_fold_target) joins an alert to an open incident whose blast radius holds its service — by default through declared edges only, which a member withdatasources.manageadds asA → B(A depends on B) on Operate → Service correlation → Service dependencies (0.1.110) or throughPOST /api/aiops/{t}/topology. Other sources fold only when listed inaiops_topology_fold_origins, and then only for a direct caller of a dependency with at most 10 direct callers, over an edge seen withinaiops_topology_traced_ttl_h(168 h by default). - Severity only upgrades on join; the worst correlated signal defines the incident. Declared criticality (
low<medium<high<mission-critical, an alert label or the tenantcriticality_map, never inferred) only rises the same way. Priority (P1–P4) derives from severity (critical P1, error P2, warning P3, else P4), one step higher when the blast radius (topology reverse-BFS, 3 hops) holds two or more services and one more for a mission-critical resource, floor P1 — re-derived on every join and merge (ingest.reprioritize). Numeric alert values are harvested as the incident'sbaseline— that is what the post-fix verify compares against. - Resolution is symmetric: when every correlated alert has reported resolved, the condition has ended — the incident auto-closes only from
open/investigating. An incident a human holds (awaiting approval, remediating, escalated) is never yanked closed by telemetry. The one exception is an escalated incident whose own adopted ticket — the customer's, sent in through an inbound ticket webhook — is resolved at its source with nothing else firing: that person closed it, in their ticket system (incident.resolvedbyticket-resolved), unless its fix still waits for a decision under the US01-AC1 hold switch.
The test-lab acceptance loop measured this machinery under load: the lab tenant runs 12 estates concurrently, and rounds 8–11 of tasks/overnight-journal.md document the two bugs found and fixed at the correlation boundary (late-notification incidents born after quiet, and the stale-run escalation that made a closed key joinable again).