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:

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 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).

Next steps