What you'll build: the master-data core of an ITSM — the system of record every other workflow (incident triage, problem clustering, change/CAB, SLA, blast-radius) reads — on one DataK3 bucket. Eight merge-keyed tables (cis, incidents, problems, changes, services, users, groups, and a typed ci_edges topology) are the transactional spine; the CMDB dependency graph turns a failing pg-orders-primary into its full blast radius — every dependent CI, three hops out, across all four business services — in one hop-ranked query; and an inline VECTOR(2048) column on incidents answers "have we seen this before?" by meaning. The same rows answer by content (SQL), by relationship (graph), and by meaning (vector) over one copy — no ETL, no Postgres + Neo4j + Pinecone to keep in sync.

What you'll learn:

  • Model ITSM master data as merge-keyed DataK3 tables — idempotent upserts, one store for the whole record.
  • Project CIs + their dependencies into a typed graph and traverse it — a failing CI's blast radius in one statement.
  • Inline a similar-incident index — a VECTOR(2048) column on incidents, jina-embeddings-v4, cosine KNN.
  • Run every step two ways — by prompting an agent over the MCP, or the dodil data CLI.

The problem — and why it matters

An ITSM suite is priced per agent seat — a mid-size IT org runs 300–2,000 fulfillers at roughly $100–$150 each per month, so the platform bill lands in the seven figures a year before a single custom workflow. And that price buys a system that is really four systems stitched together: the ticket database (Postgres), a KB / similar-incident index (Pinecone), the CMDB relationship store (Neo4j — every app→service→host→db dependency), and a reporting warehouse the whole thing is ETL'd into overnight. Four engines, four bills, four copies of the same rows, and glue code to keep them agreeing.

The whole point of a CMDB is the question "if the order database falls over, what breaks?" — and answering it means a graph traversal the ticket DB can't do, over data the warehouse only has at yesterday's freshness. So the graph lives in Neo4j, the tickets in Postgres, and correlating them is a nightly job.

