A guided, two-act demo from a cold checkout to "a real pod was remediated and verified." Act 1 runs the whole closed loop on docker-compose with auth on and Postgres persistence — no cluster needed. Act 2 swaps in a real kind cluster so approving a fix restarts a real pod.
Act 1's commands below were run end-to-end and verified; use them verbatim.
| Stage | What happens | Where you see it |
|---|---|---|
| Telemetry | Prometheus scrapes the demo-app; ingestion normalizes it onto the bus | (background) |
| Situation | Correlation collapses the anomaly into one Situation |
console · GET /situations |
| Diagnosis | RCA attaches a hypothesis + a suggested playbook | console · the situation's hypotheses |
| HITL gate | Action requests approval; nothing runs until a human decides | console · GET /approvals |
| Remediation | On approve, the playbook runs (dry-run in Act 1, real pod in Act 2) | console · kubectl (Act 2) |
| Verified | A health check confirms recovery; the decision is written to the audit trail | GET /audit (persisted in Postgres) |
- Act 1: Docker + Docker Compose, Node (for the console),
curl+python(for the CLI checks). - Act 2 (adds): kind +
kubectl.
This runs the hardened configuration: AUTH_MODE=token (edge auth on) and
STORE_BACKEND=postgres (durable state). The base compose already sets STORE_BACKEND=postgres;
the deploy/docker-compose.auth.yml overlay turns auth on with a shared demo token
(intelliops-demo-token).
docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.auth.yml up -d --buildPostgres and Redis come up first (health-gated), the one-shot migrate job applies the schema and
exits, then the seven services start. Give them ~20s to connect, then confirm readiness (the
/health liveness and /ready readiness probes are always reachable without a token):
curl -s -o /dev/null -w "governance /ready -> %{http_code}\n" http://localhost:8005/ready
curl -s -o /dev/null -w "read /ready -> %{http_code}\n" http://localhost:8007/readyBoth return 200.
Set the token once:
export TOKEN=intelliops-demo-tokenWithout it, the console's data endpoints are locked:
curl -s -o /dev/null -w "situations (no token) -> %{http_code}\n" http://localhost:8007/situations
curl -s -o /dev/null -w "situations (wrong token) -> %{http_code}\n" -H "Authorization: Bearer nope" http://localhost:8007/situationsBoth return 401. With the correct token they return 200:
curl -s -o /dev/null -w "situations (token) -> %{http_code}\n" -H "Authorization: Bearer $TOKEN" http://localhost:8007/situationsThis is why the console must send the token — see step 3.
cd frontend
cp .env.example .env.local
# edit .env.local:
# VITE_DATA_MODE=live
# VITE_AUTH_TOKEN=intelliops-demo-token
npm run devOpen http://localhost:5173. It loads because the frontend now attaches
Authorization: Bearer <VITE_AUTH_TOKEN> on every request (without the token set, the console would
get 401s — that's the auth-coverage fix).
curl -s -X POST http://localhost:8080/breakDetection takes ~15-30 seconds — that's expected, not a hang: a real Prometheus scrape (every 5s) + an ingestion poll (every 5s) + River needing a few samples to flag the anomaly. Watch the console, or poll:
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8007/situations | python -m json.toolThe Situation appears with status: diagnosed and a hypothesis (e.g. "resource saturation" ->
scale-service), then an approval shows up at the HITL gate:
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8005/approvals | python -m json.toolIn the console, click Approve on the situation. (From the CLI, the equivalent — note the authorized decider and the token:)
APPR=appr-<situation-id> # from the /approvals response above
curl -s -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" \
-X POST "http://localhost:8005/approvals/$APPR/decide" \
-d '{"decision":"approved","decided_by":"oncall-alice"}'Action runs the (dry-run) remediation, publishes the outcome, and the console's KPIs update.
Every decision is written to the Postgres-backed audit trail, threaded by correlation_id:
curl -s -H "Authorization: Bearer $TOKEN" "http://localhost:8005/audit?correlation_id=<situation-id>" | python -m json.toolYou'll see the rca-service diagnose and action-service execute records — persisted, queryable,
and surviving a service restart (that's the Tier-1b durability payoff).
AUTH_TOKEN=$TOKEN ./scripts/reset.shThis recovers the demo-app, clears the detector baseline + read-model, and drops pending approvals — but the audit trail and training records are preserved (they're the compliance/learning record). Confirm:
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8005/audit | python -c "import sys,json; print(len(json.load(sys.stdin)), 'audit records still here')"The prior run's decisions are still there. Break it again for a fresh incident.
docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.auth.yml down
# add -v to also drop the Postgres volume for a truly fresh databaseThe same loop, but approving restarts a real pod and a real health check verifies recovery. This is the PPO centerpiece, and it is a single self-contained cluster — all seven services, the Meridian sample production system, demo-app, Postgres, Redis, Prometheus and the React console run inside kind. There is no compose stack and no kubeconfig plumbing in this path.
Every command below was run end-to-end against a cluster built from
kind delete clusteron 2026-09-15; the timings quoted are what it actually did.
GROQ_API_KEY=gsk_... ./scripts/kind-up-full.shThat builds the three images, loads them into kind, installs the chart with the LIVE posture
(values-live.yaml: robust correlation, embedding runbook selection, real k8s remediation,
per-metric health verification, at-least-once delivery), and wires your key into a Secret. Without
GROQ_API_KEY the stack still comes up and LLM explanations fall back to template.
A cold run takes a while: it pulls a CPU torch wheel, bakes the embedding model, and pulls
postgres:16/redis:7/prom/prometheus from Docker Hub. The script now fails loudly if any
workload never becomes ready rather than printing a checkmark over a broken stack.
When it finishes:
| What | URL |
|---|---|
| Console (the live UI) | http://localhost:30080 |
| Read service | http://localhost:30007 |
| Meridian gateway (inject faults here) | http://localhost:30808 |
| Prometheus | http://localhost:30090 |
| Grafana (logs + metrics + audit) | http://localhost:30300 |
Confirm it is genuinely up — the banner is not proof on its own:
kubectl get pods
curl -s http://localhost:30007/system | python -m json.tool/system asks each service what it is actually running, so remediator_mode reading k8s is the
cluster's own answer, not the read service's environment.
Meridian's four services are the fault source. The gateway is the one bound to a host port; reach
the others through any pod. crash drives service_up 1 → 0, which is the cleanest story because
the fix is a pod restart you can watch:
kubectl exec deploy/read -- python -c "import httpx; print(httpx.post('http://meridian-validation:8000/admin/fault', json={'type':'crash','magnitude':1.0}, timeout=10).text)"Watch the target in another terminal:
kubectl get pods -l app.kubernetes.io/name=meridian-validation -wThe console shows the incident appear, then a hypothesis and a suggested runbook. From the CLI:
curl -s http://localhost:30007/situations | python -m json.toolMeasured across three verified runs: the Situation appeared 17-30s after injection (Prometheus
scrape + ingestion poll + correlation window), RCA attached service is down — the process stopped serving → restart-pod 8-14s later once the correlator is warm (the first incident after a
cold start took ~90s while baselines filled), and the approval request followed in under a
second. Budget ~45s from injection to the HITL gate, and do not narrate it as instant.
Nothing runs until a human decides. Approve in the console, or:
kubectl exec deploy/read -- python -c "import httpx; print(httpx.post('http://governance:8000/approvals/appr-<situation-id>/decide', json={'decision':'approved','decided_by':'oncall-alice'}, timeout=20).status_code)"The kubectl get pods -w window shows the pod terminate and a new one come up — a real
rollout restart, about 5s after approval. The pod name changes, which is the proof this is
not dry-run. Then:
curl -s http://localhost:30007/outcomes | python -m json.toolThe outcome carries "mode": "k8s" — a real cluster was touched — and "result": "success" once
the per-metric check confirms service_up is back to its baseline for that service. The
verification predicate converges in about 8s.
The reversible-only safety property (ADR-007): if health is not restored, the action service runs the real
rollback_stepsand reportsrolled_backrather than a false success.
The more interesting half. unknown_signal moves tls_handshake_failures, a metric family no RCA
rule matches:
kubectl exec deploy/read -- python -c "import httpx; print(httpx.post('http://meridian-reporting:8000/admin/fault', json={'type':'unknown_signal','magnitude':1.0}, timeout=10).text)"It is still detected — tls_handshake_failures has a perfectly flat baseline, and the robust
correlator handles zero-variance windows — but no runbook is invented for it. The incident reaches
needs_attention, and the console offers "Draft a runbook with AI": the governance agent
reads the incident plus past outcomes, calls tools, and proposes a ProposedPlaybook for a human to
approve. Nothing it drafts is auto-applied.
Do this before demoing. The action service consumes diagnosed incidents serially and waits
inline for a human decision, so an incident left undecided blocks later ones until it times out
(HITL_POLL_TIMEOUT_SECONDS, 120s in the live posture). Clear the slate:
for s in meridian-gateway meridian-validation meridian-reporting meridian-aggregation; do
kubectl exec deploy/read -- python -c "import httpx; httpx.post('http://$s:8000/admin/clear', json={}, timeout=10)"
done
curl -s -X POST http://localhost:30007/reset
kubectl exec deploy/read -- python -c "import httpx; [httpx.post(u, json={}, timeout=15) for u in ['http://governance:8000/reset-approvals','http://governance:8000/reset-proposed']]"kind delete cluster --name intelliops- Detection takes 15-30s. Expected — real scrape + poll intervals + River warm-up, not a hang.
- A service isn't answering. Check
/ready(not just/health):/healthsays the process is up,/readysays it can reach Redis + Postgres (503 with afailedlist until it can). Themigratejob must finish before the store services are ready. - The console shows 401s / no data. Under
AUTH_MODE=tokenthe console must send the token — setVITE_AUTH_TOKENinfrontend/.env.localto matchINTELLIOPS_AUTH_TOKEN. reset.shreturns 401. Under token mode the reset endpoints are gated — run it withAUTH_TOKEN=<token> ./scripts/reset.sh.
- Dry-run vs real. Act 1's remediation is dry-run (logged + a simulated health check) — nothing real is touched. Real pod remediation only happens on Act 2's kind path.
- Simulation controls.
/break,/fix,/reset,/reset-baseline,/reset-approvalsare simulation controls, not production endpoints. UnderAUTH_MODE=tokenthey're gated like everything else, and they must be removed or gated when pointed at a real system. - The demo token isn't a real secret.
VITE_AUTH_TOKENis compiled into the frontend bundle, so it's a shared demo token, not a per-user credential. A real deployment would use per-user tokens or an identity provider — see docs/OPERATIONS.md.