What you'll build: the CMDB blast radius — the graph layer of the DODIL ITSM suite.
Your itsm bucket already holds the CMDB: cis (configuration items) and a single typed ci_edges
table carrying depends_on / runs_on / part_of, plus the services master — all owned by itsm/core.
This skill assembles the graph — it builds the reverse, typed impacts edges and snapshots two graphs
(cmdb forward, cmdb_impact reverse) — and turns them into the two answers a flat ticket table can't give:
- Blast radius — if
pg-orders-primaryfalls over, what breaks? — every CI that transitively depends on the failing one, hop-ranked, in one query. - Impacted-service rollup — which business services that blast radius takes down, grouped and counted — the sentence the incident bridge and the change-approval board actually want.
One bucket answers by content (SQL) and by relationship (graph) over one copy of the rows — no Neo4j sync, no nightly correlation job. And it does the thing most CMDBs get wrong: it types the traversal, so a composition edge that shouldn't propagate failure never inflates the blast.
The problem — and why it matters
You're the incident commander on a Sev-1 bridge (or the change-approval board staring at a Friday
deploy). The pager fired for pg-orders-primary. The only question that matters in the first five minutes
is what else is about to fail, and which customer-facing service does that take down? Answering it means a graph
traversal your ticket database can't do, over dependency data that — in a classic stack — lives in a separate
Neo4j CMDB, correlated to the tickets by a nightly ETL. So you're reading yesterday's topology to reason
about a live outage.
| Piece | Lands in | Pillar |
|---|---|---|
| Configuration items + typed dependencies | cis / ci_edges (core) | SQL → graph nodes/edges |
| The reverse impact edges | table impacts | SQL → graph edges |
| The assembled graphs | cmdb / cmdb_impact | Graph |
| CI → business-service map | table service_map | SQL (the rollup key) |
| Precomputed blast rollups per CI | table impact_analysis | SQL |
| The recompute loop | itsm-cmdb-engine | Ignite (own service account) |
The money is MTTR and avoided outages. A CMDB that can turn a failing database into its full blast radius before a customer notices is the difference between a scoped, communicated incident and a blind one — and on the change side, it's the difference between "low-risk standard change" and "this touches a CI that 10 other CIs across 4 business services sit downstream of." The seat-priced ITSM suite charges you for the CMDB module and the graph DB and the warehouse it's ETL'd into. Here it's one bucket, three pillars, one copy of the rows.
This component is also the one every other ITSM component reads. Change management's risk verdict, the major-incident bridge's scope, incident management's affected-CI read — none of them traverse the graph themselves. They read what this component leaves behind. That makes it the module's highest-leverage table and its quietest failure mode, which is the thread running through the rest of this post.
NOTE
This is the ITSM suite's graph skill and it owns the single CREATE GRAPH. In the suite, itsm/core
inserts the CMDB nodes + edges but runs no CREATE GRAPH; because CREATE GRAPH snapshots its edges,
every edge must exist before the graph is created. So the deferral is core → this skill: core fills
ci_edges, and cmdb-blast-radius builds impacts and snapshots both graphs once.
Prerequisites
- The
dodilCLI (dodil auth login) and an agent connected over MCP (Claude Code, Cursor, VS Code, Codex). Every step shows an Ask your agent tab and a CLI tab. export BUCKET=itsm— the one bucket the whole suite shares.- itsm/core already scaffolded:
cis(the CMDB nodes), the typedci_edges(depends_on/runs_on/part_of), and theservicesmaster. Standalone, the One-shot below stubs a minimal slice of those so you can run this skill by itself.
Step 1 — Stand up the blast-radius tables
Three merge-keyed tables: impacts is the reverse impact edges (it becomes graph edges), service_map is the
CI → business-service rollup key, and impact_analysis holds the precomputed blast rollups per CI. A
--merge-key (PRIMARY KEY) is required — writes are keyed, so every recompute upserts idempotently instead
of duplicating.
The whole suite shares one bucket, and it is called itsm — four characters, so it clears DataK3's
three-character minimum with nothing to work around. (The GL suite's natural name, gl, does not, which is
why that bucket is gl-suite. Worth knowing before you pick a name you can't have.)
In the itsm bucket, create three merge-keyed tables: impacts (key src,dst: both long); service_map (key ci_id: service_id, business_service, tier int); and impact_analysis (key analysis_id: ci_id long, impacted_ci_count int, impacted_services_json, max_hop int, computed_at).
data_table_createCreated impacts (key src,dst), service_map (key ci_id), and impact_analysis (key analysis_id) — each with a PRIMARY KEY, so upserts are idempotent and re-runs are safe.
export BUCKET=itsm
# the reverse impact edges (core's impact-bearing ci_edges, flipped + typed)
dodil data table create impacts -b "$BUCKET" --merge-key src --merge-key dst \
--columns-json '[
{"name":"src","type":"long","nullable":false},
{"name":"dst","type":"long","nullable":false}
]'
# CI -> business-service rollup key
dodil data table create service_map -b "$BUCKET" --merge-key ci_id \
--columns-json '[
{"name":"ci_id","type":"long","nullable":false},
{"name":"service_id","type":"string","nullable":true},
{"name":"business_service","type":"string","nullable":true},
{"name":"tier","type":"int","nullable":true}
]'
# precomputed blast rollups, one row per CI
dodil data table create impact_analysis -b "$BUCKET" --merge-key analysis_id \
--columns-json '[
{"name":"analysis_id","type":"string","nullable":false},
{"name":"ci_id","type":"long","nullable":true},
{"name":"impacted_ci_count","type":"int","nullable":true},
{"name":"impacted_services_json","type":"string","nullable":true},
{"name":"max_hop","type":"int","nullable":true},
{"name":"computed_at","type":"timestamp","nullable":true}
]'# models.py — the three tables this skill OWNS, as SQLAlchemy models (natural PKs, never SERIAL)
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, Float, Integer, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class Impact(Base):
"""The reverse, TYPED impact edges — `ci_edges` flipped and restricted to the rels that
propagate failure (impact_rels). A pure edge row: composite all-key PK, so db.upsert
lands it with ON CONFLICT DO NOTHING. `CREATE GRAPH cmdb_impact` SNAPSHOTS this table, so
every impacts row must exist BEFORE the (re-)create (see routes.assemble_graph)."""
__tablename__ = "impacts"
src: Mapped[int] = mapped_column(BigInteger, primary_key=True) # the failing CI (was ci_edges.dst)
dst: Mapped[int] = mapped_column(BigInteger, primary_key=True) # the CI it impacts (was ci_edges.src)
class ServiceMap(Base):
"""CI → business-service rollup key — the right-hand side of the impacted-service rollup,
keyed on `ci_id` so a rebuild upserts in place. CIs with no service (hosts, shared caches)
are simply absent from the map."""
__tablename__ = "service_map"
ci_id: Mapped[int] = mapped_column(BigInteger, primary_key=True) # natural key (the CI id)
service_id: Mapped[str | None] = mapped_column(String, nullable=True)
business_service: Mapped[str | None] = mapped_column(String, nullable=True)
tier: Mapped[int | None] = mapped_column(Integer, nullable=True)
class ImpactAnalysis(Base):
"""The precomputed per-CI blast rollup, keyed on a deterministic `analysis_id` = "ia-<ci>"
so a recompute UPSERTS in place — one row per CI, ever. `computed_at` is a `DateTime`
(timestamp, not string) so the recompute clock is real."""
__tablename__ = "impact_analysis"
analysis_id: Mapped[str] = mapped_column(String, primary_key=True) # "ia-<ci_id>" (deterministic)
ci_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
impacted_ci_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
impacted_services_json: Mapped[str | None] = mapped_column(String, nullable=True)
max_hop: Mapped[int | None] = mapped_column(Integer, nullable=True)
computed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # timestamp, never stringStep 2 — Map every CI to its business service
The impacted-service rollup needs a right-hand side: which business service each CI belongs to. Read it
straight off the masters — cis.service_id JOINed to services — into service_map. CIs with no service
(hosts, shared caches) are simply absent from the map.
The seeded estate is 13 CIs, 14 typed edges, 4 business services and 5 owner groups, carrying 13 incidents — small enough to read end to end, big enough that the blast radius is not obvious by eye.
In itsm, populate service_map from cis JOINed to services: for every CI that has a service_id, write ci_id, service_id, business_service, and tier.
data_pgWrote 12 service_map rows — the 12 CIs that belong to one of the 4 business services (Online Checkout, Order Management, Customer Accounts, Internal Reporting). host-app-01 has no service_id and is simply absent from the map.
dodil data pg -b "$BUCKET" "
INSERT INTO service_map (ci_id, service_id, business_service, tier)
SELECT c.id, c.service_id, s.business_service, s.tier
FROM cis c JOIN services s ON s.service_id = c.service_id"Step 3 — Assemble the graph: reverse, TYPED impacts + the single CREATE GRAPH
This is the heart of the skill. Blast radius is the reverse question — not what does this CI depend on?
but who depends on it? — so you build a reverse edge table (impacts) by flipping ci_edges. But you cannot
flip every edge: ci_edges mixes three relationship kinds, and only the ones that propagate failure belong
in the blast graph. That's the impact_rels knob — default all three (depends_on, runs_on, part_of).
WARNING
Type the traversal or you over-count. A blanket reverse over every ci_edges row pulls in relationships
that shouldn't propagate failure (a part_of composition edge, say, where a component failing doesn't take
the whole down). This is the ITSM analog of the CRM family-rollup bug where an untyped hop leaked
partner_of edges into a subsidiary rollup. impacts is built only from rels in impact_rels — you'll
prove the difference in Step 6.
Then — because CREATE GRAPH snapshots its edges at creation — you (re-)create both graphs after
impacts is fully populated. This skill owns the assembly, so it DROP+CREATEs: the forward cmdb (over
ci_edges, for "what does X depend on?") and the reverse cmdb_impact (over impacts, the blast graph).
In itsm, build the reverse impact edges: insert into impacts SELECT dst AS src, src AS dst FROM ci_edges WHERE rel IN ('depends_on','runs_on','part_of'). Then DROP + CREATE GRAPH cmdb over cis/ci_edges (forward) and cmdb_impact over cis/impacts (reverse), so both snapshots see every edge.
data_pgBuilt 14 impacts rows (all 14 impact-bearing ci_edges flipped — 11 depends_on, 2 runs_on, 1 part_of). CREATE GRAPH cmdb (forward, over ci_edges) and CREATE GRAPH cmdb_impact (reverse, over impacts) both succeeded — 13 nodes each.
# reverse + TYPE the edges: only rels in impact_rels propagate failure (default all three).
# TRUNCATE first so a re-run is idempotent — impacts is PK (src,dst); a bare re-INSERT duplicate-keys
# (and a WHERE-less DELETE is rejected: "use WHERE TRUE or TRUNCATE").
dodil data pg -b "$BUCKET" "TRUNCATE TABLE impacts"
dodil data pg -b "$BUCKET" "
INSERT INTO impacts (src, dst)
SELECT dst AS src, src AS dst FROM ci_edges
WHERE rel IN ('depends_on','runs_on','part_of')"
# this skill OWNS the assembly — DROP + CREATE both graphs AFTER impacts is populated (snapshot rule)
dodil data pg -b "$BUCKET" "DROP GRAPH IF EXISTS cmdb"
dodil data pg -b "$BUCKET" "CREATE GRAPH cmdb NODES (cis KEY id) EDGES (ci_edges SRC src DST dst)"
dodil data pg -b "$BUCKET" "DROP GRAPH IF EXISTS cmdb_impact"
dodil data pg -b "$BUCKET" "CREATE GRAPH cmdb_impact NODES (cis KEY id) EDGES (impacts SRC src DST dst)"WARNING
This is the most destructive operation in the ITSM module, and it is the one route in this component
that carries a permission. POST /graph/assemble TRUNCATEs impacts and service_map and then
DROP+CREATEs both graphs. It is gated on itsm:cmdb:rebuild — see Auth
for why this one earned a permission and the rest of the CMDB did not. The reason is the failure mode:
a half-assembled graph does not error. It returns fewer impacted CIs — and a smaller blast radius
reads as good news, right up until a change everyone scored low takes down checkout.
Step 4 — The blast radius (the flagship query)
Now the payoff. graph_khop('cmdb_impact', $CI_ID, $MAX_HOPS) walks the reverse edges forward from the
failing CI and returns the whole impacted set with hop distance; you hydrate names by joining cis in the
same statement. A failing pg-orders-primary (CI 1) takes down 10 CIs across three hops — and you know
it before a customer does:
In itsm, give me the full blast radius of pg-orders-primary (CI 1) over cmdb_impact — every impacted CI, hop-ranked, with its name, type, and owner group, up to 5 hops.
data_pgBlast radius of pg-orders-primary: 10 impacted CIs, max hop 3. Hop 1 (5): accounts-api, checkout-api, orders-api, pg-orders-replica, reporting-etl. Hop 2 (3): payments-gateway, search-index, web-storefront. Hop 3 (2): cdn-edge, mobile-app-bff.
dodil data pg -b "$BUCKET" "
SELECT k.hop_distance, c.name, c.ci_type, c.owner_group
FROM graph_khop('cmdb_impact', 1, 5) k
JOIN cis c ON c.id = k.node
ORDER BY k.hop_distance, c.name"
# hop_distance name ci_type owner_group
# 1 accounts-api app g-platform
# 1 checkout-api app g-platform
# 1 orders-api app g-orders
# 1 pg-orders-replica database g-orders
# 1 reporting-etl app g-orders
# 2 payments-gateway app g-platform
# 2 search-index app g-orders
# 2 web-storefront app g-platform
# 3 cdn-edge app (none)
# 3 mobile-app-bff app g-platformRead that as an incident commander, not a DBA. One database going down reaches five systems immediately,
another three one hop behind them, and the customer-facing edge two hops after that. reporting-etl is in
there at hop 1 — an internal batch job nobody would have paged, sharing a primary with checkout. That is
precisely the relationship a flat CMDB spreadsheet loses.
The same traversal in Cypher over Bolt — the graph plane speaks the Neo4j protocol, hands back node keys, and
you join cis for properties (its Cypher subset dedupes the node set for you, so no DISTINCT / no id() in
RETURN — aggregate in the enclosing SQL if you need to):
Same blast radius in Cypher over Bolt: from pg-orders-primary (id 1), follow impacts up to 5 hops and return the impacted nodes.
data_boltReturns the same 10 nodes with identical hop distances — 11, 9, 8, 4, 3 at hop 1; 12, 6, 5 at hop 2; 13, 10 at hop 3. Same rows, same graph, two protocols.
dodil data bolt -b "$BUCKET" -g cmdb_impact \
"MATCH (f)-[:impacts*1..5]->(a) WHERE id(f)=1 RETURN a"
# node hop_distance
# 11 1 9 1 8 1 4 1 3 1
# 12 2 6 2 5 2
# 13 3 10 3Ten nodes, same hop distances, over a completely different wire protocol — because it is the same graph over the same rows, not a replica that a sync job keeps approximately current.
Step 5 — Roll the blast up to impacted business services
A list of CIs is for engineers; the incident bridge and the CAB want the business impact. JOIN the blast set
to service_map and GROUP BY business_service — one statement, graph traversal folded into a SQL aggregate:
In itsm, roll the pg-orders-primary blast radius up to business services: which business services does it hit, and how many CIs does each lose?
data_pgAll 4 business services are hit: Online Checkout loses 5 CIs, Order Management 3, Customer Accounts 1, Internal Reporting 1. One database, the whole service catalogue.
dodil data pg -b "$BUCKET" "
SELECT sm.business_service, count(*) AS impacted_cis
FROM graph_khop('cmdb_impact', 1, 5) k
JOIN service_map sm ON sm.ci_id = k.node
GROUP BY sm.business_service
ORDER BY impacted_cis DESC, sm.business_service"
# business_service impacted_cis
# Online Checkout 5
# Order Management 3
# Customer Accounts 1
# Internal Reporting 1That is the sentence the bridge wants: pg-orders-primary is down, and it takes all four business services
with it — Online Checkout hardest, five CIs deep. It is also the sentence the CAB wants on a Friday, which
is why change management reads exactly this rollup rather than recomputing it —
CHG3001 and CHG5001 both scored cab_review, risk high, off these numbers.
Step 6 — Type your traversal (prove it, don't trust it)
Here is why impact_rels is load-bearing, and it is worth slowing down for — this single knob is the
difference between a blast radius an incident commander trusts and one they learn to ignore.
ci_edges carries three relationship kinds, and they do not mean the same thing. depends_on and runs_on
propagate failure: if the thing you depend on dies, you die. part_of is composition — it says a CI is a
piece of a larger CI, which is a statement about structure, not about failure. Whether composition should
propagate is a genuine judgement call about your estate, and impact_rels is where you record the answer.
In this estate exactly one edge is part_of: mobile-app-bff is part_of web-storefront. That single
edge is the whole experiment, because mobile-app-bff has no other path into the graph. With all three rels
in effect, pg-orders-primary's blast is the 10 CIs you just saw. Drop part_of and it falls to 9 —
mobile-app-bff disappears, and Online Checkout drops from 5 impacted CIs to 4:
In itsm, show pg-orders-primary's typed blast radius with part_of excluded — walk ci_edges with a recursive CTE restricted to depends_on and runs_on, then roll it up to business services, and compare against the all-rels numbers.
data_pgWith impact_rels=[depends_on, runs_on] the blast is 9 CIs (down from 10) and Online Checkout falls from 5 impacted CIs to 4 — mobile-app-bff drops out, because a part_of edge was its ONLY path in. Order Management (3), Customer Accounts (1) and Internal Reporting (1) are unchanged. Max hop stays 3.
# the typed walk, WITHOUT rebuilding the graph — a recursive CTE over ci_edges with an explicit
# rel filter (cypher() can't filter on an edge's rel; see workflow op 3 in Routes)
dodil data pg -b "$BUCKET" "
WITH RECURSIVE blast(node, hop) AS (
SELECT CAST(1 AS BIGINT) AS node, 0 AS hop
UNION
SELECT e.src, b.hop + 1 FROM ci_edges e JOIN blast b ON e.dst = b.node
WHERE e.rel IN ('depends_on','runs_on') AND b.hop < 5)
SELECT count(*) AS impacted_ci_count, max(hop) AS max_hop
FROM (SELECT node, min(hop) AS hop FROM blast WHERE node <> 1 GROUP BY node) t"
# impacted_ci_count max_hop
# 9 3 <- 10 with part_of; mobile-app-bff is the one that drops
# and the same contrast at the business-service level — the number the CAB actually reads
# Online Checkout 4 (5 with part_of)
# Order Management 3
# Customer Accounts 1
# Internal Reporting 1One CI and one business service. That is the entire measured difference, and it is exactly why the
distinction matters rather than being pedantry. A blanket, untyped reverse over every ci_edges row keeps
mobile-app-bff in every blast radius that touches web-storefront — so every change to the storefront
scores one tier riskier than it is, and every bridge pages one team that did not need paging. Do that across
a real estate with thousands of composition edges and the blast radius inflates until nobody believes it. The
failure of an over-counting CMDB is not that it is wrong; it is that people stop reading it.
Note also which direction the error runs. Over-typing (dropping a rel that does propagate) under-counts — and under-counting is the dangerous one, because a blast radius that is too small reads as good news. That asymmetry is why this is a decision you make deliberately per estate, and why the post proves it with a contrast rather than asserting it.
NOTE
graph_khop() has three rules, and they shape how you write every rollup. It projects node +
hop_distance — not node_id, which is the single most common way to waste ten minutes here. It
resolves only as a top-level SELECT: not inside a subquery, a CTE, a UNION branch, or an
INSERT … SELECT (any of those fail with Table Function graph_khop does not exist). And its start node
must be an integer literal, not a column or a bound parameter.
So the pattern for a materialized rollup is always two statements: run the traversal as a verifying
top-level SELECT, then write the result with a separate idempotent INSERT … ON CONFLICT. That is
exactly what Step 7 and workflow op 4 do — not a stylistic choice, a constraint. When you need a typed
traversal, cypher() can't filter on an edge's rel either, so you fall back to a recursive CTE over
ci_edges (the query you just ran). Either way: type the edge.
(The contrast above used the recursive CTE precisely so it did not disturb the assembled graph — no
TRUNCATE, no re-CREATE GRAPH, nothing for a concurrent reader to trip over. The rollups below still
reflect the default impact_rels = [depends_on, runs_on, part_of].)
Step 7 — Precompute the per-CI blast rollups
An incident bridge shouldn't wait on a traversal. Precompute each CI's blast — size, impacted services, depth —
into impact_analysis, keyed analysis_id = 'ia-<ci>' so a recompute upserts in place (one row per CI, ever).
In itsm, compute pg-orders-primary's (CI 1) blast rollup — impacted CI count, distinct impacted business services, and max hop — then upsert it into impact_analysis as analysis_id ia-1.
data_pg→data_table_upsertpg-orders-primary rollup: impacted_ci_count 10, max_hop 3, impacted services Customer Accounts + Internal Reporting + Online Checkout + Order Management. Upserted impact_analysis ia-1 (wal_written: true). Re-running upserts the same key — one row per CI, ever.
# compute the rollup straight off the graph (graph_khop at top-level FROM — never inside INSERT … SELECT)
dodil data pg -b "$BUCKET" "
SELECT count(*) AS impacted_ci_count,
max(k.hop_distance) AS max_hop,
string_agg(DISTINCT sm.business_service, ',') AS services
FROM graph_khop('cmdb_impact', 1, 5) k
LEFT JOIN service_map sm ON sm.ci_id = k.node"
# impacted_ci_count max_hop services
# 10 3 Customer Accounts,Internal Reporting,Online Checkout,Order Management
# upsert the precomputed rollup (deterministic key -> idempotent)
dodil data table upsert impact_analysis -b "$BUCKET" \
--row '{"analysis_id":"ia-1","ci_id":1,"impacted_ci_count":10,"impacted_services_json":"[\"Customer Accounts\", \"Internal Reporting\", \"Online Checkout\", \"Order Management\"]","max_hop":3,"computed_at":"2026-09-08T22:40:12Z"}'Two things about this table are worth saying out loud, because they are the reason the gate and the idempotency are shaped the way they are.
It is the table everything else believes. Nothing in the module re-derives the blast radius. Change
management reads impact_analysis to score a change; the major-incident bridge reads it to scope an outage.
So a stale or half-assembled row does not surface as an error anywhere — it surfaces as a risky change scored
low and an outage scoped too small. A derived table that nothing validates is a table everything
believes. That is the whole argument for gating the rebuild (itsm:cmdb:rebuild), for making the recompute
idempotent so re-running it is always safe, and for proving the typed traversal with a measured contrast
instead of asserting it.
And it is why the seven components were finally validated together. Each of the seven ITSM components
passed its own ## Test alone. Stood up on one bucket on 2026-09-08 — as itsm-suite-app actually runs —
they surfaced six integration bugs in an afternoon: a vector column the ORM could not read back, two
schema drifts, a read-your-writes assumption that returned None, a state machine whose only producer could
not satisfy its own entry condition, and — the sharpest — one ticket created with a NULL open time that took
the SLA engine down for the entire estate, silently freezing every other incident's breach flags
(the full story is in the SLA post). Not one of them was findable on a
private bucket, because alone each component was perfectly self-consistent. That is the lesson, and it is
worth more than any individual bug: components validated separately are internally consistent and still
wrong together.
Step 8 — The impact-summary gate (optional)
Deterministic blast is the baseline and it's free. When you want the incident bridge handed a sentence — a
severity and a recommended change window — turn on impact_summary_gate and let kimi-k2.6 narrate the blast
set. It only ranks and describes; the deterministic blast decides which CIs.
NOTE
kimi-k2.6 is a reasoning model. Called via ignite models chat (MCP/CLI) there's no max_tokens
knob, so it can return empty content. End the prompt Return ONLY compact JSON, no reasoning/preamble
and retry until non-empty (retry-once is not enough for this model). The deployed handler sets
max_tokens: 4096 on the raw api.dodil.io/v1 call — the interactive path can't. This is also the only
reason the engine's service account needs ignite.model-user; without the gate it's pure graph/SQL.
Summarize the pg-orders-primary blast for the incident bridge on kimi-k2.6. Failing CI pg-orders-primary (database, prod); blast radius 10 CIs over 3 hops; impacted services Online Checkout (5 CIs), Order Management (3), Customer Accounts (1), Internal Reporting (1). Return ONLY JSON: impact_summary (25 words or fewer), recommended_window (immediate|off_peak|maintenance), severity (low|medium|high|critical).
ignite_models_chat{"impact_summary":"Primary order database failure cascades to 10 CIs across all four business services; checkout and order management customer-facing.","recommended_window":"immediate","severity":"critical"}
dodil ignite models chat kimi-k2.6 \
--system 'You are a CMDB change-impact assistant. Given a failing CI, its blast radius, and the impacted business services, return ONLY compact JSON: {"impact_summary":"25 words or fewer","recommended_window":"immediate|off_peak|maintenance","severity":"low|medium|high|critical"}. No prose, no reasoning, no preamble. Return ONLY compact JSON, no reasoning/preamble.' \
--message 'Failing CI: pg-orders-primary (database, prod). Blast radius: 10 CIs, max hop 3. Impacted business services: Online Checkout (5 CIs), Order Management (3), Customer Accounts (1), Internal Reporting (1).'
# -> {"impact_summary":"Primary order database failure cascades to 10 CIs across all four business services; checkout and order management customer-facing.","recommended_window":"immediate","severity":"critical"}Routes
The download (see Get the code) fronts this bucket with a small FastAPI app, routes.py —
CRUD over the CMDB plus the ops that turn it into answers: assemble the graph, traverse a blast radius,
type that traversal, and precompute the rollups. This is what you deploy. Every route follows the same
DataK3 rules the package bakes in, so quoting it is documenting them.
The connection and the one write helper live in db.py (shared verbatim across the suite). A DataK3
bucket is a Postgres endpoint — db name = the bucket, user = the literal token, password = your
DODIL token — so there's no data connect step in code, just fixed region constants. upsert() is the
only writer every route uses:
# db.py — INSERT ... ON CONFLICT DO UPDATE (idempotent keyed write); DO NOTHING for pure edge rows
def upsert(session, model, rows, key):
keys = [key] if isinstance(key, str) else list(key)
table = model.__table__
cols = {c for r in rows for c in r}
rows = [{c: r.get(c) for c in cols} for r in rows]
stmt = pg_insert(table).values(rows)
update_cols = [c.name for c in table.columns if c.name not in keys]
if update_cols:
stmt = stmt.on_conflict_do_update(
index_elements=keys,
set_={c: getattr(stmt.excluded, c) for c in update_cols},
)
else:
# every column is part of the key (a pure edge/junction row) — nothing to update.
stmt = stmt.on_conflict_do_nothing(index_elements=keys)
session.execute(stmt)Why it matters: on DataK3 a bare re-INSERT of an already-committed primary key raises duplicate-key
23505 — a plain INSERT is not an upsert on re-write. upsert makes a retry, a shard replay, or a
recompute land the row once. That's the whole reason a nightly blast recompute is safe to re-run.
CRUD — each write is an upsert then a commit (DataK3 has no read-your-writes inside an open
transaction; the engine is expire_on_commit=False, so routes commit before they return). The ci_edges
write is a pure edge row (composite all-key PK), so upsert lands it with ON CONFLICT DO NOTHING:
# routes.py — CRUD over the CMDB, keyed on the natural PK
@app.post("/cis")
def upsert_ci(c: CiIn, s: Session = Depends(db)):
upsert(s, Ci, [c.model_dump()], key="id")
s.commit()
return {"ok": True, "id": c.id}
@app.post("/ci_edges")
def upsert_ci_edge(e: CiEdgeIn, s: Session = Depends(db)):
if e.rel not in ALL_RELS:
raise HTTPException(422, f"rel must be one of {ALL_RELS}")
# a pure edge row: composite all-key PK, so upsert lands it with ON CONFLICT DO NOTHING.
upsert(s, CiEdge, [e.model_dump()], key=("src", "dst", "rel"))
s.commit()
return {"ok": True, "edge": [e.src, e.dst, e.rel]}Workflow op 1 — assemble the graph (the snapshot rule, GRAPH). POST /graph/assemble rebuilds the
reverse, TYPED impacts edges from ci_edges (restricted to IMPACT_RELS), refreshes service_map,
then DROP+CREATEs both graphs — because CREATE GRAPH snapshots its edges, every impacts row
must exist before the (re-)create or it isn't traversable. The rel list is whitelisted before it's
inlined (the traversal functions can't bind a parameter — see op 3):
# routes.py — workflow op 1: assemble the reverse graph, the snapshot rule
@router.post("/graph/assemble")
def assemble_graph(user=Depends(require_permission("itsm:cmdb:rebuild")),
s: Session = Depends(db)):
rel_frag = _rel_in(IMPACT_RELS)
# rebuild the reverse impact edges: TRUNCATE (idempotent) then flip the impact-bearing rels.
s.execute(text("TRUNCATE TABLE impacts"))
s.execute(text(
"INSERT INTO impacts (src, dst) "
f"SELECT dst AS src, src AS dst FROM ci_edges WHERE rel IN ({rel_frag})"))
# refresh the CI→business-service map (CIs with no service are simply absent).
s.execute(text("TRUNCATE TABLE service_map"))
s.execute(text(
"INSERT INTO service_map (ci_id, service_id, business_service, tier) "
"SELECT c.id, c.service_id, sv.business_service, sv.tier "
"FROM cis c JOIN services sv ON sv.service_id = c.service_id"))
s.commit()
# (re-)snapshot both graphs AFTER impacts is fully populated (the snapshot rule).
for stmt in (
f"DROP GRAPH IF EXISTS {FWD_GRAPH}",
f"CREATE GRAPH {FWD_GRAPH} NODES (cis KEY id) EDGES (ci_edges SRC src DST dst)",
f"DROP GRAPH IF EXISTS {IMPACT_GRAPH}",
f"CREATE GRAPH {IMPACT_GRAPH} NODES (cis KEY id) EDGES (impacts SRC src DST dst)",
):
s.execute(text(stmt))
s.commit()
n = s.execute(text("SELECT count(*) FROM impacts")).scalar()
return {"ok": True, "impact_rels": IMPACT_RELS, "impacts_edges": int(n),
"graphs": [FWD_GRAPH, IMPACT_GRAPH]}Live-verified on bucket itsm: POST /graph/assemble builds 14 impacts edges (all three
impact_rels — 11 depends_on, 2 runs_on, 1 part_of), refreshes service_map to 12 rows, and
re-snapshots both graphs over 13 nodes.
Look at what that one call destroys before it rebuilds: two TRUNCATEs and two DROP GRAPHes. This is
the only route in the component that carries a permission (itsm:cmdb:rebuild) — everything else here,
including the precompute, runs on Depends(current_user) alone. The Auth
section explains why the audit drew the line exactly there.
Workflow op 2 — the blast radius + impacted-service rollup (GRAPH). GET /cis/{ci_id}/blast walks
the pre-typed cmdb_impact reverse graph forward from the failing CI, hydrates names, and rolls the
blast up to business services — one call. DataK3's graph functions come with three rules the code obeys:
graph_khop/cypher() are top-level table functions (no UNION/subquery/CTE), the anchor id must be
an integer literal (so the FastAPI-validated int ci_id is inlined, not bound), and you feed the
returned node ids into a SQL IN (…) (DuckDB has no = ANY(array)):
# routes.py — workflow op 2: the blast set over the pre-typed reverse graph
def _untyped_blast(s: Session, ci_id: int) -> list[tuple[int, int]]:
# cmdb_impact's edges are already restricted to impact_rels, so an UNTYPED forward walk
# from the failing CI IS the typed blast. graph_khop is a top-level table function; the
# anchor is an integer literal (the validated ci_id, inlined).
rows = s.execute(
text(
f"SELECT node, hop_distance FROM graph_khop('{IMPACT_GRAPH}', {int(ci_id)}, {MAX_HOPS})"
)
).all()
return [(int(n), int(h)) for n, h in rows]Live-verified: GET /cis/1/blast (pg-orders-primary) returns 10 impacted CIs over 3 hops, and an
impacted_services rollup of Online Checkout (5), Order Management (3), Customer Accounts (1)
and Internal Reporting (1) — all four business services. The same ten ids come back over Bolt (MATCH (f)-[:impacts*1..5]->(a) WHERE id(f)=1), with identical hop distances.
Workflow op 3 — a TYPED blast at query time (the recursive-CTE escape hatch, GRAPH). cypher()'s
subset takes only an id(<var>)=<key> anchor — it can't filter on an edge's rel — so a blast
restricted to a subset of rels can't be expressed in cypher(). GET /cis/{ci_id}/blast_typed falls
back to a recursive CTE over ci_edges with an explicit rel IN (…) filter (the same typed escape
hatch the CRM account-hierarchy rollup uses), still a top-level SELECT with an integer-literal root:
# routes.py — workflow op 3: typed blast without rebuilding the graph (recursive CTE over the edges)
def _typed_blast(s: Session, ci_id: int, rels: list[str]) -> list[tuple[int, int]]:
rel_frag = _rel_in(rels) # whitelisted against ALL_RELS — no injection surface
rows = s.execute(
text(
"WITH RECURSIVE blast(node, hop) AS ("
f" SELECT CAST({int(ci_id)} AS BIGINT) AS node, 0 AS hop "
" UNION "
" SELECT e.src, b.hop + 1 FROM ci_edges e JOIN blast b ON e.dst = b.node "
f" WHERE e.rel IN ({rel_frag}) AND b.hop < {MAX_HOPS}) "
f"SELECT node, min(hop) AS hop_distance FROM blast WHERE node <> {int(ci_id)} "
"GROUP BY node"
)
).all()
return [(int(n), int(h)) for n, h in rows]Live-verified — this is the trap the whole skill exists to prove: GET /cis/1/blast_typed
(pg-orders-primary) returns 10 CIs with all three rels, but 9 with
?rels=depends_on&rels=runs_on — mobile-app-bff, reachable only over the part_of composition edge,
drops out, and Online Checkout falls from 5 impacted CIs to 4. Type the edge or you over-count.
Workflow op 4 — precompute the per-CI rollup (idempotent). POST /cis/{ci_id}/analysis writes the
blast size, distinct impacted services, and max hop into impact_analysis, keyed analysis_id='ia-<ci>'
so a recompute upserts in place:
# routes.py — workflow op 4: precompute impact_analysis (deterministic key -> idempotent)
row = {
"analysis_id": f"ia-{ci_id}",
"ci_id": ci_id,
"impacted_ci_count": len(blast),
"impacted_services_json": json.dumps(services),
"max_hop": max((h for _, h in blast), default=0),
"computed_at": _now(),
}
upsert(s, ImpactAnalysis, [row], key="analysis_id")
s.commit()Live-verified: POST /cis/1/analysis writes {analysis_id: "ia-1", impacted_ci_count: 10, max_hop: 3, impacted_services_json: ["Customer Accounts","Internal Reporting","Online Checkout","Order Management"]};
a second POST re-writes the same ia-1 key (count(*) = 1, computed_at updated in place) —
INSERT … ON CONFLICT (analysis_id) DO UPDATE, never a duplicate. That idempotency is exactly why this
route needs no permission: re-running it cannot change the answer.
Adding a business operation touches only routes.py (and maybe models.py) — the plumbing in
db.py is fixed. The pattern is one Pydantic *In schema + one @app.<verb> function: write via
upsert, untyped graph via cypher(…)/graph_khop, typed graph via a recursive CTE (see
EXTENDING.md in the package).
Auth — config at the edge, a role gate in the app
On Ignite, end-user login is configuration, not code. The ITSM deploys with the itsm-suite
dodil-appid pool attached (user_pool: itsm-suite in .dodil/deploy.yaml) and the per-cluster Ignite
gateway runs the entire browser login at the edge — PKCE S256 against the pool issuer, an AEAD-sealed
host-only session cookie, single-flight refresh, EdDSA JWT verification against trust anchors this app does
not hold — then injects the verified identity into every request it forwards: X-Dodil-User (sub,
email, connection, app_roles), X-Dodil-User-Jwt (the raw verified token, carrying the
catalog-expanded permissions claim) and X-Dodil-Auth-Source (pool for an app end-user, platform for
an operator or service-account invoke). Any inbound copy of those headers is stripped first, on every
mode and every principal, so a caller can never forge them.
What survives in the package is a small auth.py that ships no verifier — no JWKS client, no
issuer/audience env, no crypto dependency, and no pyjwt in requirements.txt. It reads the injected
header and keeps the one job the app still owns: role-based gating.
# auth.py — a header-trust reader, not a verifier (the gateway already checked the JWT)
def current_user(request: Request) -> dict:
raw = request.headers.get("x-dodil-user") # {"sub","email","connection","app_roles"}
... # + permissions read off x-dodil-user-jwt
raise HTTPException(401, "end-user login required — no X-Dodil-User from the gateway")
def require_permission(perm: str):
"""Gate a route on a pool permission: Depends(require_permission("itsm:cmdb:rebuild"))."""
def _dep(user: dict = Depends(current_user)) -> dict:
if perm not in user["permissions"] and perm not in user["roles"]:
raise HTTPException(403, f"missing permission: {perm}")
return user
return _depITSM uses namespaced permissions — <module>:<object>:<verb> — so one customer pool can carry every
ERP module's roles without collision (itsm:change:approve is not crm:change:approve). Across all seven
components the audit left exactly four gates standing:
| Permission | Gates | Why this one and not the rest |
|---|---|---|
itsm:change:approve | change-management: POST /changes/{id}/assess, POST /changes/{id}/transition, POST /change-policies | accepting the risk of a production change. change_approvals.decided_by records the gateway-vouched user |
itsm:major:declare | major-incident: POST /major/declare | declaring a major incident pages the org. bridges.declared_by records who |
itsm:incident:resolve | major-incident: POST /major/bridges/{id}/state | the only route in the module that writes incidents.state='resolved' — what stops the SLA clock |
itsm:cmdb:rebuild | this component: POST /graph/assemble | it TRUNCATEs impacts + service_map and DROP+CREATEs both graphs; every other component's blast radius is whatever this route leaves behind |
Why this component has exactly one gate
itsm:cmdb:rebuild was not in the original design. It is a fourth permission beyond the three the
roadmap predicted, and it earned its place for a specific reason: POST /graph/assemble is the most
destructive operation in the module, and its failure is silent. A half-assembled graph raises nothing.
It returns fewer impacted CIs — and a smaller blast radius reads as good news. Every consumer downstream
(the CAB's risk score, the bridge's scope, the affected-CI read) inherits that quiet under-count without a
single error in a log.
The gate this post used to describe — cmdb:write on the per-CI precompute — was deleted. The
precompute recomputes derived rows from the graph, accepts no user input, and re-running it changes
nothing; it is idempotent by construction. Gating an idempotent recompute is ceremony, and ceremony is
exactly what an auditor discounts.
That is the question the audit actually asked, and it is worth stealing for your own build. Not "is this
a write?" — most writes are just work. But: does this accept risk, page people, stop a clock, or
destroy something? One route in this component answers yes. Everything else here — CI and edge CRUD, the
blast reads, the typed traversal, the precompute — takes Depends(current_user) and nothing more.
Applied across all seven components, that question left four of them with zero gates at all:
itsm-core, itsm-incident-management, itsm-problem-management and itsm-sla-management. CMDB CRUD,
triage and clustering are the service desk's ordinary work — a permission every agent on the desk must
hold protects nothing and only obscures the three that matter. Two other gates went the same way as
cmdb:write in that audit: incidents:triage and problems:write.
The pool, created once
Create a dodil-appid pool itsm-suite with email+password, and set its role catalog: the service desk agent role holds no permissions, a change manager may approve changes, an incident commander may declare a major incident and resolve incidents, and a CMDB admin may rebuild the graph.
Pool itsm-suite created — issuer https://appid.dodil.io/ihdiash/itsm-suite, audience pool:itsm-suite, email+password (local) enabled. Catalog set: agent holds no permissions; change-manager = itsm:change:approve; incident-commander = itsm:major:declare, itsm:incident:resolve; cmdb-admin = itsm:cmdb:rebuild. A user's next token carries app_roles plus the catalog-expanded permissions claim the gates read.
dodil appid pool create itsm-suite --with-local
# issuer: https://appid.dodil.io/ihdiash/itsm-suite audience: pool:itsm-suite
dodil appid roles set itsm-suite \
agent= \
change-manager=itsm:change:approve \
incident-commander=itsm:major:declare,itsm:incident:resolve \
cmdb-admin=itsm:cmdb:rebuildNote that agent — the service desk, the largest role in any ITSM deployment — holds no permissions at
all. It does the ungated work, which is most of the module. That is the shape a good permission catalogue
has: small, and mostly empty.
The two-plane rule is unchanged: the pool identifies the user; the app still reaches DataK3 through
its own service account (sa_token.py mints and refreshes a client_credentials token for the pg-wire
password) — an app-user is never a bucket principal. Locally, with no gateway in front of
uvicorn routes:app, opt in to a stub identity with DEV_ALLOW_ANON=1; the stub carries no permissions
unless you grant them (DEV_USER_PERMISSIONS=itsm:cmdb:rebuild,…), so the gated route stays gated on a
laptop too. The full flow — creating the pool, the redirect_uris allowlist, and what the gateway injects —
is App authentication; the catalog mechanics are App
roles. Today it is email+password (local); oauth / oidc / saml corporate SSO
switch on per pool later, no app change.
The other half of the rule: recompute routes take no identity at all
The SLA engine's POST /sla/tick carries no identity dependency whatsoever — not a permission, not even
Depends(current_user). It is a machine heartbeat, called by a service account over a platform invoke,
which carries no X-Dodil-User. A current_user dependency there would 401 the clock; the engine would
stop and every incident's breach flags would silently go stale — the failure mode of an SLA system that
reports green. Exposure is the ingress's job (public_invoke=false), not a user permission.
This applies to every recompute route in every module — a rollup, a re-materialization, an embedding backfill. If a machine calls it, gate the door, not the caller. Which is why the graph rebuild here is an interesting edge case worth being deliberate about: it is destructive enough to gate for a human caller, so when you drive it from an engine on a schedule, that engine authenticates as a service account through the ingress — not by holding a business user's permission.
Get the code
The package is a real download — code/itsm-cmdb-blast-radius/v1.tar.
This post is a walkthrough of exactly those files; the tarball is source only (no Dockerfile — deploy is
in Ship it):
models.py # SQLAlchemy — impacts / service_map / impact_analysis (owned) + cis / ci_edges / services (consumed)
routes.py # FastAPI — CRUD + graph assemble + blast (graph) + typed blast (recursive CTE) + precompute
db.py # lazy engine + the ON CONFLICT upsert helper every route uses (shared across the suite)
sa_token.py # mints/refreshes the service-account client_credentials token used as the pg-wire password
auth.py # header-trust role gate — reads what the gateway injected. No verifier, no JWKS, no pyjwt
.env.example # BUCKET + PG_HOST/PG_PORT + DODIL_TOKEN + IMPACT_RELS/MAX_HOPS (no APPID_* anything)
requirements.txt # sqlalchemy, psycopg[binary], pgvector, fastapi, uvicorn, pydantic, httpx
README.md # run it, the routes, the DataK3 rules the code depends on
EXTENDING.md # the pattern for adding a workflow route
PLATFORM.md # the platform invariants — shipped WITH the code, so the tar carries the rules
Two of those are worth a sentence. sa_token.py is lazy on purpose — it mints no token at import, so
app.openapi() builds with no credentials at all and CI can generate the API client without secrets. And
PLATFORM.md ships inside the tar rather than living only in a repo: every line in it is a scar from a
real failure on this platform, and whoever downloads the package gets the rules along with the code.
.env.example no longer configures authentication, because there is nothing to configure — no issuer, no
audience, no JWKS. Its only auth-related content is a commented-out local-dev-only block
(DEV_ALLOW_ANON=1 plus an optional DEV_USER_PERMISSIONS=…) for running without a gateway in front.
Run it — point .env at your bucket, create the tables from the models, serve:
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # then set DODIL_TOKEN (your `dodil auth login` token)
# BUCKET defaults to "itsm"; create it once (Step 1), then create the tables from the models:
python -c "import db, models; models.Base.metadata.create_all(db.engine)"
uvicorn routes:app --reload
# POST /cis, /ci_edges, /services · POST /graph/assemble (itsm:cmdb:rebuild)
# GET /cis/{id}/blast · GET /cis/{id}/blast_typed?rels=depends_on · POST /cis/{id}/analysismodels.Base.metadata.create_all is the ORM tab's every class at once — the same tables Step 1 built
by CLI, created from the natural-key models with no migration tool.
How the pillars map
One bucket, two pillars, one copy of the rows — this is where the graph plane earns its keep.
| Job | The usual stack | On DataK3 |
|---|---|---|
| CMDB nodes + typed dependencies | Neo4j + a sync job | cis / ci_edges, one CREATE GRAPH |
| Reverse impact edges | A materialized Neo4j view | impacts — SELECT dst, src FROM ci_edges WHERE rel IN (…) |
| Blast radius ("who depends on X?") | Neo4j variable-length match | graph_khop('cmdb_impact', …) or data bolt MATCH — same bucket |
| Impacted-service rollup | Graph traversal + a warehouse JOIN | one GROUP BY over service_map, graph folded in |
| Precomputed blast per CI | A nightly batch into a datamart | impact_analysis, upserted, read-your-writes |
No ETL, no second copy, no drift between the graph and the ticket record — the blast radius and the incidents it correlates are the same live rows.
Customize — the decisions this skill asks you
Q1 · impact_rels — which relationships propagate failure?
"Which relationship kinds count as failure propagation —
depends_on,runs_on,part_of?" → The typed-traversal knob. Sets theWHERE rel IN (…)of the reverseimpactsbuild (and therefore the blast size). Default is all three. Droppart_ofwhen composition ≠ failure — the blast then excludes any CI reachable only over apart_ofedge (Step 6: 10 CIs → 9, Online Checkout 5 → 4). A blanket, untyped reverse over-counts; over-typing under-counts, which is the more dangerous direction.
Q2 · max_hops — how deep does a blast reach?
"How many hops deep should a blast radius reach?" → The traversal depth of
graph_khop('cmdb_impact', $CI, $MAX_HOPS)and the Bolt*1..Nbound. Default 5 comfortably covers this estate, whose deepest blast is 3 hops (database → api → storefront → edge/bff).
Q3 · impact_summary_gate — narrate the blast with a model?
- false (default) → deterministic blast + impacted-service rollup only; the engine's service account needs
just
k3.editor+ignite.app-developer(noignite.model-user, no token-billed calls). - true → deploy
itsm-cmdb-enginewithignite.model-userand thekimi-k2.6gate returns{impact_summary, recommended_window, severity}per CI.
Industry overlays (finserv, manufacturing) compose this skill — manufacturing extends the blast across
OT controls edges to reach impacted plant lines, not just IT services.
(Suite-shared answers — bucket and seed_data — are asked once at the suite level and not re-asked here.)
Test
Every query below ran live against DataK3 on 2026-09-08 (org IHDIASH, bucket itsm) — with all
seven ITSM components stood up together on that one bucket, not on a private validation bucket of this
component's own. That distinction is the point: it is what turns a passing test into evidence. Real results
are inline. tested_branches: full (impact_rels: [depends_on, runs_on, part_of]) and
typed-narrowing (impact_rels: [depends_on, runs_on]).
# 0) the estate: 13 CIs, 14 typed edges, 4 business services, 5 groups, 13 incidents
dodil data pg -b "$BUCKET" "SELECT
(SELECT count(*) FROM cis) AS cis, (SELECT count(*) FROM ci_edges) AS edges,
(SELECT count(*) FROM impacts) AS impacts, (SELECT count(*) FROM service_map) AS service_map,
(SELECT count(*) FROM services) AS services"
# cis 13 | edges 14 | impacts 14 | service_map 12 | services 4
# 1) blast radius of pg-orders-primary (CI 1) = 10 CIs over 3 hops, hop-ranked
dodil data pg -b "$BUCKET" "
SELECT k.hop_distance, c.name FROM graph_khop('cmdb_impact', 1, 5) k
JOIN cis c ON c.id = k.node ORDER BY k.hop_distance, c.name"
# -> hop 1: accounts-api, checkout-api, orders-api, pg-orders-replica, reporting-etl
# -> hop 2: payments-gateway, search-index, web-storefront
# -> hop 3: cdn-edge, mobile-app-bff
# 2) same over Bolt -> the same 10 nodes, identical hop distances
dodil data bolt -b "$BUCKET" -g cmdb_impact "MATCH (f)-[:impacts*1..5]->(a) WHERE id(f)=1 RETURN a"
# -> 11,9,8,4,3 @1 ; 12,6,5 @2 ; 13,10 @3
# 3) impacted-service rollup -> all 4 business services
dodil data pg -b "$BUCKET" "
SELECT sm.business_service, count(*) AS impacted_cis
FROM graph_khop('cmdb_impact', 1, 5) k JOIN service_map sm ON sm.ci_id = k.node
GROUP BY sm.business_service ORDER BY impacted_cis DESC, sm.business_service"
# Online Checkout 5 | Order Management 3 | Customer Accounts 1 | Internal Reporting 1
# 4) TYPED proof: drop part_of -> the blast shrinks 10 -> 9, Online Checkout 5 -> 4
# (mobile-app-bff, reachable ONLY via the single part_of edge, is excluded)
dodil data pg -b "$BUCKET" "
WITH RECURSIVE blast(node, hop) AS (
SELECT CAST(1 AS BIGINT) AS node, 0 AS hop UNION
SELECT e.src, b.hop + 1 FROM ci_edges e JOIN blast b ON e.dst = b.node
WHERE e.rel IN ('depends_on','runs_on') AND b.hop < 5)
SELECT count(*) FROM (SELECT node FROM blast WHERE node <> 1 GROUP BY node) t"
# 9 (10 with all three rels)
# 5) idempotent recompute -> one row per CI, ever
dodil data sql -b "$BUCKET" "SELECT analysis_id, impacted_ci_count, max_hop FROM impact_analysis WHERE ci_id = 1"
# ia-1 | 10 | 3 (a second POST re-writes the same key; count stays 1)Live-captured values: the estate is 13 CIs / 14 typed edges / 4 business services / 5 groups / 13
incidents; POST /graph/assemble built 14 reverse impacts edges and 12 service_map rows (only
host-app-01 has no service) and re-snapshotted both graphs over 13 nodes; graph_khop('cmdb_impact', 1, 5)
returned 10 CIs with max hop 3, and the Bolt MATCH returned the same ten node ids at the same hop
distances; the service rollup hit all four business services (Online Checkout 5, Order Management 3,
Customer Accounts 1, Internal Reporting 1); the typed narrowing dropped it to 9 CIs / Online Checkout 4;
impact_analysis holds the single row ia-1 (impacted_ci_count 10, max_hop 3, all four services) and
stayed at one row per CI after re-upsert.
One-shot
With the DODIL MCP connected, paste this to assemble the CMDB blast radius — it stubs a minimal core slice so it runs standalone:
Assemble a CMDB blast radius on DataK3 (bucket itsm = SQL + graph). Confirm each step.
1. If itsm/core is absent, stub it: cis (key id: name, ci_type, environment, owner_group, service_id,
business_criticality, status) with 13 CIs — pg-orders-primary(1,database,svc-orders),
redis-session(2,database,svc-checkout), checkout-api(3,app,svc-checkout), orders-api(4,app,svc-orders),
payments-gateway(5,app,svc-checkout), web-storefront(6,app,svc-checkout), host-app-01(7,host,NULL),
accounts-api(8,app,svc-accounts), reporting-etl(9,app,svc-reporting), mobile-app-bff(10,app,svc-checkout),
pg-orders-replica(11,database,svc-orders), search-index(12,app,svc-orders), cdn-edge(13,app,svc-checkout);
services (key service_id): svc-checkout/Online Checkout, svc-orders/Order Management,
svc-accounts/Customer Accounts, svc-reporting/Internal Reporting; and ci_edges (key src,dst,rel) TYPED —
depends_on (3->1, 3->2, 4->1, 5->3, 6->3, 6->5, 8->1, 9->1, 11->1, 12->11, 13->6),
runs_on (3->7, 4->7), part_of (10->6). [13 CIs, 14 edges — exactly one part_of]
2. Create impacts (key src,dst), service_map (key ci_id), impact_analysis (key analysis_id). Populate
service_map from cis JOIN services -> 12 rows (host-app-01 has no service).
3. Build impacts = flip of ci_edges WHERE rel IN (depends_on,runs_on,part_of) -> 14 rows. DROP + CREATE GRAPH
cmdb over cis/ci_edges and cmdb_impact over cis/impacts (snapshot rule — every edge before the create).
4. Blast radius: graph_khop('cmdb_impact', 1, 5) JOIN cis -> 10 CIs, max hop 3. Same over Bolt
(MATCH (f)-[:impacts*1..5]->(a) WHERE id(f)=1). Roll up via service_map -> Online Checkout 5,
Order Management 3, Customer Accounts 1, Internal Reporting 1.
5. Prove typing WITHOUT touching the graph: recursive CTE over ci_edges restricted to depends_on+runs_on ->
the blast shrinks 10 -> 9 and Online Checkout 5 -> 4, because mobile-app-bff's only path in is part_of.
6. Precompute impact_analysis per CI (analysis_id ia-<ci>, idempotent) -> ia-1 = {10 CIs, max_hop 3,
4 services}. Re-run it and confirm the row count does not change.Connect your tools
Everything this build assembled lives in one DataK3 bucket, reachable by your own stack — not just the CLI.
data connect itsm prints the endpoints; point your tools straight at the same rows:
- SQL over Postgres wire —
psql,psycopg/asyncpg(Python),node-postgres(TS). - Graph over Bolt — a Neo4j driver or
cypher-shellagainstcmdb/cmdb_impact.
Full, live-validated walkthrough: Connect your tools.
Ship it
The recompute loop is one small image-mode Ignite app — itsm-cmdb-engine. On invoke or tick it rebuilds
impacts from ci_edges (respecting impact_rels), re-snapshots both graphs, and precomputes
impact_analysis per CI — an HTTP server (GET /healthz, POST /rebuild) packaged by a Dockerfile and built
on deploy, not a handler(payload, ctx) compile-mode function. Graph reads go over Bolt
(bolt+s://bolt.uk-lon-1.dodil.io:7687); the impacts rebuild and the rollups go over the drop-in Postgres
wire (pg.uk-lon-1.dodil.io:5432, dbname=itsm, user=token, password = the SA access token) via psycopg —
there is no K3 HTTP API. The impacts rebuild is TRUNCATE + INSERT, and the per-CI impact_analysis
rollup re-writes the same analysis_id (ia-<ci>) so it's INSERT … ON CONFLICT (analysis_id) DO UPDATE
(or the managed data table upsert) — a bare re-INSERT of an already-committed PK raises duplicate-key 23505.
Give it its own least-privilege service account — k3.editor to write, ignite.app-developer as the deploy
identity, and ignite.model-user only if impact_summary_gate=true — then deploy its Dockerfile. Set
DODIL_SERVICE_ACCOUNT_ID to the cli-… serviceAccountId, not the uuid (the uuid fails client_credentials
with invalid_client).
Create a service account itsm-cmdb-engine-sa, grant it k3.editor plus ignite.app-developer (deterministic default — no ignite.model-user), then deploy my ./cmdb-engine (image mode — its Dockerfile builds on deploy) to Ignite as itsm-cmdb-engine on port 8080 with health path /healthz, passing the service-account creds, BUCKET, IMPACT_RELS, and MAX_HOPS as runtime env. Use the cli- serviceAccountId as DODIL_SERVICE_ACCOUNT_ID, not the uuid. Then POST to /rebuild to smoke-test.
auth_service-account_create→auth_service-account_grant-role→ignite_app_deployCreated itsm-cmdb-engine-sa (serviceAccountId cli-itsm-cmdb-engine-sa), granted k3.editor + ignite.app-developer, built + deployed itsm-cmdb-engine (image:build, public FQDN on :8080, scale-to-zero). POST /rebuild rebuilt impacts, re-snapshot both graphs, and wrote impact_analysis for 11 CIs, written=true.
# create prints the serviceAccountId (cli-itsm-cmdb-engine-sa) + the secret. The client_credentials
# client_id is that serviceAccountId — NOT the uuid (the uuid fails with invalid_client).
dodil auth service-account create itsm-cmdb-engine-sa
SA_ID=cli-itsm-cmdb-engine-sa
# grant-role addresses the SA by its uuid; the env below uses the serviceAccountId.
SA_UUID=$(dodil auth service-account list -o json | python3 -c "import sys,json;print([s['uuid'] for s in json.load(sys.stdin) if s['serviceAccountId']=='cli-itsm-cmdb-engine-sa'][0])")
dodil auth service-account grant-role "$SA_UUID" k3-authorization-service k3.editor # write tables
dodil auth service-account grant-role "$SA_UUID" ignite-authorization-service ignite.app-developer # deploy identity
# add: grant-role "$SA_UUID" ignite-authorization-service ignite.model-user # ONLY if impact_summary_gate=true
# IMAGE mode — the platform builds ./cmdb-engine/Dockerfile on deploy (Lane B / Kaniko build-on-deploy).
dodil ignite app deploy itsm-cmdb-engine \
--code ./cmdb-engine --dockerfile-path Dockerfile \
--port 8080 --health-path /healthz --allow-unauthenticated \
--env BUCKET="$BUCKET" --env IMPACT_RELS=depends_on,runs_on,part_of --env MAX_HOPS=5 \
--env DODIL_SERVICE_ACCOUNT_ID="$SA_ID" --env DODIL_SERVICE_ACCOUNT_SECRET="$SA_SECRET"
# runtime image:build -> public FQDN itsm-cmdb-engine-$ORG-8080.ignite.dodil.cloud
# the app is an HTTP server now — smoke-test with a POST to /rebuild (not ignite invoke)
curl -sS -X POST "https://itsm-cmdb-engine-$ORG-8080.ignite.dodil.cloud/rebuild" \
-H 'Content-Type: application/json' -d '{}'NOTE
Deploy: image mode (Lane B), validated live 2026-09-02. This pattern deploys, serves /healthz + its
route unauthenticated, and writes durably — confirmed end-to-end via the sibling crm-lead-scorer engine
(written rows survived +154s, re-confirmed at +95s). itsm-cmdb-engine reuses that identical handler/deploy
pattern; its own graph assembly, blast, rollup, and precompute run live one call at a time above and in
## Test. If dodil ignite app deploy returns IAM resource registration failed: broken pipe, deploy under
a fresh app name — the half-created app can't be updated or deleted.
Who calls the rebuild — the scheduler question, said plainly
Ignite is request-invoked, and there is no server-side scheduler. Nothing on the platform will call your rebuild on a timer. For a CMDB that matters less than it does for a clock — a blast graph that is a few minutes stale is usually fine, and the rebuild is naturally event-driven (a CI or edge changed). But it is the same decision the whole module faces, and a deployment has to make it explicitly. Two real options:
- an always-on pinned app — deploy with
--reserved 1 --max-replicas 1and run a loop in the pod that calls the rebuild on an interval. Self-contained; costs a warm replica; never scales to zero. - an external scheduler — cron, a CI timer, any orchestrator POSTing
/rebuild. The app stays scale-to-zero, but the schedule now lives on infrastructure outside DODIL.
Either way the caller is a service account over a platform invoke, not a browser user — which is exactly
why a machine-driven route must not sit behind current_user. Where this really bites is the SLA clock,
because an SLA clock that only advances when someone loads a page is not an SLA clock:
SLA management has the full treatment.
The full lifecycle — DODIL git → CI checks → a scanned registry image → versioning and rollback — is in Ship a DODIL App.
The suite — seven components, one app
This package runs standalone (uvicorn routes:app) — that is what this post walks through, and the
code/itsm-cmdb-blast-radius download is still exactly that. Deployed,
the seven ITSM components compose into one app: itsm-suite-app is a single FastAPI with a router
per component, one canonical models.py (23 tables) and plain imports — no importlib loader — over one
bucket (itsm) and one dodil-appid pool, so seven components mean one sign-in and one bill. Fetch it as
code/itsm-suite-app. It ships by the ordinary git cycle (repo → CI → registry →
CD): Ship a DODIL app.
One app rather than seven is the ERP default; you split only for a stated reason — a public surface against a
private engine, independent scaling, a distinct trust boundary — and ITSM has none of those. It is also, as
Step 7 said, the only configuration in which the six integration bugs were findable at all: this component's
impacts and service_map are read by components that never appear in this post.
Conclusion
You assembled the ITSM suite's CMDB graph on one DataK3 bucket — the reverse, typed impacts edges and
two snapshotted graphs over one cis / ci_edges copy — then turned a failing pg-orders-primary into its
blast radius in one hop-ranked query (10 CIs, max hop 3), rolled that up to all four business services
it takes down, and proved the trap: type your traversal or you over-count (drop part_of and the blast
honestly shrinks to 9 CIs, with Online Checkout falling from 5 to 4 — mobile-app-bff had exactly one
path in). The graph and the ticket record are the same rows — no Neo4j sync, no nightly correlation, no drift.
Two things to carry out of here beyond the graph. The gate audit's question — not "is this a write?" but "does this accept risk, page people, stop a clock, or destroy something?" — left this component with one permission on the one destructive route, and four of the seven ITSM components with none at all. And the reason the numbers above are trustworthy: they were measured with all seven components live on one bucket, the configuration that surfaced six integration bugs no component could find while validating itself.
Next steps:
- Build a ServiceNow-style ITSM on DataK3 — the anchor: the incident core, the CMDB, and the auto-triage engine.
- Ship a DODIL App — take
itsm-cmdb-enginefrom code to a versioned public endpoint.