On DataK3 the incident record is the graph node is the similar-incident index: one bucket, three pillars, one copy of the rows. The master core is eight tables, one graph, and — at core — not a single line of handler code. That is the payoff, and the reason master data is where the money starts: collapse four engines and their ETL into one bucket, and every other itsm/* skill composes onto the same rows.

PieceLands inPillar
CIs / incidents / problems / changes / services / users / groupstables (merge-keyed)SQL
CMDB topology + blast radiusci_edges (typed) → graph cmdbGraph
Similar-incident searchincidents.embedding (VECTOR(2048), inline)Vector

NOTE

Connect the DODIL MCP once — then every step shows an Ask your agent tab (the default — DODIL is agent-native) and a CLI tab. This whole core was built and validated by prompting an agent over this MCP.

Prerequisites

  • A DODIL organization with the dodil CLI authenticated (dodil auth login) or the DODIL MCP connected to your agent (Claude Code, Cursor, VS Code, Codex). Headless? Check auth_status first — an agent can't do the browser login for you.
  • export BUCKET=itsm — one bucket is the whole ITSM's data plane. (This is the bucket param, default itsm.)

Step 1 — Stand up the eight master + CMDB tables

The ITSM is the schema: merge-keyed tables in one bucket. id keys the transactional records (cis/incidents/problems/changes), service_id/user_id/group_id key the reference masters, and ci_edges is keyed on the composite (src, dst, rel). A --merge-key (PRIMARY KEY) is required — writes are keyed, so re-runs and shard retries upsert idempotently, and reads are read-your-writes across committed transactions (a fresh row is visible to the next JOIN, no compaction step — though not inside the transaction that wrote it; every route here commits before it reads back).

The bucket is called itsm and that is the whole data plane. Four characters is deliberate: DataK3 rejects a bucket name shorter than three, which is why the GL suite has to live in gl-suite rather than gl. ITSM needs no such suffix.

TIP

data table create makes non-PK columns NOT NULL by default. Set nullable:true on every optional column — especially incidents.resolution, resolved_at, priority, category, and embedding — or a partial seed row (a new incident with no resolution yet) fails with NotNullViolation.

You

Create a DataK3 bucket itsm, then eight merge-keyed tables: cis (key id) with name, ci_type, environment, owner_group, service_id(string), business_criticality(int), status; incidents (key id) with number, short_description, description, ci_id(long), service_id(string), state, priority, impact, urgency, category, subcategory, assignment_group, assigned_to, problem_id(long), opened_at(timestamp), resolved_at(timestamp), resolution, embedding VECTOR(2048); problems (key id) with number, short_description, description, root_cause, state, known_error(boolean), workaround, related_change_id(long), created_at; changes (key id) with number, ci_id(long), short_description, description, type, state, risk, impact, requested_by, assignment_group, approval_state, planned_start, planned_end, actual_start, actual_end, problem_id(long); services (key service_id) with name, business_service, owner_group, tier(int), sla_id; users (key user_id) with name, email, group_id, role; groups (key group_id) with name, manager, email, on_call(boolean); ci_edges (key src,dst,rel) with src(long), dst(long), rel, weight(double). Make every non-key column nullable.

DODIL MCP tools called
data_bucket_createdata_table_create
Agent

Created bucket itsm and 8 tables — cis (pk id, 8 cols), incidents (pk id, 19 cols incl. embedding VECTOR(2048)), problems (pk id), changes (pk id), services (pk service_id), users (pk user_id), groups (pk group_id), ci_edges (pk src,dst,rel). Upserts are idempotent; the same bucket also holds the CMDB graph and the inline similar-incident vectors.

One rule about names, because it cost two bugs

The ORM attribute name is the column name. Always. It looks like a style preference and it is not: it is load-bearing, because two idioms this whole suite is built on read the column list off the model. The keyed write helper takes a dict of column names, and the read-merge-upsert used by every workflow route is {c.name: getattr(row, c.name) for c in Model.__table__.columns}. A mapped attribute whose name differs from its column breaks both — getattr raises AttributeError, and passing the attribute name to db.upsert comes back as an "unconsumed column names" error.

Two components had quietly drifted, and neither noticed while it ran on its own bucket. incidents.service_id was typed BigInteger in one package and String in every other — the masters are strings like svc-orders, so a JOIN to services over it would have compared an integer to a varchar. And changes.type was mapped as the attribute change_type in one package and as type everywhere else. The two stories are in problem management and incident management; the rule is here, because this post owns the canonical models. When you copy these into a customer build, keep it — one canonical models.py shared by every component is what makes the drift impossible in the first place.

NOTE

This core IS the masters. Every itsm/* workflow skill (incident-management, problem-management, change-management, sla-management, cmdb-blast-radius) consumes these seven masters and adds only its own sidecar tables — one row, many readers, no second copy. Installed standalone without core, each workflow ships a stub_masters step that recreates just the masters it reads; once two workflows share masters, you install itsm/core once here instead. Core has nothing to stub — it is the root of the DAG.

Step 2 — Seed the demo CMDB + incident history (seed_data=demo)

Load the e-commerce estate: 13 CIs spanning apps, hosts and databases, the 4 business services they roll up to, the 5 groups that own them and the 5 users who staff those groups. These cis rows are the graph's nodes in Step 3 and the ci_id foreign key every incident points at. Everything downstream — the blast radius, the similar-incident KNN, the SLA clock's escalation manager — asserts against exactly these rows.

The shape to notice: pg-orders-primary (CI 1) is the order database, and almost everything else reaches it eventually. That is what makes it the interesting failure in Step 3.

You

In itsm, upsert 13 configuration items into cis: pg-orders-primary and pg-orders-replica and redis-session (databases), checkout-api, orders-api, payments-gateway, web-storefront, accounts-api, reporting-etl, mobile-app-bff, search-index, cdn-edge (apps), host-app-01 (host) — each with environment prod, an owner_group, a service_id, business_criticality, status operational. Then upsert 4 services (Checkout tier 1 -> Online Checkout, Orders tier 1 -> Order Management, Accounts tier 2 -> Customer Accounts, Reporting tier 3 -> Internal Reporting), 5 groups (g-orders, g-platform, g-dba, g-network, g-cab) and 5 users.

DODIL MCP tools called
data_table_upsert
Agent

Upserted 13 CIs (wal_written: true), 4 services, 5 groups, 5 users. CI 1 pg-orders-primary is the order database the rest of the estate leans on; each incident points at one CI by ci_id.

Now the incidents — 13 of them: six resolved history rows (each with its fix and the team that owned it) and seven still open across new and triaged. Each carries a VECTOR(2048) embedding of its symptoms; you produce that with jina-embeddings-v4 in Step 4 and write the [f1,f2,…] literal. Because embeddings are large, upsert one row per call (a batched frame carrying several 2048-dim vectors exceeds the gateway's first-frame size).

Three of the resolved rows — INC1003, INC1004, INC1005 — are the same underlying failure on pg-orders-primary, a connection-pool exhaustion traced to leaking reporting-etl cursors, and all three were fixed by g-dba. That repetition is deliberate: it is the precedent set Step 4's KNN finds, the cluster problem management turns into PRB2001, and the reason a recurring incident becomes a problem rather than a fourth ticket.

Note opened_at on every row. A new incident legitimately leaves priority, category and resolution unset — that is why those columns are nullable:true — but opened_at is never optional in the row, because it is the SLA clock's t0. More on that in Routes, where it cost an outage.

You

In itsm, embed each incident's short_description + description with jina-embeddings-v4, then upsert the row one per call: six resolved history incidents (1001 checkout 502s -> g-platform; 1002 orders API latency -> g-orders; 1003/1004/1005 the recurring order-database connection-pool exhaustion -> g-dba; 1006 session cache evictions -> g-platform) each with resolution + resolved_at + assignment_group set, plus the open ones (1007 reporting extract failed, 1008 mobile BFF stale prices, 1009 order database connections exhausted, 1010 and 1011 checkout 502s, 1012 CDN cache miss rate, 1013 search index lag). Every row carries an opened_at.

DODIL MCP tools called
ignite_models_embeddata_table_upsert
Agent

Upserted 13 incidents (6 resolved, 7 open across new/triaged); 11 carry a 2048-dim jina-embeddings-v4 vector literal (wal_written: true, one row per call). INC1003/1004/1005 are the same recurring pg-orders-primary pool exhaustion, all resolved by g-dba — the precedent set the KNN in Step 4 finds.

TIP

Keyed upserts drop null/empty keys. A JSON null — or an empty string "" — in a merge-key column reads back as null and the row silently disappears. The transactional records key on a real id; the reference masters key on a real service_id/user_id/group_id — never "".

Step 3 — Build the typed CMDB dependency graph (topology=true)

This is the pillar an ITSM lives or dies on. A DataK3 graph is table-backed: the cis table is the node set (KEY id), the ci_edges table holds src → dst tagged by rel, and CREATE GRAPH binds them. The rel column is what makes the traversal typeddepends_on (an app/service needs another CI), runs_on (a service runs on a host), part_of (composition). Populate ci_edges fully, then CREATE GRAPH — the graph snapshots its edges at creation, so edges added afterwards aren't traversable until you re-create it.

You

In itsm, populate ci_edges with the estate topology: depends_on edges checkout-api->pg-orders-primary, checkout-api->redis-session, orders-api->pg-orders-primary, payments-gateway->checkout-api, web-storefront->checkout-api, web-storefront->payments-gateway, accounts-api->pg-orders-primary, reporting-etl->pg-orders-primary, pg-orders-replica->pg-orders-primary, search-index->pg-orders-replica, cdn-edge->web-storefront; runs_on edges checkout-api->host-app-01 and orders-api->host-app-01; and one part_of edge mobile-app-bff->web-storefront. Then CREATE GRAPH cmdb over node table cis (KEY id) and edges ci_edges (SRC src DST dst).

DODIL MCP tools called
data_table_upsertdata_pg
Agent

Upserted 14 ci_edges across 3 relationship kinds (11 depends_on, 2 runs_on, 1 part_of). CREATE GRAPH cmdb NODES (cis KEY id) EDGES (ci_edges SRC src DST dst) succeeded — edges snapshotted at creation.

Edges point from the dependent to the dependencycheckout-api → pg-orders-primary reads "checkout-api needs the order database". So graph_neighbors('cmdb', 3) walks forward and answers what does this CI rely on? From checkout-api (3) that's the order database, the session cache, and the host it runs on:

You

What does checkout-api (CI 3) directly depend on? Use graph_neighbors on cmdb, joined to cis for names.

DODIL MCP tools called
data_pg
Agent

neighbor 1 (pg-orders-primary), neighbor 2 (redis-session), neighbor 7 (host-app-01) — its database, its session cache, and the host it runs on.

But blast radius is the reverse, typed questionif this CI fails, who is impacted? — i.e. every CI that transitively depends on the failing one. Build a reverse impacts edge table from the impact-propagating rels only (depends_on + runs_onpart_of composition may not propagate failure, so it's excluded by policy), create a cmdb_impact graph over it, and graph_khop(...) from the failing CI returns the whole impacted set with hop distance, names hydrated by joining cis in the same statement.

NOTE

Type the traversal. A blanket reverse walk over every ci_edges row over-counts when a rel like part_of shouldn't propagate failure. Filtering rel IN ('depends_on','runs_on') when you build impacts is what keeps the blast radius honest. In the suite, this reverse assembly (and its impact_rels knob) is owned by itsm/cmdb-blast-radius, which runs the single CREATE GRAPH after core has loaded all the edges — core defers the graph so nothing is snapshotted twice.

You

In itsm, build the reverse impact graph typed to the impact-propagating rels: create impacts(src,dst) as the flip of ci_edges WHERE rel IN (depends_on, runs_on), then CREATE GRAPH cmdb_impact over cis / impacts. Then give me the full blast radius of pg-orders-primary (CI 1), hop-ranked and hydrated with CI names.

DODIL MCP tools called
data_pg
Agent

impacts built from the 13 impact-propagating edges (part_of excluded). Blast radius of pg-orders-primary = 9 CIs, max hop 3: hop 1 checkout-api, orders-api, accounts-api, reporting-etl, pg-orders-replica; hop 2 payments-gateway, web-storefront, search-index; hop 3 cdn-edge.

Real output — one order database failing reaches 9 CIs, three hops out, and you know which team owns each before a customer does:

 hop_distance  name               ci_type   owner_group
 1             checkout-api       app       g-platform
 1             orders-api         app       g-orders
 1             accounts-api       app       g-platform
 1             reporting-etl      app       g-orders
 1             pg-orders-replica  database  g-orders
 2             payments-gateway   app       g-platform
 2             web-storefront     app       g-platform
 2             search-index       app       g-orders
 3             cdn-edge           app       (unassigned)

Roll those CIs up to services.business_service and the answer stops being infrastructure and starts being a sentence an executive understands: all four business services are affected — Online Checkout 4 CIs, Order Management 3, Customer Accounts 1, Internal Reporting 1. That rollup is what a major-incident bridge reports as "what is down", and it is owned by itsm/cmdb-blast-radius.

The typing is not theoretical — here is what it costs. Build impacts from all 14 edges instead of the 13 impact-propagating ones and the same blast radius returns 10 CIs, with Online Checkout at 5. The single CI that appears is mobile-app-bff, and it is reachable only over the part_of edge mobile-app-bff → web-storefront. Composition is not causation: the mobile BFF being part of the storefront does not mean an order-database outage takes it down. One unfiltered flip, one phantom CI, and a business-service count that is wrong in the direction that gets people paged.

The same forward walk in Cypher over Bolt — the graph plane hands back node keys; join cis for properties:

You

Same CMDB in Cypher over Bolt: from web-storefront, follow ci_edges up to 3 hops and return the dependency subtree.

DODIL MCP tools called
data_bolt
Agent

From web-storefront (6): hop 1 checkout-api and payments-gateway, hop 2 pg-orders-primary, redis-session, host-app-01 (via checkout-api) — the storefront's whole dependency subtree, keys the graph plane returns and you join cis for properties.

Step 4 — The inline similar-incident index (similar_incidents=true)

The incidents.embedding column you declared in Step 1 is the semantic index — no Pinecone, no second copy. Ask the fleet-tested question "have we seen this before?": embed each incident's short_description + description with jina-embeddings-v4, write the vector inline (that's the Step 2 upsert), and KNN-search the resolved history by cosine distance over the same rows.

The question a triage engine actually asks is "given this new ticket, which resolved ones look like it, and who fixed them?" — a KNN from the new incident's stored embedding against resolved history, in one statement, over the same table:

You

In itsm, take INC1009's stored embedding and find its nearest RESOLVED incidents by cosine distance, with the team that owned each and the fix they applied.

DODIL MCP tools called
data_pg
Agent

INC1003 0.109, INC1005 0.137, INC1004 0.145 — the three recurring order-database pool exhaustions, all resolved by g-dba, all well inside a 0.25 cosine band. The next nearest, INC1001 (a checkout 502) sits at 0.260, outside it. Three hits, one team, one root cause nobody has permanently fixed.

The three nearest resolved incidents are the three previous order-database pool exhaustions, all fixed by the same team, all inside a 0.25 cosine band — and the fourth result, a superficially similar "connection pool" incident on a different CI, sits at 0.260, outside it. That gap is what makes a threshold usable. Nobody tagged anything; the on-call gets the precedent and the owner, by meaning.

Read those three resolutions together and the actual finding is not "here is your fix" — it is "this keeps happening and nobody has fixed the cause." Turning that into a problem record with a permanent fix is exactly what problem management does with the same column.

In an app you pass a query vector literal instead of joining a stored row — embed the incoming text, interpolate the […] string, and let pgvector drive the KNN:

# $QVEC = the query embedding as a '[f1,f2,…]' literal (from `ignite models embed`)
dodil data sql -b "$BUCKET" \
  "SELECT id, short_description, assignment_group, resolution
     FROM incidents
    WHERE state = 'resolved' AND embedding IS NOT NULL
    ORDER BY embedding <=> '$QVEC' LIMIT 3"

WARNING

Filter embedding IS NOT NULL, always. Cosine distance against a NULL vector doesn't skip the row — it fails the entire statement with list_cosine_distance: … argument can not contain NULL values. On this estate 11 of the 13 incidents are embedded (a ticket raised through the CRUD route has no embedding until something backfills it), which is enough to break an unguarded query. The same NULL is why dodil data vsearch — which takes no predicate — cannot search this table at all; the SQL path with an explicit WHERE is the one that survives a partially-embedded column, and a real system's column is always partially embedded.

Also note: the <=> operator needs a vector literal on the right-hand side. A correlated sub-SELECT of another row's vector does not drive the KNN — pass the literal, or join the row as above.

The one that bites everybody: you cannot read a vector back through the ORM

Everything above goes through SQL, and it all works. The moment you read one of these rows through the ORMsession.get(Incident, 1009), or any select(Incident) — it dies:

AttributeError: 'list' object has no attribute 'split'

Writes are fine. pgvector.sqlalchemy.Vector's bind processor renders the Postgres text form [0.1,0.2,…] and DataK3 accepts it happily. Reads are not. Over a binary-format result — which is exactly what the ORM uses — DataK3 sends a VECTOR column as a native float array, so psycopg hands SQLAlchemy a Python list, while upstream's result processor expects the text form and calls .split() on it.

What makes this genuinely nasty is that it depends on the result format, not the query. A raw text("SELECT embedding …") comes back as a string and works. A package whose incidents table is a stub that omits the embedding column never selects it at all, so it never sees the bug. Every component here passed its own tests. Then all seven were unified onto one canonical models.py — and every single session.get(Incident, …) in the system started failing at once.

The fix is a tolerant subclass, byte-identical in all four ITSM models.py files that declare a vector column:

# models.py — pass a list/tuple straight through; defer to upstream for the text form
class Vector(_PgVector):
    def result_processor(self, dialect, coltype):
        base = super().result_processor(dialect, coltype)
 
        def process(value):
            if value is None or isinstance(value, (list, tuple)):
                return list(value) if value is not None else None
            return base(value)
 
        return process

Distance operators are untouched — cosine_distance() and <=> are computed server-side and come back as plain floats, which is why the KNN above never hinted at a problem.

IMPORTANT

This is platform-wide, not an ITSM quirk, and it is a workaround rather than the end state. Anything on DODIL that reads a vector column through the ORM hits it, in any module — assume it applies to your own build the moment you put a Vector column on a mapped class. The underlying behaviour is filed as a platform issue; when the wire and the upstream result processor agree, this subclass becomes dead code you can delete. Until then, ship it.

This was one of six integration bugs that surfaced when the seven ITSM components were finally stood up together on one bucket, and it is the one with the widest blast radius of its own. The general lesson is worth more than any individual bug, and it is the reason this suite is validated as a system rather than as seven tutorials that happen to share a folder: components validated separately are internally consistent and still wrong together. Alone, each of the seven passed its own ## Test. Together, on one bucket, they surfaced six defects in an afternoon — and every one of them was invisible on a private bucket.

Routes

The download (see Get the code) fronts this bucket with a small FastAPI app, routes.py — CRUD over the models plus the two questions a flat ticket table can't answer: a failing CI's blast radius (graph) and a "have we seen this before?" precedent search (vector). This is what you deploy. Every route follows the same three DataK3 rules the package bakes in, so quoting it is documenting them.

The connection and the one write helper live in db.py. 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 re-import land the row once. Verified live: re-upserting CI 8 with a changed status left count(*) FROM cis at 10 and updated the row in place to degraded; re-posting an existing ci_edges row (its whole (src,dst,rel) is the key) took the DO NOTHING branch and left ci_edges at 10. That's the whole reason the master tables are 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). ci_edges keys on the whole (src,dst,rel) tuple, so its upsert takes the DO NOTHING branch:

# routes.py — CRUD over the models, keyed on the natural PK
@app.post("/cis")
def upsert_ci(c: CiIn, user=Depends(current_user), s: Session = Depends(db)):
    upsert(s, Ci, [c.model_dump()], key="id")
    s.commit()
    return {"ok": True, "id": c.id}
 
 
@app.post("/incidents")
def upsert_incident(i: IncidentIn, user=Depends(current_user), s: Session = Depends(db)):
    row = i.model_dump()
    # stamp t0 if the caller did not supply one — see IncidentIn.opened_at
    row["opened_at"] = row.get("opened_at") or datetime.now(timezone.utc).replace(tzinfo=None)
    upsert(s, Incident, [row], key="id")
    s.commit()
    return {"ok": True, "id": i.id, "opened_at": row["opened_at"].isoformat()}
 
 
@app.post("/ci_edges")
def upsert_ci_edge(e: CiEdgeIn, user=Depends(current_user), s: Session = Depends(db)):
    # ci_edges is the typed CMDB topology; (src,dst,rel) is the whole key, so the upsert
    # helper takes its DO NOTHING branch (a re-posted edge lands once, nothing to update).
    upsert(s, CiEdge, [e.model_dump()], key=["src", "dst", "rel"])
    s.commit()
    return {"ok": True, "edge": [e.src, e.dst, e.rel]}

Those two lines stamping opened_at are a scar, and they are the best bug of the six. IncidentIn originally had no opened_at at all — it is obviously optional, a fresh ticket is "now", and every test this component had passed. So a ticket created through this plain CRUD route landed with a NULL open time.

The SLA clock, three components away, computes both due dates as opened_at + target minutes — and it does that across every open incident in the estate, in one pass. One row with a NULL open time raised TypeError: unsupported operand type(s) for +: 'NoneType' and 'datetime.timedelta', the tick returned 500, and every subsequent tick 500'd too. Not just for that ticket: every other incident's breach flags quietly stopped advancing. A single optional field on a create form took down service-level monitoring for the whole company, and the symptom was not an error anyone saw — it was an SLA dashboard that kept showing green.

The general shape is worth more than the fix: a producer's "optional" field was a consumer's mandatory one, and nobody found out until they shared a bucket. On separate buckets, core's tests passed and the SLA engine's tests passed, because the SLA engine seeded its own incidents and always set opened_at. It took one bucket and one realistic create call to connect them.

It is fixed on both sides, which is the right instinct — a producer that can emit a bad row and a consumer that dies on one are two defects, not one. Core stamps t0 here; the clock skips uncomputable rows and counts them as skipped_no_clock rather than raising. The consumer half, and the rule that a per-tick engine must never be one bad row away from stopping, is in SLA management.

Gates: this component has none, on purpose. Every route above takes Depends(current_user) — a signed-in user — and nothing more. CMDB CRUD and raising an incident are the service desk's ordinary work; a permission that every agent on the desk must hold is ceremony. The full audit, and the four permissions that did survive it, is in Auth below.

Workflow op 1 — a failing CI's blast radius (GRAPH). GET /cis/{ci_id}/blast-radius walks the cmdb_impact graph (the reverse, typed impact graph from Step 3) forward from the failing CI to every dependent CI, then hydrates names + owning groups in SQL. DataK3 runs a Cypher subset embedded in SQL — cypher('<graph>', 'MATCH …') — with three rules the code obeys: it's a top-level table function (no UNION/subquery/CTE), the anchor id must be an integer literal (so the FastAPI-validated 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 1: a failing CI's blast radius (GRAPH)
@app.get("/cis/{ci_id}/blast-radius")
def blast_radius(ci_id: int, user=Depends(current_user), s: Session = Depends(db)):
    """If this CI fails, who is impacted? cmdb_impact is the reverse, typed impact graph
    (the flip of ci_edges WHERE rel IN the impact-propagating rels, built once by
    itsm/cmdb-blast-radius). Walk it forward from the failing CI to every dependent CI,
    then hydrate names + owning groups in SQL."""
    # DataK3 runs a Cypher subset embedded in SQL: cypher('<graph>', 'MATCH ...'). It's a
    # top-level table function (can't sit in a UNION/subquery) and the anchor id must be an
    # integer literal, so we inline the FastAPI-validated ci_id.
    impacted = s.execute(
        text(
            "SELECT node FROM cypher('cmdb_impact', "
            f"'MATCH (root)-[*1..5]->(down) WHERE id(root) = {ci_id} RETURN down')"
        )
    ).scalars().all()
    ids = list(dict.fromkeys(impacted))  # de-dupe, preserve hop order
    if not ids:
        if not s.get(Ci, ci_id):
            raise HTTPException(404, "no such CI")
        return {"root": ci_id, "impacted_ci_ids": [], "impacted": []}
    # DuckDB has no `= ANY(:array)` (UNNEST), so hydrate over an inlined IN (...) of ints.
    in_list = ",".join(str(int(i)) for i in ids)
    rows = s.execute(
        text(
            "SELECT id, name, ci_type, owner_group FROM cis "
            f"WHERE id IN ({in_list})"
        )
    ).all()
    by_id = {r[0]: {"id": r[0], "name": r[1], "ci_type": r[2], "owner_group": r[3]} for r in rows}
    return {
        "root": ci_id,
        "impacted_ci_ids": ids,
        "impacted": [by_id[i] for i in ids if i in by_id],
    }

Live-verified: GET /cis/1/blast-radius on the seeded estate returns the 9 CIs Step 3 walked — checkout-api, orders-api, accounts-api, reporting-etl and pg-orders-replica at one hop, then payments-gateway, web-storefront and search-index, then cdn-edge — each hydrated with the group that owns it. One failing database, three hops, all four business services, and you know who to call before a customer notices. No flat query gives you that set.

Workflow op 2 — "have we seen this before?" precedent search (VECTOR). POST /incidents/similar takes a query embedding and returns the nearest incidents by pgvector cosine distance over the inline incidents.embedding column — the incidents table is the semantic index, no side table. It's the same KNN as Step 4, now callable from your app so an on-call pastes the new symptoms and gets the precedents:

# routes.py — workflow op 2: "have we seen this before?" precedent search (VECTOR)
@app.post("/incidents/similar")
def similar_incidents(q: SimilarIn, user=Depends(current_user), s: Session = Depends(db)):
    """Nearest incidents to a query embedding — pgvector cosine distance over the inline
    incidents.embedding column (the incidents table IS the semantic index, no side table).
    An on-call pastes the new symptoms; this returns the precedents by meaning."""
    rows = s.execute(
        select(
            Incident.id,
            Incident.number,
            Incident.state,
            Incident.embedding.cosine_distance(q.embedding).label("d"),
        )
        .where(Incident.embedding.is_not(None))
        .order_by("d")
        .limit(q.top_k)
    ).all()
    return {"matches": [
        {"id": i, "number": num, "state": st, "distance": float(dist)}
        for i, num, st, dist in rows
    ]}

Note the .where(Incident.embedding.is_not(None)) — that is the NULL guard from Step 4, and it is not optional: without it a single unembedded ticket fails the whole request. Live-verified against the seeded history, searching from INC1009's symptoms returns INC1003 (0.109), INC1005 (0.137) and INC1004 (0.145) — the three recurring order-database pool exhaustions, all inside a 0.25 cosine band and clearly ahead of the next result at 0.260. Exactly the precedents an on-call wants.

Adding a new 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, graph via cypher(…), vector via cosine_distance(…) (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:change:approve"))."""
    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

The gate audit — four permissions across seven components

ITSM permissions are namespaced <module>:<object>:<verb>, so one customer pool can carry every ERP module's roles without collision — itsm:change:approve is not crm:change:approve. Auditing all seven components 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:rebuildcmdb-blast-radius: POST /graph/assembleTRUNCATEs impacts + service_map and DROP+CREATEs both graphs; every other component's blast radius comes from what it leaves behind

Four of the seven components ended with zero gates, deliberatelyitsm-core (this one), itsm-incident-management, itsm-problem-management and itsm-sla-management. CMDB CRUD, triage and clustering are the service desk's ordinary work. A permission that every agent on the desk must hold is ceremony, and ceremony is exactly what an auditor discounts: if everyone has it, it protects nothing and it tells you nothing about who did what.

That is a real change from what this post used to say. The audit deleted three gatesincidents:triage, problems:write, and cmdb:write on an idempotent per-CI precompute — and added one the roadmap had not predicted, itsm:cmdb:rebuild, because the route it guards is the most destructive operation in the module. The test that survived was not "is this write important?" but "does a human accept a risk here that we need a name against?" Resolving a major incident and approving a production change pass it. Creating a ticket does not.

So in itsm-core, every route takes Depends(current_user) and nothing more. A signed-in user can create CIs, raise incidents, walk a blast radius and search precedents — because that is the job.

POST /sla/tick carries no identity dependency at all — 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.

The pool, created once

The pool is created once for the whole suite, with the role catalog those four gates check — the service desk's org chart expressed as permissions:

You

Create a dodil-appid pool itsm-suite with email+password, and set its role catalog: an agent works tickets and needs no special permission; a change-manager may approve changes; an incident-commander may declare a major incident and resolve it; a cmdb-admin may rebuild the CMDB graphs.

Agent

Pool itsm-suite created — issuer https://appid.dodil.io/ihdiash/itsm-suite, audience pool:itsm-suite, email+password (local) enabled. Catalog set: agent = (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.

agent holds no permissions on purpose. That is not an oversight — it is the shape of a well-audited ITSM: the service desk does the ungated work, which is most of the module, and the four named permissions sit with the handful of people who accept risk on the organization's behalf.

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:change:approve,…), so the gated routes stay gated on a laptop too. Today the pool is email+password (local); oauth/oidc/saml corporate SSO switch on per pool later, no app change. The full flow — creating the pool, the redirect_uris allowlist, what the gateway injects, and the off-gateway path where you do verify the pool JWT yourself — is App authentication; the catalog mechanics are App roles.

Get the code

The package is a real download — code/itsm-core/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 — cis, incidents, problems, changes, services, users, groups, ci_edges
routes.py          # FastAPI    — CRUD + a CI's blast radius (graph) + similar-incident search (vector)
db.py              # the lazy engine + the ON CONFLICT upsert helper every route uses
sa_token.py        # mints + refreshes the service-account token used as the pg-wire password
auth.py            # header-trust role gate — reads what the gateway injected; NO verifier, no JWKS
.env.example       # BUCKET + PG_HOST/PG_PORT + DODIL_TOKEN  (no APPID_* anything — see Auth)
requirements.txt   # sqlalchemy, psycopg[binary], pgvector, fastapi, uvicorn, pydantic, httpx
README.md          # what it is, how to run it
EXTENDING.md       # the pattern for adding a route
PLATFORM.md        # the platform invariants — identical in every DODIL package

Two of those deserve a note. sa_token.py is deliberately lazy — it mints no token at import time, so app.openapi() builds with no credentials at all, which is what lets CI generate an API client without secrets. And PLATFORM.md ships the platform invariants inside the tar, so whoever downloads the code gets the rules along with it rather than having to find them in a repo they can't see. Every line in it is a scar from a real failure on this platform — the opened_at story above is one of them.

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)"
 
# no gateway in front of a laptop, so opt in to a stub identity (never set this in a deployed env):
export DEV_ALLOW_ANON=1
uvicorn routes:app --reload
# POST /cis, /incidents, /ci_edges · GET /cis/{id}/blast-radius · POST /incidents/similar

models.Base.metadata.create_all is the ORM tab's every class at once — the same eight tables Step 1 built by CLI, created from the natural-key models with no migration tool.

How the pillars map

One bucket, one bill, one auth context — the incident record is the CMDB node is the similar-incident index, over one copy of the rows. What this core would otherwise be:

JobThe usual stackOn DataK3
CIs / incidents / problems / changesPostgres (or a SaaS ITSM seat)SQL master tables in the bucket
"If pg-orders-primary fails, what breaks?"Neo4j + a nightly synccmdb graph — graph_khop('cmdb_impact', …) JOINed to cis
"Have we seen this incident before?"Pinecone + an embedding pipelineVECTOR(2048) column + pgvector <=> (cosine KNN)
Cross-pillar reportingNightly ETL → warehouseone JOIN — same rows, read-your-writes, no ETL
Point your own tools at itPer-system drivers & credsdata connect — psql / bolt / pgvector, DB = bucket

No ETL, no second copy, no drift between the ticket and its graph node or its similar-incident index — because it's all one bucket. This is the anchor the rest of the ITSM suite (itsm/cmdb-blast-radius, itsm/incident-management, itsm/problem-management, itsm/change-management, itsm/sla-management) composes onto by consuming these masters.

Customize — the decisions this skill asks you

Q1 · seed_data — demo rows or empty schemas?

"Load the demo CMDB + incident history, or ship empty schemas?"

  • demo (default) → loads 13 CIs / 14 typed edges / 4 business services / 5 groups / 5 users + 13 incidents (6 resolved), so ## Test asserts exact counts, the pg-orders-primary blast radius, and the three recurring order-database precedents.
  • empty → schemas + graph/vector tables only; ## Test switches to structural assertions (tables exist, the graph traverses 0 rows without error, the KNN returns []).

Q2 · topology — build the CMDB graph?

"Build the CMDB dependency graph (ci_edges + CREATE GRAPH cmdb)?" → true (default, recommended) builds the typed ci_edges topology + the cmdb graph (Step 3). false skips them — flat ticketing, no blast radius. The blast-radius traversal is the whole reason a CMDB exists, so keep it on unless you genuinely have no service dependencies to model.

Q3 · similar_incidents — embed every incident?

"Embed every incident for similar-incident search (jina-embeddings-v4)?"

  • true (default) → populates incidents.embedding (2048-dim) so "have we seen this before?" is a cosine KNN over the same rows (Step 4).
  • falseincidents.embedding stays null — SQL-only tickets, no dedup, no precedent retrieval.

TIP

Industry overlays. This core is the cross-industry base (industry: software). Overlays add a small additive diff — finserv adds cis.compliance_scope/regulated + a change_audit trail; manufacturing adds cis.ci_class (IT|OT) + plant CIs — see the per-industry ITSM pages.

Test

Every command below ran live against DataK3 on 2026-09-08, on the shared itsm bucket — the same bucket all seven ITSM components run on, not a private one. That is the point: these assertions hold with six other components writing to the same rows. Real results are inline. The default branch is {seed_data: demo, topology: true, similar_incidents: true}. (The empty branch swaps assertions 2–5 for: tables exist, the graph traverses 0 rows without error, the KNN returns [].)

# 1) the 8 master + CMDB tables exist (the bucket holds 23 in total — the other 15 belong to
#    the six workflow components that compose onto these masters)
dodil data table list -b "$BUCKET"
#  cis, incidents, problems, changes, services, users, groups, ci_edges  (+ 15 sidecars)
 
# 2) the seeded estate lands — 13 CIs, 14 typed edges across 3 rel kinds, 4 services, 5 groups, 5 users
dodil data sql -b "$BUCKET" "SELECT count(*) AS cis, (SELECT count(*) FROM ci_edges) AS edges,
  (SELECT count(DISTINCT rel) FROM ci_edges) AS rel_kinds, (SELECT count(*) FROM services) AS services,
  (SELECT count(*) FROM groups) AS groups, (SELECT count(*) FROM users) AS users FROM cis"
#  cis 13 | edges 14 | rel_kinds 3 | services 4 | groups 5 | users 5
 
# 3) 13 incidents, 6 resolved, 11 carrying an embedding (a ticket raised through the CRUD route
#    has no vector until something backfills it — which is why every KNN filters IS NOT NULL)
dodil data sql -b "$BUCKET" "SELECT count(*) AS n, count(embedding) AS embedded,
  count(*) FILTER (WHERE state='resolved') AS resolved FROM incidents"
#  n 13 | embedded 11 | resolved 6
 
# 4) the graph resolves checkout-api's direct dependencies (forward walk = what it relies on)
dodil data pg -b "$BUCKET" \
  "SELECT n.name FROM graph_neighbors('cmdb', 3) g JOIN cis n ON n.id = g.neighbor ORDER BY g.neighbor"
#  pg-orders-primary, redis-session, host-app-01
 
# 5) typed blast radius of pg-orders-primary (CI 1) — 9 CIs, max hop 3, all 4 business services
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.id"
#  1 checkout-api · 1 orders-api · 1 accounts-api · 1 reporting-etl · 1 pg-orders-replica
#  2 payments-gateway · 2 web-storefront · 2 search-index · 3 cdn-edge
#  (untyped — flipping all 14 edges instead of the 13 impact-propagating ones — returns 10:
#   mobile-app-bff joins over a part_of edge it should not propagate through)
 
# 6) precedent search: INC1009's three nearest RESOLVED incidents are the recurring order-DB
#    pool exhaustions, all g-dba, all inside 0.25 — and the next result is outside it
dodil data pg -b "$BUCKET" "SELECT b.number, b.assignment_group,
    ROUND((a.embedding <=> b.embedding)::numeric, 3) AS d
  FROM incidents a, incidents b WHERE a.id = 1009 AND b.id <> 1009
    AND b.state = 'resolved' AND b.embedding IS NOT NULL ORDER BY d LIMIT 4"
#  INC1003 g-dba 0.109 · INC1005 g-dba 0.137 · INC1004 g-dba 0.145 · INC1001 g-platform 0.260
 
# 7) drop-in clients: same bucket, your own psql / cypher-shell / pgvector driver
dodil data connect "$BUCKET"            # pg / bolt / grpc endpoints
#  pg   postgresql://token:…@pg.uk-lon-1.dodil.io:5432/itsm
#  bolt bolt+s://bolt.uk-lon-1.dodil.io:7687   ·   grpc table-rpc.uk-lon-1.dodil.io:443

One-shot

With the DODIL MCP connected, paste this to scaffold the whole master-data core at once:

Scaffold the ITSM master-data core + CMDB on DataK3 (one bucket = SQL + graph + vector). Confirm each step.
 
1. Create a DataK3 bucket `itsm`, then eight merge-keyed tables (every non-key column nullable):
   - cis (key id): name, ci_type, environment, owner_group, service_id(string), business_criticality(int), status.
   - incidents (key id): number, short_description, description, ci_id(long), service_id(string), state, priority,
     impact, urgency, category, subcategory, assignment_group, assigned_to, problem_id(long),
     opened_at(timestamp), resolved_at(timestamp), resolution, embedding VECTOR(2048).
   - problems (key id): number, short_description, description, root_cause, state, known_error(boolean),
     workaround, related_change_id(long), created_at.
   - changes (key id): number, ci_id(long), short_description, description, type, state, risk, impact,
     requested_by, assignment_group, approval_state, planned_start, planned_end, actual_start, actual_end,
     problem_id(long).
   - services (key service_id): name, business_service, owner_group, tier(int), sla_id.
   - users (key user_id): name, email, group_id, role.
   - groups (key group_id): name, manager, email, on_call(boolean).
   - ci_edges (key src,dst,rel): src(long), dst(long), rel, weight(double).
2. Seed the demo estate: 13 CIs (pg-orders-primary, pg-orders-replica, redis-session as databases;
   checkout-api, orders-api, payments-gateway, web-storefront, accounts-api, reporting-etl, mobile-app-bff,
   search-index, cdn-edge as apps; host-app-01 as a host); 4 services mapping to the business services
   Online Checkout / Order Management / Customer Accounts / Internal Reporting; 5 groups (each with the
   manager the SLA clock escalates to) and 5 users; 13 incidents, 6 resolved. Make INC1003/1004/1005 the
   SAME recurring order-database connection-pool exhaustion, all resolved by g-dba — that repetition is the
   precedent set the KNN finds. EVERY incident row carries an opened_at (the SLA clock's t0; a NULL there
   breaks the clock for the whole estate). Embed each incident's symptoms with jina-embeddings-v4 and store
   the [ … ] literal in embedding; one row per call.
3. Populate ci_edges with typed rels — depends_on for real dependencies, runs_on for service->host, and one
   part_of (mobile-app-bff -> web-storefront) — THEN CREATE GRAPH cmdb over cis (KEY id) + ci_edges.
   graph_neighbors('cmdb', 3) -> pg-orders-primary + redis-session + host-app-01.
4. Blast radius = build impacts(src,dst) = flip of ci_edges WHERE rel IN (depends_on, runs_on) — the part_of
   edge is EXCLUDED, composition is not causation — CREATE GRAPH cmdb_impact, then
   graph_khop('cmdb_impact', 1, 5) JOINed to cis -> 9 CIs, max hop 3, rolling up to all 4 business services
   (Online Checkout 4, Order Management 3, Customer Accounts 1, Internal Reporting 1). Flipping all 14 edges
   instead returns 10 and is wrong.
5. Similar-incident search: pgvector `embedding <=> '<qvec>'` over state='resolved' AND embedding IS NOT NULL
   -> from INC1009, the nearest are INC1003 (0.109), INC1005 (0.137), INC1004 (0.145), all g-dba, all inside
   0.25; the next result sits at 0.260. The IS NOT NULL filter is required, not defensive.

Ship it

The core is declarative — tables, a typed graph, and inline embeddings — so there's nothing to deploy: the bucket is the running system, queryable over pg / bolt / grpc the moment the rows land (data connect). The stateful pieces are the workflow engines — the incident triage engine, the CAB change advisor, the problem clusterer, the SLA monitor, the CMDB blast-radius engine — each an image-mode Ignite app (an HTTP server behind a Dockerfile, Kaniko build-on-deploy via --dockerfile-path, GET /healthz + a POST route, not --runtime python). A deployed handler that calls Models runs under a service account granted k3.editor + ignite.model-user + ignite.app-developer; a pure-SQL engine (the SLA monitor) needs only k3.editor + ignite.app-developer. DODIL_SERVICE_ACCOUNT_ID is the cli-… serviceAccountId that auth service-account create prints (not the internal uuid). Those engines are the sibling itsm/* tutorials; this core is the data they read.

One thing a buyer will ask about, so state it before they do: there is no scheduler. Ignite is request-invoked — an app runs when something calls it, and scales to zero when nothing does. That is ideal for the routes in this post, which are all request-shaped. It is not ideal for the SLA clock, and an SLA clock that only advances when somebody happens to load a page is not an SLA clock. There are two real answers and a deployment must pick one and say which: (a) an always-on pinned app--reserved 1 --max-replicas 1, with its own loop in the pod calling the recompute on an interval: self-contained, never scales to zero, costs a warm replica; or (b) an external scheduler — cron, a CI timer, any orchestrator POSTing the recompute route: the app stays scale-to-zero, but the clock now depends on infrastructure outside DODIL. Either way the caller is a service account over a platform invoke, not a browser user — which is precisely why that route must not sit behind current_user. The full treatment is in SLA management.

The suite — seven components, one app

This package runs standalone (uvicorn routes:app) — that is what this post walks through, and the code/itsm-core 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 covering all 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. The single canonical models.py is also what makes the naming rule above enforceable: there is exactly one definition of incidents, so a service_id cannot be a string in one component and an integer in another.

Connect your tools

Everything this build wrote 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), any BI tool.
  • Graph over Bolt — a Neo4j driver or cypher-shell against the cmdb graph.
  • Vector — pgvector (<=>) over the same wire against incidents.embedding.

Full, live-validated walkthrough: Connect your tools.

Conclusion

You now have the master-data core of an ITSM on one DataK3 bucket: eight merge-keyed tables you upsert idempotently, a typed CMDB graph that turns a failing pg-orders-primary into its 9-CI blast radius across all four business services in one hop-ranked query, and an inline VECTOR(2048) column that finds the precedents for a new incident by meaning — no Postgres + Pinecone + Neo4j + warehouse + the ETL between them. One bucket, one bill, three pillars over one copy of the rows. This is the system of record the rest of the ITSM suite reads.

Three of the details above are scars rather than design, and they are the ones most worth carrying into your own build: the ORM attribute name is the column name, because two idioms in this codebase read the column list off the model; a vector column cannot be read back through the ORM without the tolerant Vector subclass, which is platform-wide and not an ITSM quirk; and opened_at is never optional in the row, because a producer's optional field was a consumer's mandatory one and it took service-level monitoring down for the whole estate. All three were invisible until seven components shared one bucket. That is the argument for validating a suite as a system: components validated separately are internally consistent and still wrong together.

Next steps:

  • Compose the workflow skills onto this core: cmdb-blast-radius (the reverse impact graph + impacted-service rollup), incident-management (dedup → triage → assign), problem-management (cluster → root-cause → known-error), change-management (risk = blast radius → CAB gate), sla-management (the pure-SQL clock).
  • See the proven end-to-end anchor: a ServiceNow-style ITSM on DataK3 — the same one-bucket, three-pillar pattern with an auto-triage engine.