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 radiusif pg-orders-primary falls 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.

PieceLands inPillar
Configuration items + typed dependenciescis / ci_edges (core)SQL → graph nodes/edges
The reverse impact edgestable impactsSQL → graph edges
The assembled graphscmdb / cmdb_impactGraph
CI → business-service maptable service_mapSQL (the rollup key)
Precomputed blast rollups per CItable impact_analysisSQL
The recompute loopitsm-cmdb-engineIgnite (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 dodil CLI (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 typed ci_edges (depends_on / runs_on / part_of), and the services master. 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.)

You

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

DODIL MCP tools called
data_table_create
Agent

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

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

You

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.

DODIL MCP tools called
data_pg
Agent

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

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

You

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.

DODIL MCP tools called
data_pg
Agent

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

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:

You

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.

DODIL MCP tools called
data_pg
Agent

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

Read 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):

You

Same blast radius in Cypher over Bolt: from pg-orders-primary (id 1), follow impacts up to 5 hops and return the impacted nodes.

DODIL MCP tools called
data_bolt
Agent

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

Ten 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:

You

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?

DODIL MCP tools called
data_pg
Agent

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

That 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 9mobile-app-bff disappears, and Online Checkout drops from 5 impacted CIs to 4:

You

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.

DODIL MCP tools called
data_pg
Agent

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

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

You

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.

DODIL MCP tools called
data_pgdata_table_upsert
Agent

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

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.

You

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

DODIL MCP tools called
ignite_models_chat
Agent

{"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_onmobile-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 _dep

ITSM 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:

PermissionGatesWhy this one and not the rest
itsm:change:approvechange-management: POST /changes/{id}/assess, POST /changes/{id}/transition, POST /change-policiesaccepting the risk of a production change. change_approvals.decided_by records the gateway-vouched user
itsm:major:declaremajor-incident: POST /major/declaredeclaring a major incident pages the org. bridges.declared_by records who
itsm:incident:resolvemajor-incident: POST /major/bridges/{id}/statethe only route in the module that writes incidents.state='resolved' — what stops the SLA clock
itsm:cmdb:rebuildthis component: POST /graph/assembleit 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

You

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.

Agent

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.

Note 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}/analysis

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

JobThe usual stackOn DataK3
CMDB nodes + typed dependenciesNeo4j + a sync jobcis / ci_edges, one CREATE GRAPH
Reverse impact edgesA materialized Neo4j viewimpactsSELECT dst, src FROM ci_edges WHERE rel IN (…)
Blast radius ("who depends on X?")Neo4j variable-length matchgraph_khop('cmdb_impact', …) or data bolt MATCH — same bucket
Impacted-service rollupGraph traversal + a warehouse JOINone GROUP BY over service_map, graph folded in
Precomputed blast per CIA nightly batch into a datamartimpact_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 the WHERE rel IN (…) of the reverse impacts build (and therefore the blast size). Default is all three. Drop part_of when composition ≠ failure — the blast then excludes any CI reachable only over a part_of edge (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..N bound. 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 (no ignite.model-user, no token-billed calls).
  • true → deploy itsm-cmdb-engine with ignite.model-user and the kimi-k2.6 gate 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-shell against cmdb / cmdb_impact.

Full, live-validated walkthrough: Connect your tools.

Ship it

The recompute loop is one small image-mode Ignite appitsm-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).

You

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.

DODIL MCP tools called
auth_service-account_createauth_service-account_grant-roleignite_app_deploy
Agent

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

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 1 and 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 4mobile-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: