What you'll build: a ServiceNow-style ITSM on one DataK3 bucket — the incident/change/problem
tables (SQL), the CMDB dependency graph that turns a failing database into its full blast radius
in one hop-ranked query, a similar-incident vector search ("have we seen this before?"), and an
Ignite auto-triage engine that fuses all three and writes category + priority +
assignment_group back to the incident — with a model as the classification gate.
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 / semantic search 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 orders-db 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.
What collapses onto one bucket: the incidents/changes/problems tables, the CMDB graph, and the KB +
similar-incident vectors are the same rows under three query pillars — SQL, Graph (Cypher/Bolt +
graph_*()), and Vector (pgvector). No ETL, no second copy, no nightly sync, one auth context. The
payoff for the team is MTTR and deflection: the on-call opens a new incident and instantly sees
the fleet-wide blast radius, the three past incidents that look identical (and how they were fixed), and
a model-graded P1/database verdict routed to the right group — instead of hand-triaging a queue.
What you'll build: a one-bucket blog-itsm system — the transactional tables (SQL), the CMDB
dependency graph (Graph), similar-incident search (Vector), an Ignite triage-engine app, and a
kimi-k2.6 classification gate — queried by content, by meaning, and by relationship over one copy of
the rows.
Prerequisites
- The
dodilCLI (dodil auth login) and an agent connected over MCP (Claude Code, Cursor, VS Code, Codex). Every step shows an Ask your agent tab and a CLI tab. export BUCKET=blog-itsm— one bucket is the whole system's data plane.
Step 1 — Stand up the incident core (SQL)
Create the bucket, then the merge-keyed transactional tables. A PRIMARY KEY (merge-key) is
required — writes are keyed, so re-runs and shard retries upsert idempotently, exactly what you want
for a ticket system that a dozen workers touch at once.
Create a DataK3 bucket called blog-itsm, then merge-keyed tables: cis (configuration items), incidents, changes, problems.
data_bucket_create→data_table_createCreated bucket blog-itsm (status ACTIVE). Tables cis, incidents, changes, problems created, each with PRIMARY KEY (id).
dodil data bucket create "$BUCKET" --description "ITSM system of record — incidents, CMDB, KB"
# Configuration items = the CMDB nodes (apps, services, hosts, databases)
dodil data table create cis -b "$BUCKET" --merge-key id \
--columns-json '[{"name":"id","type":"bigint"},{"name":"name","type":"varchar"},{"name":"ci_type","type":"varchar"},{"name":"environment","type":"varchar"},{"name":"owner_group","type":"varchar"},{"name":"status","type":"varchar"}]'
# Incidents — the lifecycle core. embedding = VECTOR(2048) for similar-incident search (Step 3)
dodil data table create incidents -b "$BUCKET" --merge-key id \
--columns-json '[{"name":"id","type":"bigint"},{"name":"short_description","type":"varchar"},{"name":"description","type":"varchar"},{"name":"ci_id","type":"bigint"},{"name":"state","type":"varchar"},{"name":"priority","type":"varchar"},{"name":"category","type":"varchar"},{"name":"assignment_group","type":"varchar"},{"name":"opened_at","type":"timestamp"},{"name":"resolved_at","type":"timestamp"},{"name":"resolution","type":"varchar"},{"name":"embedding","type":"VECTOR(2048)"}]'
dodil data table create changes -b "$BUCKET" --merge-key id \
--columns-json '[{"name":"id","type":"bigint"},{"name":"ci_id","type":"bigint"},{"name":"short_description","type":"varchar"},{"name":"state","type":"varchar"},{"name":"risk","type":"varchar"},{"name":"requested_by","type":"varchar"},{"name":"planned_start","type":"timestamp"}]'
dodil data table create problems -b "$BUCKET" --merge-key id \
--columns-json '[{"name":"id","type":"bigint"},{"name":"short_description","type":"varchar"},{"name":"root_cause","type":"varchar"},{"name":"state","type":"varchar"},{"name":"related_incident_id","type":"bigint"}]'Load the CMDB inventory — ten CIs spanning the four tiers app → service → host → database. These
rows are the graph's nodes in Step 2 and the ci_id foreign key every incident points at.
Upsert 10 configuration items into cis: web-storefront (app), checkout-service/orders-api/payments-gateway/auth-service/redis-cache (services), app-host-01/02 (hosts), orders-db/payments-db (databases).
data_table_upsertUpserted 10 CIs (wal_written: true).
dodil data table upsert cis -b "$BUCKET" \
--row '{"id":1,"name":"web-storefront","ci_type":"app","environment":"prod","owner_group":"retail-web","status":"operational"}' \
--row '{"id":2,"name":"checkout-service","ci_type":"service","environment":"prod","owner_group":"payments-eng","status":"operational"}' \
--row '{"id":3,"name":"orders-api","ci_type":"service","environment":"prod","owner_group":"orders-eng","status":"operational"}' \
--row '{"id":4,"name":"payments-gateway","ci_type":"service","environment":"prod","owner_group":"payments-eng","status":"operational"}' \
--row '{"id":5,"name":"auth-service","ci_type":"service","environment":"prod","owner_group":"identity","status":"operational"}' \
--row '{"id":6,"name":"app-host-01","ci_type":"host","environment":"prod","owner_group":"platform","status":"operational"}' \
--row '{"id":7,"name":"app-host-02","ci_type":"host","environment":"prod","owner_group":"platform","status":"operational"}' \
--row '{"id":8,"name":"orders-db","ci_type":"database","environment":"prod","owner_group":"dba","status":"operational"}' \
--row '{"id":9,"name":"payments-db","ci_type":"database","environment":"prod","owner_group":"dba","status":"operational"}' \
--row '{"id":10,"name":"redis-cache","ci_type":"service","environment":"prod","owner_group":"platform","status":"operational"}'Now the incidents themselves — five resolved history rows (with their fixes) plus one fresh new
incident. Each carries a VECTOR(2048) embedding of its description; you produce that with the
jina-embeddings-v4 model and write the [f1,f2,…] literal. Because embeddings are large, upsert
one row per call (a batched frame carrying six 2048-dim vectors exceeds the gateway's first-frame
size).
For each incident, embed its description with jina-embeddings-v4, then upsert the row (id, short_description, description, ci_id, state, priority, category, assignment_group, opened_at, resolved_at, resolution, embedding). Include all columns — a full-row upsert that omits a declared column will not persist.
ignite_models_embed→data_table_upsertUpserted 6 incidents (1001–1005 resolved, 1006 new); each embedding is a 2048-dim vector literal.
# 1) embed the description → a pgvector literal
EMB=$(dodil ignite models embed jina-embeddings-v4 \
--input "orders-db connections maxed out, orders-api throwing connection timeout errors under peak checkout load" \
--output json | jq -c '.data.data[0].embedding')
# 2) upsert the incident, one row per call (large vectors → keep frames small)
dodil data table upsert incidents -b "$BUCKET" \
--row "{\"id\":1006,\"short_description\":\"orders-db connections maxed out, orders-api timing out\",\"description\":\"orders-db connections maxed out, orders-api throwing connection timeout errors under peak checkout load\",\"ci_id\":8,\"state\":\"new\",\"priority\":\"\",\"category\":\"\",\"assignment_group\":\"\",\"opened_at\":\"2026-08-31 20:15:00\",\"resolved_at\":\"\",\"resolution\":\"\",\"embedding\":$EMB}"
# …repeat for the 5 resolved history rows (1001–1005), each with resolution + assignment_group set.Step 2 — The CMDB dependency graph (blast radius)
This is the pillar an ITSM lives or dies on. A graph is table-backed: the cis table is the node
set (KEY id), a depends_on edge table holds src → dst (a CI depends on another CI), and
CREATE GRAPH binds them. data pg owns the DDL.
In blog-itsm, create edge table depends_on(src,dst), insert the app→service→host→db dependencies, and CREATE GRAPH cmdb over node table cis (KEY id) and edges depends_on (SRC src DST dst).
data_pgGraph cmdb created over cis / depends_on (10 nodes, 10 edges).
dodil data pg -b "$BUCKET" "CREATE TABLE depends_on (src BIGINT, dst BIGINT, PRIMARY KEY (src,dst))"
# app→service→host→db: what each CI depends on
dodil data pg -b "$BUCKET" "INSERT INTO depends_on VALUES (1,2),(1,5),(2,3),(2,4),(2,10),(3,8),(3,6),(4,9),(4,7),(5,10)"
dodil data pg -b "$BUCKET" "CREATE GRAPH cmdb NODES (cis KEY id) EDGES (depends_on SRC src DST dst)"graph_neighbors('cmdb', 1) walks the edges forward — the direct dependencies of a CI. From
web-storefront (1) that's auth-service (5) and checkout-service (2):
What does web-storefront (CI 1) directly depend on? Use graph_neighbors on cmdb.
data_pgneighbor: 5 (auth-service), 2 (checkout-service).
dodil data pg -b "$BUCKET" "SELECT n.name FROM graph_neighbors('cmdb', 1) g JOIN cis n ON n.id = g.neighbor"But blast radius is the reverse question — if this CI fails, who is impacted? — i.e. every CI that
transitively depends on the failing one. Build a reverse impacts edge table (just the
depends_on rows flipped) and a cmdb_impact graph over it; now graph_khop(...) from the failing CI
returns the whole impacted set with hop distance, and you hydrate names by joining cis in the
same statement.
Build the reverse impact graph: create impacts(src,dst) as the flip of depends_on, then CREATE GRAPH cmdb_impact over cis / impacts. Then give me the full blast radius of orders-db (CI 8), hop-ranked and hydrated with CI names.
data_pgBlast radius of orders-db → hop 1: orders-api, hop 2: checkout-service, hop 3: web-storefront.
dodil data pg -b "$BUCKET" "CREATE TABLE impacts (src BIGINT, dst BIGINT, PRIMARY KEY (src,dst))"
dodil data pg -b "$BUCKET" "INSERT INTO impacts SELECT dst AS src, src AS dst FROM depends_on"
dodil data pg -b "$BUCKET" "CREATE GRAPH cmdb_impact NODES (cis KEY id) EDGES (impacts SRC src DST dst)"
# THE flagship query: blast radius of a failing CI, hop-ranked, names hydrated in one statement
dodil data pg -b "$BUCKET" \
"SELECT k.hop_distance, c.name, c.ci_type, c.owner_group
FROM graph_khop('cmdb_impact', 8, 5) k
JOIN cis c ON c.id = k.node
ORDER BY k.hop_distance"Real output — a failing orders-db takes down three tiers, and you know it before a customer does:
hop_distance name ci_type owner_group
1 orders-api service orders-eng
2 checkout-service service payments-eng
3 web-storefront app retail-web
The same traversal in Cypher over Bolt — return the node variable (the graph plane hands back node
keys; join cis for properties):
Same blast radius in Cypher over Bolt: from orders-db, follow impacts up to 5 hops and return the impacted nodes.
data_boltReturns nodes 3, 2, 1 with hop_distance 1, 2, 3.
dodil data bolt -b "$BUCKET" -g cmdb_impact \
"MATCH (f)-[:impacts*1..5]->(a) WHERE id(f)=8 RETURN a"Step 3 — Similar-incident search (Vector)
The incidents.embedding column you populated in Step 1 is the semantic index — no Pinecone, no
second copy. Ask the fleet-tested question "have we seen this before?": embed the new incident's
symptoms and KNN-search the resolved history. data vsearch --text embeds the query client-side (pass
the same model the column was built with) and returns the nearest rows by cosine distance.
In blog-itsm, find the incidents most similar to 'orders-db connections maxed out, connection timeouts under load' — vector search the incidents.embedding column with jina-embeddings-v4, cosine, top 3.
data_vsearchNearest: 1006 (self, 0.078), 1001 (0.149, orders-db pool exhausted), 1005 (0.242, orders-db disk full).
dodil data vsearch -b "$BUCKET" -t incidents --column embedding \
--text "orders-db connections maxed out, connection timeouts under load" \
--model jina-embeddings-v4 --metric cosine --top-k 3The two nearest resolved incidents are the other two orders-db outages — exactly the precedents an
on-call wants. The same KNN in SQL, which is what the engine runs so it can filter to state='resolved'
and pull the historic fix and owning team in one shot (pgvector's <=> with a query-vector literal):
Show the 3 nearest RESOLVED incidents to the new incident's embedding, with their assignment_group and resolution — SQL pgvector KNN.
data_sql1001 (orders-eng, added PgBouncer), 1005 (dba, extended volume), 1003 (payments-eng).
# $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'
ORDER BY embedding <=> '$QVEC' LIMIT 3"The
<=>operator needs a vector literal on the right-hand side — embed the query text and interpolate the[…]string. (A correlated sub-SELECTof another row's vector does not drive the KNN; pass the literal.)
Step 4 — The auto-triage engine (Ignite + a real handler)
An Ignite app is a separate workload — it needs its own service account to call DataK3 and Models
(client-credentials → bearer). Grant least-privilege roles, inject the creds as runtime env, deploy,
then invoke. The handler fuses all three pillars over one Postgres-wire connection to the bucket
(SQL + pgvector <=> + graph_khop() are all just SQL there) and writes the verdict back.
Create a service account triage-engine-sa, grant it ignite.model-user (invoke models) and k3.editor (read/write the bucket), deploy ./triage-engine as a python app with the SA creds as runtime env, then invoke it for incident 1006.
auth_service-account_create→auth_service-account_grant-role→ignite_app_deploy→ignite_invokeDeployed triage-engine (deployment_state: deployed); invoke returned category=database, priority=P1, assignment_group=orders-eng.
dodil auth service-account create triage-engine-sa # → prints $SA_ID / $SA_SECRET
dodil auth service-account grant-role triage-engine-sa ignite-authorization-service ignite.model-user
dodil auth service-account grant-role triage-engine-sa k3-authorization-service k3.editor
dodil ignite app deploy triage-engine --code ./triage-engine --runtime python \
--env DODIL_BUCKET="$BUCKET" \
--env DODIL_SERVICE_ACCOUNT_ID="$SA_ID" \
--env DODIL_SERVICE_ACCOUNT_SECRET="$SA_SECRET"
dodil ignite invoke triage-engine --payload '{"incident_id":1006}'The handler — real, runnable code. ./triage-engine/handler.py:
TOKEN_URL = "https://id.dodil.io/realms/dodil/protocol/openid-connect/token"
MODELS_BASE = "https://api.dodil.io/v1" # OpenAI-compatible
PG_HOST, PG_PORT = "pg.uk-lon-1.dodil.io", 5432
BUCKET = os.environ.get("DODIL_BUCKET", "blog-itsm")
EMBED_MODEL = "jina-embeddings-v4" # matches the VECTOR(2048) column
CHAT_MODEL = "kimi-k2.6"
def _token():
r = requests.post(TOKEN_URL, timeout=30, data={
"grant_type": "client_credentials",
"client_id": os.environ["DODIL_SERVICE_ACCOUNT_ID"],
"client_secret": os.environ["DODIL_SERVICE_ACCOUNT_SECRET"]})
r.raise_for_status()
return r.json()["access_token"]
def _embed(tok, text):
r = requests.post(f"{MODELS_BASE}/embeddings", timeout=120,
headers={"Authorization": f"Bearer {tok}"},
json={"model": EMBED_MODEL, "input": text})
r.raise_for_status()
vec = r.json()["data"][0]["embedding"]
return "[" + ",".join(repr(round(x, 6)) for x in vec) + "]" # pgvector literal
def _classify(tok, desc, ci_name, blast):
system = ('You are an ITSM triage assistant. Reply with ONLY compact JSON: '
'{"category": one of [database, network, performance, availability, '
'security], "priority": one of [P1, P2, P3]}. No prose.')
user = f'Incident: "{desc}". Affected CI: {ci_name}. Blast radius: {", ".join(blast)}.'
r = requests.post(f"{MODELS_BASE}/chat/completions", timeout=120,
headers={"Authorization": f"Bearer {tok}"},
json={"model": CHAT_MODEL, "messages": [
{"role": "system", "content": system},
{"role": "user", "content": user}]})
r.raise_for_status()
c = r.json()["choices"][0]["message"]["content"].strip().strip("`")
if c.startswith("json"): c = c[4:].strip()
return json.loads(c)
def handler(payload, ctx):
tok = _token()
inc_id = int(payload["incident_id"])
conn = psycopg.connect(host=PG_HOST, port=PG_PORT, dbname=BUCKET,
user="token", password=tok, sslmode="require")
with conn, conn.cursor() as cur:
cur.execute("""SELECT i.description, i.ci_id, c.name
FROM incidents i JOIN cis c ON c.id = i.ci_id
WHERE i.id = %s""", (inc_id,))
description, ci_id, ci_name = cur.fetchone()
# 1. VECTOR — nearest resolved incidents
qvec = _embed(tok, description)
cur.execute(f"""SELECT id, assignment_group, resolution FROM incidents
WHERE state='resolved' ORDER BY embedding <=> '{qvec}' LIMIT 3""")
neighbors = cur.fetchall()
# 2. GRAPH — blast radius of the affected CI
cur.execute("""SELECT c.name FROM graph_khop('cmdb_impact', %s, 5) k
JOIN cis c ON c.id = k.node ORDER BY k.hop_distance""", (ci_id,))
blast = [r[0] for r in cur.fetchall()]
# 3. MODEL — classify; route to the team that fixed the nearest precedent
verdict = _classify(tok, description, ci_name, blast)
assignment_group = neighbors[0][1] if neighbors else None
# 4. WRITE BACK — same bucket, same connection
cur.execute("""UPDATE incidents SET state='triaged', category=%s,
priority=%s, assignment_group=%s WHERE id=%s""",
(verdict["category"], verdict["priority"], assignment_group, inc_id))
return {"incident_id": inc_id, **verdict,
"assignment_group": assignment_group,
"similar_incidents": [n[0] for n in neighbors], "blast_radius": blast}requirements.txt is just requests and psycopg[binary]. Note the one connection, three pillars:
the same psycopg cursor runs the pgvector KNN, the graph_khop() traversal, and the write-back — no
second driver, no second store.
Step 5 — The classification gate (Models)
The gate is why this is cheap at fleet scale: a low-cost model reads the free-text symptom and returns a
structured category + priority, so the queue self-sorts and analysts spend their hours on the P1s.
Here is the real call the handler makes, and the real reply:
Classify this incident with kimi-k2.6 and return only JSON {category, priority, assignment_group, reason}: 'orders-db connections maxed out, orders-api throwing connection timeout errors under peak checkout load' — affected CI orders-db, blast radius orders-api/checkout-service/web-storefront.
ignite_models_chat{"category":"database","priority":"P1","assignment_group":"DBA","reason":"Prod DB connection pool exhausted under peak load, causing checkout timeouts"}
dodil ignite models chat kimi-k2.6 \
--system 'You are an ITSM triage assistant. Reply with ONLY compact JSON: {"category": one of [database, network, performance, availability, security], "priority": one of [P1, P2, P3], "assignment_group": short}. No prose.' \
--message 'Incident: "orders-db connections maxed out, orders-api throwing connection timeout errors under peak checkout load". Affected CI: orders-db. Blast radius: orders-api, checkout-service, web-storefront.'The verdict lands back in the incident's SQL columns — category='database', priority='P1', and
assignment_group='orders-eng' (the team that resolved the nearest past incident, INC1001), with
state advanced new → triaged:
Write the triage verdict back to incident 1006: state=triaged, category=database, priority=P1, assignment_group=orders-eng. Then show the row.
data_table_update→data_sqlrows_affected: 1. Row 1006 → triaged / database / P1 / orders-eng.
dodil data table update incidents -b "$BUCKET" --predicate "id=1006" \
--updates-json '{"state":"triaged","category":"database","priority":"P1","assignment_group":"orders-eng"}'
dodil data sql -b "$BUCKET" "SELECT id, state, category, priority, assignment_group FROM incidents WHERE id=1006"Query it — one bucket, three pillars, drop-in clients
The same rows answer by content (SQL), by meaning (vector), and by relationship (graph) — no ETL, no
second copy. Join an incident to its affected CI, and the lifecycle report is a plain GROUP BY:
Join incidents to their affected CI (name + type), and give me the incident count by lifecycle state.
data_sql6 incidents (5 resolved, 1 triaged); each joined to its affected CI.
dodil data sql -b "$BUCKET" \
"SELECT i.id, i.short_description, c.name AS affected_ci, c.ci_type, i.state, i.priority
FROM incidents i JOIN cis c ON c.id = i.ci_id ORDER BY i.id"
dodil data sql -b "$BUCKET" "SELECT state, count(*) AS n FROM incidents GROUP BY state ORDER BY n DESC"And any Postgres/pgvector or Bolt/Neo4j client points straight at the bucket — data connect prints the
endpoints (DB name = bucket, credential = your login token). Point psql, cypher-shell, Grafana, or a
BI tool at these with zero export:
Print the drop-in pg / bolt / grpc endpoints for blog-itsm so I can point psql and cypher-shell at it.
data_connectpg postgresql://token:…@pg.uk-lon-1.dodil.io:5432/blog-itsm · bolt bolt+s://bolt.uk-lon-1.dodil.io:7687 (database=blog-itsm) · grpc table-rpc.uk-lon-1.dodil.io:443
dodil data connect "$BUCKET" # pg / bolt / grpc endpoints
dodil data connect "$BUCKET" -o psql # a ready-to-paste postgresql://… URLDataK3 vs. the multi-system ITSM stack
| ITSM concern | Classic stack | On DataK3 |
|---|---|---|
| Incident / change / problem tables | Postgres (ticket DB) | SQL pillar — incidents, changes, problems (merge-keyed) |
| CMDB dependency graph + blast radius | Neo4j | Graph pillar — graph_khop('cmdb_impact', …), Cypher over Bolt |
| KB / similar-incident search | Pinecone / Elasticsearch | Vector pillar — VECTOR(2048) column, pgvector <=> |
| Cross-pillar reporting | Nightly ETL → warehouse | One JOIN — same rows, read-your-writes, no ETL |
| Automation runtime | Workflow engine + connectors | Ignite handler.py, one bucket, one auth context |
One bucket, one bill, one auth context — and the blast-radius traversal and the tickets it correlates are the same live rows, not last night's snapshot in a separate graph DB.
Test
Run against live DataK3, then the assertions below hold. What ran live for this post
(tested_at: 2026-09-01, bucket blog-itsm, org IHDIASH): the bucket + all four SQL tables; both
CMDB graphs; all 6 incident embeddings (jina-embeddings-v4, dim 2048); the graph_neighbors /
graph_khop / Bolt traversals; the vsearch and pgvector <=> KNN; the kimi-k2.6 classification
call; and the data table update write-back. The handler.py is real and byte-for-byte the triage
pipeline that was executed live query-by-query; the ignite app deploy / invoke wrapper is shown as
code.
# CMDB blast radius of orders-db — expect 3 rows: orders-api(1), checkout-service(2), web-storefront(3)
dodil data pg -b "$BUCKET" \
"SELECT k.hop_distance, c.name FROM graph_khop('cmdb_impact', 8, 5) k
JOIN cis c ON c.id = k.node ORDER BY k.hop_distance"
# Similar-incident search — expect the two other orders-db incidents (1001, 1005) as nearest resolved
dodil data vsearch -b "$BUCKET" -t incidents --column embedding \
--text "orders-db connections maxed out, connection timeouts under load" \
--model jina-embeddings-v4 --metric cosine --top-k 3
# The triaged incident — expect: triaged | database | P1 | orders-eng
dodil data sql -b "$BUCKET" \
"SELECT state, category, priority, assignment_group FROM incidents WHERE id=1006"
# Lifecycle rollup — expect resolved=5, triaged=1
dodil data sql -b "$BUCKET" "SELECT state, count(*) AS n FROM incidents GROUP BY state"One-shot
Build a ServiceNow-style ITSM on one DataK3 bucket named blog-itsm.
1. Create the bucket and merge-keyed tables: cis(id,name,ci_type,environment,owner_group,status);
incidents(id,short_description,description,ci_id,state,priority,category,assignment_group,
opened_at,resolved_at,resolution,embedding VECTOR(2048)); changes(...); problems(...).
2. Upsert 10 CIs across app→service→host→db, and 6 incidents (5 resolved with resolutions, 1 new).
Embed each incident description with jina-embeddings-v4 and store the [ … ] literal in embedding;
upsert one row per call (large vectors).
3. Build the CMDB graph: depends_on(src,dst) edges (app→service→host→db) + CREATE GRAPH cmdb over cis;
then the reverse impacts(src,dst) + CREATE GRAPH cmdb_impact. Blast radius of a failing CI =
graph_khop('cmdb_impact', <ci_id>, 5) joined to cis, hop-ranked.
4. Similar-incident search: vsearch / pgvector `embedding <=> '<qvec>'` over state='resolved'.
5. Deploy an Ignite triage-engine (own service account: ignite.model-user + k3.editor) whose handler.py
embeds the new incident, KNN-searches resolved history, computes the blast radius, classifies with
kimi-k2.6, and writes state=triaged + category + priority + assignment_group back to the incident.
6. Verify: blast radius of orders-db = orders-api/checkout-service/web-storefront; incident 1006 →
triaged/database/P1/orders-eng.Ship it — make triage-engine a public endpoint
The triage-engine app above is shown as code. Ship it the last mile so users hit a real URL, not a
snippet — the managed runtime compiles your handler.py and returns a public FQDN:
Deploy the triage-engine app publicly with no auth and give me its URL.
ignite_app_deploy→ignite_app_getDeployed triage-engine (deployment_state: deployed). Its public FQDN on ignite.dodil.cloud is callable with no token — or call it with dodil ignite invoke triage-engine.
dodil ignite app deploy triage-engine --code ./triage-engine --runtime python --allow-unauthenticated
dodil ignite app get triage-engine --output json # → public_urlsThat's the quick path (managed compile). The full supply chain — DODIL git → CI checks → a scanned image in the DODIL registry → versioning and one-command rollback — is its own tutorial: Ship a DODIL App.
Conclusion
You now have a one-bucket ITSM — the incident/change/problem SQL core, the CMDB dependency graph that
turns a failing orders-db into its blast radius in one hop-ranked query, similar-incident vector
search over the same rows, and an Ignite engine that auto-triages a new incident and writes the verdict
back — with a model as the classification gate. No Postgres + Pinecone + Neo4j + warehouse + the ETL
between them: one copy of the rows, three query pillars, one bill.
The reusable skill: model a relationship-heavy enterprise system as one DataK3 bucket and traverse blast-radius over the graph pillar while the SQL and vector pillars share the same rows. See also the sibling builds a cost-gated CRM lead pipeline and an AI-powered SIEM for threat hunting — same one-bucket, three-pillar pattern, different domain.