What you'll build: ITSM change management on one DataK3 bucket, where the number that decides
a change — its risk — is not a form field a requester self-selects but a graph traversal: the
CMDB blast radius of the CI the change touches. A patch to pg-orders-primary is high risk because the
impact graph says a bad restart takes down ten CIs across four business services — checkout, orders,
accounts and reporting — three hops out to a mobile BFF nobody in the room would have named. A
kimi-k2.6 CAB gate turns that blast bundle into {decision, risk, reason}, a deterministic
freeze-window floor hard-routes anything scheduled in a change freeze regardless of what the model
says, and the verdict + a change calendar + the policy as data all land in SQL — the same rows the
graph reads.
The problem — and why it matters
The person who feels this is the change manager running the CAB (Change Advisory Board), and the money is failed changes. Industry surveys put change as the single largest cause of major incidents — a botched migration, a config push with an unmodelled dependency, a "standard" change that quietly touched a payments service. Every one is an outage the business pays for in lost revenue and an all-hands firefight; the CAB exists to catch them before they ship.
But the classic CAB can't see risk. It sees a requester's self-assessed risk: low dropdown and a free-text description. The one system that actually knows what a change endangers — the CMDB, the graph of every app→service→host→database dependency — lives in a separate Neo4j, while the change tickets live in a Postgres, and correlating them is a person eyeballing two screens. So "risk" is a guess, and the CAB rubber-stamps a queue instead of adjudicating the changes that matter.
What collapses onto one bucket: the change tickets, the CMDB impact graph, and the CAB verdict store
are the same rows under two query pillars — SQL and Graph (graph_khop() / Cypher over Bolt). The
risk of a change is a graph_khop('cmdb_impact', <the change's CI>, 5) away — over live rows, not last
night's snapshot in a separate graph DB. The payoff: the CAB opens a change and instantly sees the
fleet-wide blast radius, the impacted business services, and a model-graded cab_review / high verdict
with the reason — instead of trusting a dropdown.
| Piece | Lands in | Pillar |
|---|---|---|
Change tickets (consumed from itsm/core) | changes (merge-keyed) | SQL |
CMDB blast radius (consumed from itsm/cmdb-blast-radius) | graph cmdb_impact over cis / impacts | Graph |
| CAB verdict + change calendar + policy | change_approvals / change_calendar / change_policy | SQL |
| The change advisor | itsm-change-advisor — blast → gate → verdict | Ignite + Models |
NOTE
This is a workflow skill in the itsm suite. It consumes changes / cis / services from
itsm/core and the cmdb_impact graph from
itsm/cmdb-blast-radius — the graph's flagship consumer. If you've
already scaffolded those, skip the stub in Step 1. Standalone, Step 1 stubs just the masters this skill
reads so it runs on an empty bucket.
Prerequisites
- A DODIL organization with the
dodilCLI authenticated (dodil auth login) or the DODIL MCP connected to your agent (Claude Code, Cursor, VS Code, Codex). Headless? Checkauth_statusfirst — an agent can't do the browser login for you. Every step shows an Ask your agent tab and a CLI tab. export BUCKET=itsm— one bucket is the whole change system's data plane (thebucketparam, defaultitsm).
Step 1 — Stand up the change tables (and stub the masters)
Three merge-keyed tables own change management's state: change_approvals (the CAB verdict), change_calendar
(scheduled windows + freeze conflicts), and change_policy (the thresholds, as data). A --merge-key
(PRIMARY KEY) is required — writes are keyed, so every re-assess upserts one verdict row per change,
idempotently. Optional columns are nullable:true: data table create makes non-PK columns NOT NULL by
default, and the Models-verdict dims (blast_radius_json, gate_verdict, rationale) arrive later.
Each data step carries a third ORM tab — the exact SQLAlchemy class from the runnable package's
models.py (natural PKs, DateTime timestamps), the same write a third way. Get the whole package in
Get the code.
In the itsm bucket, create three merge-keyed tables: change_approvals (key approval_id) with change_id, approver_group, decision, risk, blast_radius_json, impacted_service_count(int), freeze_conflict(boolean), gate_verdict, rationale, decided_by, decided_at; change_calendar (key slot_id) with change_id, ci_id, environment, window_start, window_end, freeze_conflict(boolean); change_policy (key policy_id) with auto_approve_risk, cab_required_risk, freeze_windows_json, require_pir(boolean), updated_at. All optional columns nullable.
data_table_createCreated change_approvals (pk approval_id, 12 cols), change_calendar (pk slot_id, 7 cols), change_policy (pk policy_id, 6 cols). Upserts are idempotent — one verdict row per change.
export BUCKET=itsm
dodil data table create change_approvals -b "$BUCKET" --merge-key approval_id \
--columns-json '[
{"name":"approval_id","type":"string"},
{"name":"change_id","type":"bigint","nullable":true},
{"name":"approver_group","type":"string","nullable":true},
{"name":"decision","type":"string","nullable":true},
{"name":"risk","type":"string","nullable":true},
{"name":"blast_radius_json","type":"string","nullable":true},
{"name":"impacted_service_count","type":"int","nullable":true},
{"name":"freeze_conflict","type":"boolean","nullable":true},
{"name":"gate_verdict","type":"string","nullable":true},
{"name":"rationale","type":"string","nullable":true},
{"name":"decided_at","type":"string","nullable":true},
{"name":"decided_by","type":"string","nullable":true}
]'
dodil data table create change_calendar -b "$BUCKET" --merge-key slot_id \
--columns-json '[
{"name":"slot_id","type":"string"},
{"name":"change_id","type":"bigint","nullable":true},
{"name":"ci_id","type":"bigint","nullable":true},
{"name":"environment","type":"string","nullable":true},
{"name":"window_start","type":"string","nullable":true},
{"name":"window_end","type":"string","nullable":true},
{"name":"freeze_conflict","type":"boolean","nullable":true}
]'
dodil data table create change_policy -b "$BUCKET" --merge-key policy_id \
--columns-json '[
{"name":"policy_id","type":"string"},
{"name":"auto_approve_risk","type":"string","nullable":true},
{"name":"cab_required_risk","type":"string","nullable":true},
{"name":"freeze_windows_json","type":"string","nullable":true},
{"name":"require_pir","type":"boolean","nullable":true},
{"name":"updated_at","type":"string","nullable":true}
]'# models.py — the three tables this skill OWNS as SQLAlchemy models (natural PKs, never SERIAL;
# timestamps are DateTime, never String — a freeze overlap is a timestamp compare)
from datetime import datetime
from sqlalchemy import BigInteger, Boolean, DateTime, Float, Integer, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class ChangeApproval(Base):
__tablename__ = "change_approvals"
approval_id: Mapped[str] = mapped_column(String, primary_key=True) # ap-<change_id>
change_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
approver_group: Mapped[str | None] = mapped_column(String, nullable=True)
decision: Mapped[str | None] = mapped_column(String, nullable=True) # auto_approve|cab_review|reject
risk: Mapped[str | None] = mapped_column(String, nullable=True) # low|medium|high
blast_radius_json: Mapped[str | None] = mapped_column(String, nullable=True)
impacted_service_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
freeze_conflict: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
gate_verdict: Mapped[str | None] = mapped_column(String, nullable=True)
rationale: Mapped[str | None] = mapped_column(String, nullable=True)
decided_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# WHO approved — the gateway-vouched user (`user["email"] or user["sub"]`), written by /assess.
# This column is the reason the gate is worth having: an audit trail of claims is not a record.
decided_by: Mapped[str | None] = mapped_column(String, nullable=True) # gateway-vouched user
class ChangeCalendar(Base):
__tablename__ = "change_calendar"
slot_id: Mapped[str] = mapped_column(String, primary_key=True)
change_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
ci_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
environment: Mapped[str | None] = mapped_column(String, nullable=True)
window_start: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
window_end: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
freeze_conflict: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
class ChangePolicy(Base):
__tablename__ = "change_policy"
policy_id: Mapped[str] = mapped_column(String, primary_key=True) # e.g. "default"
auto_approve_risk: Mapped[str | None] = mapped_column(String, nullable=True) # none|low|low_medium
cab_required_risk: Mapped[str | None] = mapped_column(String, nullable=True)
freeze_windows_json: Mapped[str | None] = mapped_column(String, nullable=True)
require_pir: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
updated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)Standalone stub (skip if itsm/core + itsm/cmdb-blast-radius are installed). Change management reads
four masters — cis, ci_edges, changes, services — plus the cmdb_impact graph. Stub them with the
same 13-CI e-commerce estate the whole suite was validated against: 13 CIs / 14 typed edges / 4
business services, the changes to assess, and the reverse impact graph. Note mobile-app-bff (CI 10) is
part_of web-storefront (CI 6) — a composition edge, not a dependency, which is exactly the case that
makes the traversal-typing in Step 2 load-bearing.
In the itsm bucket, stub the masters change-management reads: cis (key id), ci_edges (key src,dst,rel), changes (key id), services (key service_id), all optional columns nullable. Seed the 13-CI e-commerce estate (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), 14 typed edges depends_on/runs_on/part_of, four business services, and the changes to assess: CHG5001 (normal, high, ci pg-orders-primary), CHG5002 (standard, low, ci reporting-etl), CHG5003 (normal, medium, ci redis-session), CHG5004 (normal, medium, ci host-app-01, scheduled inside the Black Friday freeze).
data_table_create→data_table_upsert→data_pgStubbed cis (13 rows), ci_edges (14 typed edges), services (4), changes (4). mobile-app-bff is part_of web-storefront (composition, not dependency).
# masters this skill consumes (subset of itsm/core's columns; every non-key column nullable)
dodil data table create cis -b "$BUCKET" --merge-key id \
--columns-json '[{"name":"id","type":"bigint"},{"name":"name","type":"string","nullable":true},{"name":"ci_type","type":"string","nullable":true},{"name":"environment","type":"string","nullable":true},{"name":"owner_group","type":"string","nullable":true},{"name":"service_id","type":"string","nullable":true},{"name":"business_criticality","type":"int","nullable":true},{"name":"status","type":"string","nullable":true}]'
dodil data table create ci_edges -b "$BUCKET" --merge-key src --merge-key dst --merge-key rel \
--columns-json '[{"name":"src","type":"bigint"},{"name":"dst","type":"bigint"},{"name":"rel","type":"string"},{"name":"weight","type":"double","nullable":true}]'
dodil data table create changes -b "$BUCKET" --merge-key id \
--columns-json '[{"name":"id","type":"bigint"},{"name":"number","type":"string","nullable":true},{"name":"ci_id","type":"bigint","nullable":true},{"name":"short_description","type":"string","nullable":true},{"name":"description","type":"string","nullable":true},{"name":"type","type":"string","nullable":true},{"name":"state","type":"string","nullable":true},{"name":"risk","type":"string","nullable":true},{"name":"impact","type":"string","nullable":true},{"name":"requested_by","type":"string","nullable":true},{"name":"assignment_group","type":"string","nullable":true},{"name":"approval_state","type":"string","nullable":true},{"name":"planned_start","type":"string","nullable":true},{"name":"planned_end","type":"string","nullable":true}]'
dodil data table create services -b "$BUCKET" --merge-key service_id \
--columns-json '[{"name":"service_id","type":"string"},{"name":"name","type":"string","nullable":true},{"name":"business_service","type":"string","nullable":true},{"name":"owner_group","type":"string","nullable":true},{"name":"tier","type":"int","nullable":true},{"name":"sla_id","type":"string","nullable":true}]'
# CIs — the 13-CI e-commerce estate; mobile-app-bff (10) is part_of web-storefront (6)
dodil data table upsert cis -b "$BUCKET" \
--row '{"id":1,"name":"pg-orders-primary","ci_type":"database","environment":"prod","owner_group":"g-orders","service_id":"svc-orders","business_criticality":1,"status":"operational"}' \
--row '{"id":2,"name":"redis-session","ci_type":"database","environment":"prod","owner_group":"g-platform","service_id":"svc-checkout","business_criticality":2,"status":"operational"}' \
--row '{"id":3,"name":"checkout-api","ci_type":"app","environment":"prod","owner_group":"g-platform","service_id":"svc-checkout","business_criticality":1,"status":"operational"}' \
--row '{"id":4,"name":"orders-api","ci_type":"app","environment":"prod","owner_group":"g-orders","service_id":"svc-orders","business_criticality":1,"status":"operational"}' \
--row '{"id":5,"name":"payments-gateway","ci_type":"app","environment":"prod","owner_group":"g-platform","service_id":"svc-checkout","business_criticality":1,"status":"operational"}' \
--row '{"id":6,"name":"web-storefront","ci_type":"app","environment":"prod","owner_group":"g-platform","service_id":"svc-checkout","business_criticality":1,"status":"operational"}' \
--row '{"id":7,"name":"host-app-01","ci_type":"host","environment":"prod","owner_group":"g-platform","business_criticality":2,"status":"operational"}' \
--row '{"id":8,"name":"accounts-api","ci_type":"app","environment":"prod","owner_group":"g-platform","service_id":"svc-accounts","business_criticality":2,"status":"operational"}' \
--row '{"id":9,"name":"reporting-etl","ci_type":"app","environment":"prod","owner_group":"g-orders","service_id":"svc-reporting","business_criticality":3,"status":"operational"}' \
--row '{"id":10,"name":"mobile-app-bff","ci_type":"app","environment":"prod","owner_group":"g-platform","service_id":"svc-checkout","business_criticality":2,"status":"operational"}' \
--row '{"id":11,"name":"pg-orders-replica","ci_type":"database","environment":"prod","owner_group":"g-orders","service_id":"svc-orders","business_criticality":2,"status":"operational"}' \
--row '{"id":12,"name":"search-index","ci_type":"app","environment":"prod","owner_group":"g-orders","service_id":"svc-orders","business_criticality":3,"status":"operational"}' \
--row '{"id":13,"name":"cdn-edge","ci_type":"app","environment":"prod","service_id":"svc-checkout","business_criticality":3,"status":"operational"}'
# TYPED edges (14): depends_on (dependency), runs_on (placement), part_of (composition)
dodil data pg -b "$BUCKET" "INSERT INTO ci_edges VALUES
(3,1,'depends_on',1.0),(3,2,'depends_on',1.0),(4,1,'depends_on',1.0),
(5,3,'depends_on',1.0),(6,3,'depends_on',1.0),(6,5,'depends_on',1.0),
(8,1,'depends_on',1.0),(9,1,'depends_on',1.0),(11,1,'depends_on',1.0),
(12,11,'depends_on',1.0),(13,6,'depends_on',1.0),
(3,7,'runs_on',1.0),(4,7,'runs_on',1.0),
(10,6,'part_of',1.0)"
dodil data table upsert services -b "$BUCKET" \
--row '{"service_id":"svc-checkout","name":"Checkout","business_service":"Online Checkout","owner_group":"g-platform","tier":1,"sla_id":"sla-p1"}' \
--row '{"service_id":"svc-orders","name":"Orders","business_service":"Order Management","owner_group":"g-orders","tier":1,"sla_id":"sla-p1"}' \
--row '{"service_id":"svc-accounts","name":"Accounts","business_service":"Customer Accounts","owner_group":"g-platform","tier":2,"sla_id":"sla-p2"}' \
--row '{"service_id":"svc-reporting","name":"Reporting","business_service":"Internal Reporting","owner_group":"g-orders","tier":3,"sla_id":"sla-p3"}'
# the changes to assess
dodil data table upsert changes -b "$BUCKET" \
--row '{"id":5001,"number":"CHG5001","ci_id":1,"short_description":"Apply the quarterly security patch to pg-orders-primary","description":"Quarterly security patch on the primary orders database. Requires a restart of the instance.","type":"normal","state":"assess","risk":"high","impact":"high","requested_by":"[email protected]","assignment_group":"g-dba","approval_state":"pending","planned_start":"2026-10-14 04:00:00","planned_end":"2026-10-14 06:00:00"}' \
--row '{"id":5002,"number":"CHG5002","ci_id":9,"short_description":"Rotate the TLS certificate on reporting-etl","description":"Routine certificate rotation on the reporting ETL job. Nothing depends on it.","type":"standard","state":"assess","risk":"low","impact":"low","requested_by":"[email protected]","assignment_group":"g-orders","approval_state":"pending","planned_start":"2026-09-10 22:00:00","planned_end":"2026-09-10 23:00:00"}' \
--row '{"id":5003,"number":"CHG5003","ci_id":2,"short_description":"Upgrade redis-session to 7.2","description":"Minor-version upgrade of the session store behind checkout.","type":"normal","state":"assess","risk":"medium","impact":"medium","requested_by":"[email protected]","assignment_group":"g-platform","approval_state":"pending","planned_start":"2026-09-11 22:00:00","planned_end":"2026-09-12 01:00:00"}' \
--row '{"id":5004,"number":"CHG5004","ci_id":7,"short_description":"Resize host-app-01 to add memory headroom","description":"Resize the shared app host. Scheduled inside the Black Friday freeze window.","type":"normal","state":"assess","risk":"medium","impact":"high","requested_by":"[email protected]","assignment_group":"g-platform","approval_state":"pending","planned_start":"2026-11-25 04:00:00","planned_end":"2026-11-25 07:00:00"}'
# the reverse impact graph — build impacts from the impact-bearing rels, THEN CREATE GRAPH (it snapshots edges)
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 ci_edges WHERE rel IN ('depends_on','runs_on','part_of')"
dodil data pg -b "$BUCKET" "CREATE GRAPH cmdb_impact NODES (cis KEY id) EDGES (impacts SRC src DST dst)"# models.py (cont.) — the masters this skill READS, owned by itsm/core + itsm/cmdb-blast-radius
# and stubbed here so the package runs standalone (same imports as above)
class Change(Base):
__tablename__ = "changes"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True) # natural id you assign
number: Mapped[str | None] = mapped_column(String, nullable=True) # CHG5001
ci_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) # the CI the change touches
short_description: Mapped[str | None] = mapped_column(String, nullable=True)
description: Mapped[str | None] = mapped_column(String, nullable=True)
type: Mapped[str | None] = mapped_column(String, nullable=True) # standard|normal|emergency
state: Mapped[str | None] = mapped_column(String, nullable=True) # assess|scheduled|implement|review|closed
risk: Mapped[str | None] = mapped_column(String, nullable=True)
impact: Mapped[str | None] = mapped_column(String, nullable=True)
requested_by: Mapped[str | None] = mapped_column(String, nullable=True)
assignment_group: Mapped[str | None] = mapped_column(String, nullable=True)
approval_state: Mapped[str | None] = mapped_column(String, nullable=True) # pending|approved
planned_start: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
planned_end: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
class Ci(Base):
__tablename__ = "cis"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True) # integer id = the graph node key
name: Mapped[str | None] = mapped_column(String, nullable=True)
ci_type: Mapped[str | None] = mapped_column(String, nullable=True) # app|service|host|database
environment: Mapped[str | None] = mapped_column(String, nullable=True)
owner_group: Mapped[str | None] = mapped_column(String, nullable=True)
service_id: Mapped[str | None] = mapped_column(String, nullable=True)
business_criticality: Mapped[int | None] = mapped_column(Integer, nullable=True)
status: Mapped[str | None] = mapped_column(String, nullable=True)
class CiEdge(Base):
__tablename__ = "ci_edges"
src: Mapped[int] = mapped_column(BigInteger, primary_key=True) # the dependent / child CI
dst: Mapped[int] = mapped_column(BigInteger, primary_key=True) # the depended-on / parent CI
rel: Mapped[str] = mapped_column(String, primary_key=True, default="depends_on") # depends_on|runs_on|part_of
weight: Mapped[float | None] = mapped_column(Float, nullable=True)
class Service(Base):
__tablename__ = "services"
service_id: Mapped[str] = mapped_column(String, primary_key=True) # natural key
name: Mapped[str | None] = mapped_column(String, nullable=True)
business_service: Mapped[str | None] = mapped_column(String, nullable=True)
owner_group: Mapped[str | None] = mapped_column(String, nullable=True)
tier: Mapped[int | None] = mapped_column(Integer, nullable=True)
sla_id: Mapped[str | None] = mapped_column(String, nullable=True)
class Impact(Base):
# ci_edges flipped (dst->src), filtered to the impact-bearing rels; the cmdb_impact graph is built from it
__tablename__ = "impacts"
src: Mapped[int] = mapped_column(BigInteger, primary_key=True) # the failing CI
dst: Mapped[int] = mapped_column(BigInteger, primary_key=True) # a CI it takes downStep 2 — Risk is the blast radius (the typed graph read)
Here is the whole thesis. The risk of a change is who breaks if it goes wrong — every CI that
transitively depends on the CI the change touches. That's a reverse traversal of the CMDB, and
cmdb_impact is exactly that graph (impacts = the impact-bearing edges flipped). graph_khop('cmdb_impact', 1, 5) from pg-orders-primary (CI 1) returns the impacted set with hop distance, and you hydrate CI
names by joining cis in the same statement:
In the itsm bucket, give me the blast radius of CHG5001, the quarterly security patch on pg-orders-primary (CI 1): graph_khop over cmdb_impact, hop-ranked, hydrated with CI names, types, and owning services.
data_pg10 CIs, max hop 3 — 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. Four business services impacted.
dodil data pg -b "$BUCKET" \
"SELECT k.hop_distance, c.name, c.ci_type, c.owner_group, c.service_id
FROM graph_khop('cmdb_impact', 1, 5) k
JOIN cis c ON c.id = k.node
ORDER BY k.hop_distance, c.name"Real output — patching the primary orders database reaches three tiers and ten CIs:
hop_distance name ci_type owner_group service_id
1 accounts-api app g-platform svc-accounts
1 checkout-api app g-platform svc-checkout
1 orders-api app g-orders svc-orders
1 pg-orders-replica database g-orders svc-orders
1 reporting-etl app g-orders svc-reporting
2 payments-gateway app g-platform svc-checkout
2 search-index app g-orders svc-orders
2 web-storefront app g-platform svc-checkout
3 cdn-edge app svc-checkout
3 mobile-app-bff app g-platform svc-checkout
That is the number a requester's risk: medium dropdown never contains. One database patch, ten downstream CIs, three hops out to a mobile BFF and a CDN edge nobody in the room would have named.
NOTE
A blast radius is a reading of a graph snapshot, and the snapshot has a timestamp. CREATE GRAPH
fixes its edges at creation, so a verdict records what the graph knew when the change was assessed —
not what the graph says now. Run the traversal today and it returns 10 CIs, as above. But CHG5001's
stored verdict says 9: it was assessed against the snapshot taken before POST /graph/assemble
picked up the cdn-edge → web-storefront edge. CHG3001, assessed on the same CI after that rebuild,
recorded 10. Same query, two snapshots, and neither row is wrong.
This is worth more than a footnote, because it is the whole argument for the one gated CMDB operation.
Every component's risk number is downstream of what POST /graph/assemble leaves behind — which is why
that route, and only that route, sits behind itsm:cmdb:rebuild (see
Auth). Re-run the traversal and you get the
current reading; re-read change_approvals and you get the reading the CAB actually decided on. An audit
needs the second one, which is why the verdict is stored rather than recomputed on demand.
The same traversal in Cypher over Bolt (the graph plane hands back node keys; join cis for properties):
Same blast radius of pg-orders-primary in Cypher over Bolt: from CI 1, follow impacts up to 5 hops and return the impacted nodes.
data_boltReturns nodes 3, 4, 8, 9, 11 (hop 1), 5, 6, 12 (hop 2), 10, 13 (hop 3) — the same impacted set.
dodil data bolt -b "$BUCKET" -g cmdb_impact \
"MATCH (f)-[:impacts*1..5]->(a) WHERE id(f)=1 RETURN a"Type your traversal — this is the load-bearing subtlety. ci_edges mixes three relationship kinds,
and part_of is composition, not a failure-propagating dependency: mobile-app-bff is part of
web-storefront, but whether the storefront failing should count the BFF as "impacted" is a policy
choice, not a given. The impact_rels knob controls it — the reverse impacts graph is built only from the
rels that propagate failure. Build it from depends_on + runs_on and the composition edge correctly drops
out of the blast:
Prove the traversal is typed: recompute pg-orders-primary's blast over depends_on and runs_on only, excluding part_of composition, and show it shrinks.
data_pgTyped blast of pg-orders-primary = 9 CIs. mobile-app-bff — reachable only over a part_of edge — is gone, and Online Checkout falls from 5 impacted CIs to 4. Blanket = 10, typed = 9.
# IMPACT_RELS = the rels that actually propagate failure (part_of excluded). A rel predicate
# can't ride inside the graph traversal, so the typed blast is a recursive CTE over ci_edges.
dodil data pg -b "$BUCKET" \
"WITH RECURSIVE blast(id) AS (
SELECT src FROM ci_edges WHERE dst = 1 AND rel IN ('depends_on','runs_on')
UNION
SELECT e.src FROM ci_edges e JOIN blast b ON e.dst = b.id
WHERE e.rel IN ('depends_on','runs_on'))
SELECT c.name FROM cis c JOIN blast ON c.id = blast.id ORDER BY c.name"A blanket reverse traversal over every edge over-counts blast radius the moment composition edges exist — the ITSM analog of the CRM family rollup that had to filter
rel='subsidiary_of'or partner edges leaked in. Here it is one CI in ten, and it is the difference between telling the CAB that five Online Checkout components are at risk and telling them four. Setimpact_relsto the rels that actually propagate failure and the risk number is honest.
Now fold the blast into a risk signal a CAB reads — the impacted business services, grouped:
For CHG5001, roll the blast radius up to impacted business services — join the blast set to services and group by business_service.
data_pgFour business services: Online Checkout (tier 1) 5 CIs, Order Management (tier 1) 3, Customer Accounts (tier 2) 1, Internal Reporting (tier 3) 1 → impacted_service_count = 4, two of them tier 1. That is the risk.
dodil data pg -b "$BUCKET" \
"SELECT s.business_service, s.tier, count(DISTINCT c.id) AS impacted_cis
FROM graph_khop('cmdb_impact', 1, 5) k
JOIN cis c ON c.id = k.node
JOIN services s ON s.service_id = c.service_id
GROUP BY s.business_service, s.tier
ORDER BY impacted_cis DESC" business_service tier impacted_cis
Online Checkout 1 5
Order Management 1 3
Customer Accounts 2 1
Internal Reporting 3 1
Contrast that with a low-risk change: CHG5002 rotates a TLS certificate on reporting-etl (CI 9) — a
leaf of the dependency tree, which nothing else reads. Its blast radius is empty, and that emptiness is
what earns it an auto-approve:
What is the blast radius of CHG5002, the certificate rotation on reporting-etl (CI 9)? Count the impacted CIs.
data_pg0 — nothing depends on reporting-etl. Empty blast → eligible for auto-approve.
dodil data pg -b "$BUCKET" "SELECT count(*) AS blast_size FROM graph_khop('cmdb_impact', 9, 5)"Step 3 — The change calendar + freeze conflicts (deterministic)
Risk isn't the only gate. A change scheduled inside a change freeze (quarter-end, market hours, a
year-end code freeze) is routed to the CAB regardless of how small its blast radius is — that's a
policy decision, not a model judgement. Schedule each change's window in change_calendar, and a
freeze conflict is a plain SQL overlap against change_policy.freeze_windows. First the policy row —
the thresholds live as data, one source the gate prompt and the handler both read:
Write the default change policy: auto_approve_risk=low, cab_required_risk=medium, require_pir=true, and two freeze windows — Black Friday 2026-11-20 to 2026-12-02 and Year end 2026-12-18 to 2027-01-05.
data_table_upsertchange_policy 'default' written — low self-approves, medium+ needs CAB, PIR required, two freeze windows (Black Friday, Year end) active.
dodil data table upsert change_policy -b "$BUCKET" \
--row '{"policy_id":"default","auto_approve_risk":"low","cab_required_risk":"medium","freeze_windows_json":"[{\"name\":\"Black Friday\",\"start\":\"2026-11-20\",\"end\":\"2026-12-02\"},{\"name\":\"Year end\",\"start\":\"2026-12-18\",\"end\":\"2027-01-05\"}]","require_pir":true,"updated_at":"2026-09-08 10:00:00"}'The freeze calendar is a list, not a single range — a retailer freezes for peak trading and for
year-end close, and a change has to miss both. Now schedule the changes and detect the conflict. CHG5004
(resize host-app-01) is booked for 2026-11-25 — inside Black Friday — so it conflicts; the others are
clear:
Schedule the changes in change_calendar (5001 on 2026-10-14, 5003 on 2026-09-11, 5004 on 2026-11-25). Then flag which windows overlap either freeze range — a plain timestamp overlap.
data_table_upsert→data_pgslots written. Freeze overlap: only CHG5004 (2026-11-25) falls inside Black Friday → freeze_conflict=true. 5001 and 5003 are clear.
dodil data table upsert change_calendar -b "$BUCKET" \
--row '{"slot_id":"slot-5001","change_id":5001,"ci_id":1,"environment":"prod","window_start":"2026-10-14 04:00:00","window_end":"2026-10-14 06:00:00","freeze_conflict":false}' \
--row '{"slot_id":"slot-5003","change_id":5003,"ci_id":2,"environment":"prod","window_start":"2026-09-11 22:00:00","window_end":"2026-09-12 01:00:00","freeze_conflict":false}' \
--row '{"slot_id":"slot-5004","change_id":5004,"ci_id":7,"environment":"prod","window_start":"2026-11-25 04:00:00","window_end":"2026-11-25 07:00:00","freeze_conflict":true}'
# freeze conflict = the window overlaps ANY freeze range (start < freeze_end AND end > freeze_start)
dodil data pg -b "$BUCKET" \
"SELECT change_id, window_start, window_end,
(window_start::timestamp < TIMESTAMP '2026-12-02 00:00:00'
AND window_end::timestamp > TIMESTAMP '2026-11-20 00:00:00') AS black_friday,
(window_start::timestamp < TIMESTAMP '2027-01-05 00:00:00'
AND window_end::timestamp > TIMESTAMP '2026-12-18 00:00:00') AS year_end
FROM change_calendar ORDER BY change_id"Step 4 — The CAB gate (Models)
Now the judgement layer. A deterministic floor decides the clear cases — low risk + empty blast + no
freeze → auto_approve; large blast / prod / freeze conflict → cab_review or reject. The kimi-k2.6
CAB gate adds judgement for the edge cases the floor can't articulate (a small-blast change that still
touches a payments CI), and returns strict JSON the handler parses. The prompt renders the policy's
auto_approve_risk. Here is the gate on the high-risk pg-orders-primary patch — its blast bundle in, a
verdict out. This is the call exactly as CHG5001 was adjudicated, so the bundle carries the 9 CIs the
graph held at assess time rather than the 10 the same traversal returns today (the snapshot note in Step 2);
what the gate is reasoning over is the blast the CAB saw:
Adjudicate CHG5001 with kimi-k2.6 as a CAB. Policy: only low-risk changes with an empty blast radius and no freeze conflict self-approve; a prod change with a non-empty blast requires CAB; any freeze conflict routes to CAB. The change: quarterly security patch on pg-orders-primary (normal, prod), blast radius 9 CIs, impacted services Online Checkout / Order Management / Customer Accounts / Internal Reporting, no freeze conflict. Return ONLY compact JSON {decision, risk, reason}.
ignite_models_chat{"decision":"cab_review","risk":"high","reason":"Prod change with non-empty blast radius spanning 9 CIs and 4 business services."}
dodil ignite models chat kimi-k2.6 \
--system 'You are a Change Advisory Board (CAB) adjudicator. Policy: auto_approve_risk=low (only a low-risk change with an EMPTY blast radius and no freeze conflict may self-approve); a prod change with a non-empty blast radius requires CAB; any freeze conflict routes to CAB regardless of risk. Weigh the blast radius and impacted business services as the risk. Return ONLY compact JSON: {"decision": one of [auto_approve, cab_review, reject], "risk": one of [low, medium, high], "reason": short}. No prose, no reasoning, no preamble.' \
--message 'Change CHG5001: "Apply the quarterly security patch to pg-orders-primary"; requires an instance restart. Type: normal. Environment: prod. Affected CI: pg-orders-primary (criticality 1). CMDB blast radius (9 CIs): accounts-api, checkout-api, mobile-app-bff, orders-api, payments-gateway, pg-orders-replica, reporting-etl, search-index, web-storefront. Impacted business services: Online Checkout (tier 1), Order Management (tier 1), Customer Accounts (tier 2), Internal Reporting (tier 3). Freeze conflict: none. Return ONLY compact JSON, no reasoning/preamble.'And the low-risk CHG5002 — empty blast, standard, no freeze — self-approves without ever reaching the
model: the deterministic floor takes it, because there is nothing for a board to review.
Adjudicate CHG5002 with the same CAB policy: TLS certificate rotation on reporting-etl (standard, prod), blast radius EMPTY, no freeze conflict. Return ONLY compact JSON {decision, risk, reason}.
ignite_models_chat{"decision":"auto_approve","risk":"low","reason":"empty blast radius, low-risk standard change"}
dodil ignite models chat kimi-k2.6 \
--system 'You are a Change Advisory Board (CAB) adjudicator. Policy: auto_approve_risk=low (only a low-risk change with an EMPTY blast radius and no freeze conflict may self-approve); a prod change with a non-empty blast radius requires CAB; any freeze conflict routes to CAB regardless of risk. Return ONLY compact JSON: {"decision": one of [auto_approve, cab_review, reject], "risk": one of [low, medium, high], "reason": short}. No prose, no reasoning, no preamble.' \
--message 'Change CHG5002: "Rotate the TLS certificate on reporting-etl"; routine rotation, nothing depends on it. Type: standard. Environment: prod. Affected CI: reporting-etl. CMDB blast radius: EMPTY (0 CIs). Impacted business services: none. Freeze conflict: none. Return ONLY compact JSON, no reasoning/preamble.'NOTE
kimi-k2.6 is a reasoning model. Reasoning tokens eat the budget first, so via ignite models chat
(no max_tokens knob) it can return empty content. End every gate prompt with Return ONLY compact JSON, no reasoning/preamble and retry until the reply is non-empty — retry-once is not reliable
enough for kimi-k2.6. The deployed itsm-change-advisor handler sets max_tokens: 4096 on the raw
api.dodil.io/v1 call, sets an explicit User-Agent (stdlib urllib's default is Cloudflare-banned), and
unwraps the response's data envelope.
Step 5 — Land the verdict
The gate's JSON, the deterministic blast, and the freeze flag combine into one change_approvals row per
change, and changes.approval_state advances (auto_approve → approved, cab_review → pending awaiting
the board). CHG5004 shows the freeze floor overriding the model: a routine host resize the gate would
happily grade medium — but the Black Friday conflict hard-routes it to cab_review regardless. The model
advises; the deterministic floor decides. A change inside a freeze window cannot be talked out of review by
an LLM.
Write the CAB verdicts to change_approvals: 5002 auto_approve/low (empty blast), 5001 cab_review/high (blast of 9 CIs, 4 impacted services), 5003 cab_review/high (blast of 4 CIs, 1 impacted service), 5004 cab_review/high with freeze_conflict=true (routed regardless — the freeze floor overrides the model). Then advance changes.approval_state: 5002 approved, the rest pending.
data_table_upsert4 change_approvals rows; changes advanced. CHG5004 routed to CAB by the freeze floor regardless of what the gate would have said.
dodil data table upsert change_approvals -b "$BUCKET" \
--row '{"approval_id":"ap-5002","change_id":5002,"approver_group":"auto","decision":"auto_approve","risk":"low","blast_radius_json":"[]","impacted_service_count":0,"freeze_conflict":false,"gate_verdict":"{\"decision\":\"auto_approve\",\"risk\":\"low\"}","rationale":"empty blast radius, low-risk standard change","decided_at":"2026-09-08 10:05:00"}' \
--row '{"approval_id":"ap-5001","change_id":5001,"approver_group":"cab","decision":"cab_review","risk":"high","blast_radius_json":"[\"accounts-api\",\"checkout-api\",\"mobile-app-bff\",\"orders-api\",\"payments-gateway\",\"pg-orders-replica\",\"reporting-etl\",\"search-index\",\"web-storefront\"]","impacted_service_count":4,"freeze_conflict":false,"gate_verdict":"{\"decision\":\"cab_review\",\"risk\":\"high\"}","rationale":"Prod change with non-empty blast radius spanning 9 CIs and 4 business services.","decided_at":"2026-09-08 10:05:00"}' \
--row '{"approval_id":"ap-5003","change_id":5003,"approver_group":"cab","decision":"cab_review","risk":"high","blast_radius_json":"[\"checkout-api\",\"mobile-app-bff\",\"payments-gateway\",\"web-storefront\"]","impacted_service_count":1,"freeze_conflict":false,"gate_verdict":"{\"decision\":\"cab_review\",\"risk\":\"high\"}","rationale":"Prod change with non-empty blast radius impacting Online Checkout","decided_at":"2026-09-08 10:05:00"}' \
--row '{"approval_id":"ap-5004","change_id":5004,"approver_group":"cab","decision":"cab_review","risk":"high","blast_radius_json":"[\"checkout-api\",\"mobile-app-bff\",\"orders-api\",\"payments-gateway\",\"web-storefront\"]","impacted_service_count":2,"freeze_conflict":true,"gate_verdict":"deterministic_freeze_route","rationale":"freeze-window conflict (deterministic floor, overrides the model)","decided_at":"2026-09-08 10:05:00"}'
# advance the change lifecycle (partial merge — only the state columns)
dodil data table upsert changes -b "$BUCKET" --merge \
--row '{"id":5002,"approval_state":"approved","state":"scheduled"}' \
--row '{"id":5001,"approval_state":"pending","state":"assess"}' \
--row '{"id":5003,"approval_state":"pending","state":"assess"}' \
--row '{"id":5004,"approval_state":"pending","state":"assess"}'Join the verdict back to the change and the CAB has its worklist — decision, risk, impacted-service count, and freeze flag, over one copy of the rows:
Show the CAB worklist: join change_approvals to changes and list change number, description, decision, risk, impacted_service_count, freeze_conflict, approval_state.
data_pgCHG3001 cab_review/medium/pending (4 impacted services); CHG5001 cab_review/high/pending (4 impacted services); CHG5002 auto_approve/low/approved; CHG5003 cab_review/high/pending (1 impacted service); CHG5004 cab_review/high/pending (freeze_conflict=true).
dodil data pg -b "$BUCKET" \
"SELECT a.change_id, ch.number, ch.short_description, a.decision, a.risk,
a.impacted_service_count, a.freeze_conflict, ch.approval_state
FROM change_approvals a JOIN changes ch ON ch.id = a.change_id
ORDER BY a.change_id"The entry state is part of the API
CHG3001 in that worklist did not come from a person. It is the permanent-fix change that
problem management spawns when its clusterer isolates a recurring
root cause — and when all seven ITSM components were first stood up together on one bucket, it was the
one change in the system that could never be approved.
The assess route guards its entry: a change is assessable only from ASSESSABLE_STATES = {"assess", None},
because you should not adjudicate a change that has not reached assessment. The problem engine created its
fix-change in state='new' — a perfectly reasonable initial state for a ticket nobody has triaged. Each
component was right on its own. Together they were a deadlock: every POST /assess {"change_id": 3001}
returned
409 change is frozen in state 'new' — not assessable
forever, on every retry. The fix is on the producer — the clusterer now spawns the change in the state the CAB gate accepts — but the lesson is the design rule, not the patch:
A state machine is a contract between components, and the entry state is part of the API. If your guard rejects a state, document which states are entry points, the same way you would document a required field. A
409that is correct in isolation is still an outage when the only producer of that record cannot satisfy it.
Note the diagnostic tell, because it is what made this hard to see: the failure was a stable 409, not
a crash. Nothing alerted, no pod restarted, no error budget moved — the API was behaving exactly as
designed. It surfaced only because both components were running against one bucket and somebody asked the
obvious question: why has the problem's fix-change never been approved?
This is one of six integration bugs the joint run found, and they all have the same shape. Alone, each
of the seven ITSM components passed its own ## Test. Stood up together on one bucket, as the suite app
actually runs, they surfaced six failures in an afternoon — because components validated separately are
internally consistent and still wrong together. Consistency with yourself is not correctness; the other
five are told where they belong, in core,
incident, problem and
SLA management.
How the pillars map
One bucket, one auth context — the change ticket is the graph query is the verdict store, over one copy of the rows. What this would otherwise be:
| Job | The usual stack | On DataK3 |
|---|---|---|
| Change tickets + approval state | Postgres (ITSM ticket DB) | SQL — changes, change_approvals (merge-keyed) |
| Blast radius / impact analysis | Neo4j CMDB + a nightly sync | Graph — graph_khop('cmdb_impact', …), Cypher over Bolt |
| Freeze-window / calendar conflict | A spreadsheet + human eyeballing | SQL overlap — change_calendar vs change_policy.freeze_windows |
| CAB risk judgement | A person reading two screens | kimi-k2.6 gate over the blast bundle → {decision, risk, reason} |
| Automation runtime | Workflow engine + connectors | Ignite itsm-change-advisor, one bucket, one auth context |
The risk score and the change ticket it grades are the same live rows, not last night's snapshot in a separate graph DB.
Routes
Steps 1–5 run the pipeline query-by-query; the download (see Get the code) fronts the
same bucket with a small FastAPI app, routes.py — the policy/calendar CRUD plus the ops that turn a
change into a verdict. 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, byte-identical with crm-core). 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: INSERT … ON CONFLICT (<pk>) DO UPDATE. On DataK3 a bare re-INSERT of an
already-committed key raises duplicate-key 23505 (verified live this build), so upsert is what makes
a re-assess, a retry, or a shard replay land the verdict once.
CRUD — the policy and the calendar are data. POST /calendar flags a freeze conflict as the slot lands
(a deterministic timestamp overlap against change_policy.freeze_windows, never model judgement):
# routes.py — the policy + calendar CRUD (upsert = idempotent, keyed on the natural PK)
@router.post("/policies")
def upsert_policy(p: PolicyIn, user=Depends(require_permission("itsm:change:approve")),
s: Session = Depends(db)):
"""The policy is a row — raise `auto_approve_risk` to `none` and re-assess to tighten the
bar, no redeploy. A keyed upsert, so editing it lands on the same policy_id in place.
GATED: editing the policy IS editing the gate, so it needs the same permission as passing it."""
row = p.model_dump()
row["updated_at"] = _now()
upsert(s, ChangePolicy, [row], key="policy_id")
s.commit()
return {"ok": True, "policy_id": p.policy_id}
@router.post("/calendar")
def schedule_slot(slot: SlotIn, user=Depends(current_user), s: Session = Depends(db)):
"""Book a change window, and flag a freeze conflict as it lands — a deterministic
timestamp overlap against every `change_policy` freeze range (NOT model judgement)."""
row = slot.model_dump()
row["freeze_conflict"] = _overlaps_freeze(s, slot.window_start, slot.window_end)
upsert(s, ChangeCalendar, [row], key="slot_id")
s.commit()
return {"ok": True, "slot_id": slot.slot_id, "freeze_conflict": row["freeze_conflict"]}Workflow op 1 — risk is the blast radius (GRAPH). GET /changes/{change_id}/blast-radius traverses the
typed cmdb_impact graph, and it obeys the three DataK3 graph rules the code bakes in: cypher(…) is a
top-level table function (no UNION/subquery), 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)). The part_of-composition contrast is the typed number — a rel predicate
can't ride inside cypher(), so it's a recursive CTE over ci_edges:
# routes.py — op 1: the blast radius (GRAPH), top-level cypher() SELECT + IN(…)
def _blast_radius(s: Session, ci_id: int) -> tuple[list[str], list[str]]:
nodes = s.execute(text(
f"SELECT node FROM cypher('{IMPACT_GRAPH}', "
f"'MATCH (f)-[*1..{MAX_HOPS}]->(a) WHERE id(f) = {int(ci_id)} RETURN a')"
)).scalars().all()
ids = list(dict.fromkeys(int(n) for n in nodes))
if not ids:
return [], []
in_list = ",".join(str(i) for i in ids)
names = s.execute(text(f"SELECT name FROM cis WHERE id IN ({in_list}) ORDER BY name")).scalars().all()
services = s.execute(text(
"SELECT DISTINCT sv.business_service FROM cis c JOIN services sv ON sv.service_id = c.service_id "
f"WHERE c.id IN ({in_list}) AND sv.business_service IS NOT NULL")).scalars().all()
return list(names), list(services)
def _blast_radius_typed(s: Session, ci_id: int) -> list[str]:
# TYPED by rel — a rel predicate can't ride inside cypher(), so composition-typed traversal is a CTE
rels = ",".join(f"'{r}'" for r in IMPACT_RELS) # IMPACT_RELS excludes part_of composition
return list(s.execute(text(
"WITH RECURSIVE blast(id) AS ("
f" SELECT src FROM ci_edges WHERE dst = {int(ci_id)} AND rel IN ({rels}) "
" UNION "
f" SELECT e.src FROM ci_edges e JOIN blast b ON e.dst = b.id WHERE e.rel IN ({rels})) "
"SELECT c.name FROM cis c JOIN blast ON c.id = blast.id ORDER BY c.name")).scalars().all())Live-verified this build: GET /changes/5001/blast-radius (the pg-orders-primary patch, CI 1) returns
blast_radius: [accounts-api, cdn-edge, checkout-api, mobile-app-bff, orders-api, payments-gateway, pg-orders-replica, reporting-etl, search-index, web-storefront] (10 CIs), impacted_services: [Online Checkout, Order Management, Customer Accounts, Internal Reporting] (count 4), and blast_radius_typed
9 — the part_of BFF drops out. GET /changes/5002/blast-radius (the reporting-etl cert rotation,
CI 9) returns an empty blast. Note this route recomputes against the current graph, so it can and
does differ from the blast stored on an older verdict — see the snapshot note in Step 2.
Workflow op 2 — the assess pipeline (the money route). POST /assess {change_id} computes the blast
(graph), detects the freeze conflict (deterministic), decides with a deterministic floor then the
kimi-k2.6 CAB gate for the edge cases, writes one change_approvals row (keyed on ap-<change_id>), and
advances changes.approval_state — all idempotent. It is also where the immutability guard lives: an
approved/scheduled change is frozen (a write-path if, not a DB constraint), so a re-assess is rejected:
# routes.py — op 2: assess — blast -> freeze floor -> CAB gate -> verdict + advance the change
@router.post("/assess")
def assess(q: AssessIn, user=Depends(require_permission("itsm:change:approve")), s: Session = Depends(db)):
change = s.get(Change, q.change_id)
if not change:
raise HTTPException(404, "no such change")
# GUARD: a change is assessable only from its ENTRY states. This set is part of the API —
# every producer of a change (including itsm/problem-management's fix-change) must land in one.
if change.state not in ASSESSABLE_STATES: # ASSESSABLE_STATES = {"assess", None}
raise HTTPException(409, f"change is frozen in state '{change.state}' — not assessable")
ci = s.get(Ci, change.ci_id)
auto_approve_risk, _ = _load_policy(s, q.policy_id)
blast, services = _blast_radius(s, change.ci_id)
freeze = bool(s.execute(text(
f"SELECT coalesce(bool_or(freeze_conflict), false) FROM change_calendar "
f"WHERE change_id = {int(q.change_id)}")).scalar())
bundle = {"number": change.number, "short_description": change.short_description,
"type": change.type, "environment": ci.environment, "ci_name": ci.name}
if freeze: # deterministic floor overrides the model
verdict = {"decision": "cab_review", "risk": "high", "reason": "freeze-window conflict"}
elif not blast and change.type == "standard" and auto_approve_risk != "none":
verdict = {"decision": "auto_approve", "risk": "low", "reason": "empty blast radius, low-risk"}
elif CAB_GATE:
verdict = _cab_gate(_models_token(), bundle, blast, services, freeze)
else:
verdict = {"decision": "cab_review", "risk": "high" if blast else "low", "reason": "risk-only"}
approval_state = "approved" if verdict["decision"] == "auto_approve" else "pending"
upsert(s, ChangeApproval, [{
"approval_id": f"ap-{q.change_id}", "change_id": q.change_id,
"approver_group": "auto" if approval_state == "approved" else "cab",
"decision": verdict["decision"], "risk": verdict["risk"],
"blast_radius_json": json.dumps(blast), "impacted_service_count": len(services),
"freeze_conflict": freeze, "gate_verdict": json.dumps(verdict),
"rationale": verdict.get("reason", ""), "decided_at": _now()}], key="approval_id")
# advance changes.approval_state — read the row, merge, upsert the FULL row (a bare re-INSERT is 23505)
change_row = {c.name: getattr(change, c.name) for c in Change.__table__.columns}
change_row["approval_state"] = approval_state
if approval_state == "approved":
change_row["state"] = "scheduled"
upsert(s, Change, [change_row], key="id")
s.commit() # commit before any read-back
return {"change_id": q.change_id, **verdict, "blast_radius": blast,
"impacted_service_count": len(services), "freeze_conflict": freeze,
"approval_state": approval_state, "written": True}The CAB gate mirrors the CLI step: max_tokens: 4096, the response unwrapped from its data envelope, and
retry until non-empty (kimi-k2.6 is a reasoning model — retry-once is not enough). Live-verified this
build against api.dodil.io/v1: POST /assess {"change_id": 5002} → auto_approve / low (the deterministic
floor, approval_state → approved, no Models call at all); {"change_id": 5001} → the gate returned
{"decision":"cab_review","risk":"high","reason":"Prod change with non-empty blast radius spanning 9 CIs and 4 business services."} (pending); {"change_id": 5003} → cab_review / high on a 4-CI blast into Online
Checkout; {"change_id": 5004} → cab_review / high from the freeze floor, overriding the model.
Workflow op 3 — the PIR gate (a write-path guard). require_pir blocks a change from reaching closed
without its post-implementation review — again a guard on the write path, not a DB constraint, so every
state change routes through POST /changes/{change_id}/transition:
# routes.py — op 3: require_pir gates the close transition (write-path guard)
@router.post("/changes/{change_id}/transition")
def transition(change_id: int, t: TransitionIn,
user=Depends(require_permission("itsm:change:approve")), s: Session = Depends(db)):
change = s.get(Change, change_id)
_, require_pir = _load_policy(s, "default")
if t.to_state == "closed" and require_pir and change.state != "review":
raise HTTPException(409, "require_pir: a change must pass through 'review' (PIR) before it can close")
...Live-verified on the joint run: the PIR guard rejected → closed from scheduled with that 409, and
allowed it from review. Note the shape of the control — like the closed-period lock in the GL, it is a
guard on the write path, not a database constraint, so it holds only as long as every state change goes
through this route. A direct data table upsert into changes bypasses it. That is not a weakness to hide;
it is a scope you state: this route is the sole writer of changes.state, or the PIR requirement is a
convention rather than a control.
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(…) top-level SELECT + IN(…), typed-by-rel via a recursive CTE (see EXTENDING.md).
Auth — config at the edge, a role gate in the app
On Ignite, end-user login is configuration, not code. This app 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 _depThe 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. (Earlier drafts of
this post used a bare changes:approve; that style collides the moment a customer runs two modules on one
pool.) An audit across all seven ITSM components left exactly four gates standing:
| Permission | Gates | Why this one and not the rest |
|---|---|---|
itsm:change:approve | change management: POST /assess, POST /changes/{id}/transition, POST /policies | accepting the risk of a production change. change_approvals.decided_by records the gateway-vouched user |
itsm:major:declare | major incident: POST /declare | declaring a major incident pages the org. bridges.declared_by records who |
itsm:incident:resolve | major incident: POST /bridges/{id}/state | the only route in the module that writes incidents.state='resolved' — what stops the SLA clock |
itsm:cmdb:rebuild | CMDB blast radius: POST /graph/assemble | it TRUNCATEs impacts + service_map and DROP+CREATEs both graphs; every other component's risk number is downstream of what it leaves behind |
(Deployed in the suite app each router sits under its own prefix, so those become
POST /change-management/assess, POST /major-incident/declare, and so on. The standalone package serves
them at the bare paths above.)
All three of change management's gated routes are this component's, and they are the module's headline
permission. The argument for each is the same: they are the points where a human accepts the risk of a
production change. POST /assess records a verdict the board is answerable for; POST /changes/{id}/transition moves a change through implement, review and close; POST /policies is gated for
a subtler reason — editing the policy is editing the gate. A user who can lower auto_approve_risk to
low_medium has approved every medium-risk change in the estate without adjudicating one, so the policy
route must cost the same permission as passing the gate it configures.
That decided_by column is worth pausing on: it is only meaningful because the gateway verified the
identity the app merely reads. If the app were trusting a self-asserted header, the audit trail would be
a list of claims, not a record of who approved what.
Everything else here takes Depends(current_user) and nothing more — GET /worklist, GET /changes/{id}/blast-radius, GET /changes/{id}/approval, GET /policies/{id} and POST /calendar. Reads
move no risk, and booking a maintenance window is scheduling, not approving: the slot's freeze_conflict is
computed deterministically as it lands, and the change still has to clear /assess before it goes anywhere.
Four of seven components have zero gates, on purpose
itsm-core, itsm-incident-management, itsm-problem-management and itsm-sla-management carry no
permission gate at all. CMDB CRUD, triage and clustering are the service desk's ordinary work — a
permission every agent on the desk must hold is ceremony, and ceremony is exactly what an auditor discounts.
The audit deleted three gates that earlier drafts described: incidents:triage, problems:write, and
cmdb:write on an idempotent per-CI precompute. It added one the roadmap had not predicted —
itsm:cmdb:rebuild — because the route it guards is the most destructive operation in the module.
The rule that fell out: gate a route when it accepts risk, pages people, or destroys state. Everything else is the job.
The pool, created once
Create a dodil-appid pool itsm-suite with email+password, and set its role catalog: the service desk agent role holds no special permissions; a change manager may approve changes; an incident commander may declare a major incident and resolve one; a CMDB admin may rebuild the impact graph.
Pool itsm-suite created — issuer https://appid.dodil.io/ihdiash/itsm-suite, audience pool:itsm-suite, email+password (local) enabled. Catalog set: agent = (none); change-manager = itsm:change:approve; incident-commander = itsm:major:declare, itsm:incident:resolve; cmdb-admin = itsm:cmdb:rebuild. A user's next token carries app_roles plus the catalog-expanded permissions claim the gates read.
dodil appid pool create itsm-suite --with-local
# issuer: https://appid.dodil.io/ihdiash/itsm-suite audience: pool:itsm-suite
dodil appid roles set itsm-suite \
agent= \
change-manager=itsm:change:approve \
incident-commander=itsm:major:declare,itsm:incident:resolve \
cmdb-admin=itsm:cmdb:rebuildagent — the service desk, and the largest role in any ITSM deployment — holds no permissions. It does
the ungated work, which is most of the module.
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. Full flows: App authentication and App
roles.
The route with no identity at all
POST /sla/tick— the SLA clock initsm/sla-management— carries no identity dependency at all: not a permission, not evenDepends(current_user). It is a machine heartbeat, called by a service account over a platform invoke, which carries noX-Dodil-User. Acurrent_userdependency there would401the 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.
Get the code
The runnable package is code/itsm-change-management/v1 — the exact
models.py (the ORM tabs above) and routes.py (the routes above), plus the platform files that are
byte-identical across the whole suite:
models.py # SQLAlchemy — change_approvals, change_calendar, change_policy (+ the masters it reads)
routes.py # FastAPI — policy/calendar CRUD + blast radius + assess + transition + worklist
db.py # lazy engine + the ON CONFLICT upsert helper every route uses
sa_token.py # mints/refreshes the service-account client_credentials token (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 (+ the SA creds the CAB gate needs)
requirements.txt # sqlalchemy, psycopg[binary], fastapi, uvicorn, pydantic, httpx — no pyjwt
README.md · EXTENDING.md
PLATFORM.md # the platform invariants, shipped WITH the code
Two of those are worth calling out. sa_token.py resolves the pg-wire password lazily, per connection —
nothing reads a token at import, so app.openapi() builds with no credentials at all and CI can generate a
client from a checkout. And PLATFORM.md ships the invariants inside the tarball, so whoever downloads
the package gets the rules along with the code: every line in it is a scar from a real failure on this
platform, and none of them are things you would guess.
Point it at any bucket by editing the .env defaults — nothing is hard-coded:
# fetch + run the API against your bucket
curl -L https://blog.dodil.io/code/itsm-change-management/v1.tar | tar x
cd itsm-change-management/v1
python -m venv .venv && source .venv/bin/activate && pip install -r requirements.txt
cp .env.example .env # set DODIL_TOKEN; BUCKET defaults to "itsm"
python -c "import db, models; models.Base.metadata.create_all(db.engine)" # create the tables from the models
export DEV_ALLOW_ANON=1 # LOCAL ONLY — no gateway in front of uvicorn, so stub the identity
export DEV_USER_PERMISSIONS=itsm:change:approve # the gated routes stay gated on a laptop too
uvicorn routes:app --reload
# POST /policies, /calendar, /assess · GET /changes/{id}/blast-radius, /worklistNote the two DEV_ lines: without them every route 401s locally, because auth.py expects an identity
the gateway is not there to inject — and the stub deliberately carries no permissions until you grant
them, so you exercise the same gate the deployment does.
The CAB gate (POST /assess) needs DODIL_SERVICE_ACCOUNT_ID + DODIL_SERVICE_ACCOUNT_SECRET (an SA
granted ignite.model-user); set CAB_GATE=false to run deterministic blast/freeze risk only, no
token-billed calls. EXTENDING.md documents adding a workflow (one *In schema + one route) and the graph
rules. Deploy is the Ship it section below — the same code, an image-mode Ignite app.
Customize — the decisions this skill asks you
Q1 · auto_approve_risk — what may self-approve?
"What may self-approve without a CAB — nothing, low-risk only, or low+medium?"
- none → every change routes to CAB. Maximum control, slowest throughput.
- low (default) → low-risk changes with an empty blast + no freeze self-approve; everything else → CAB.
Auditable, ServiceNow-classic. Written to
change_policyand the handler constant and the gate prompt (single source). - low_medium → low and medium self-approve — looser, faster, less defensible.
Q2 · freeze_windows — the change-freeze calendar
"Any change-freeze calendar (date ranges that block deploys)?" → Each range writes into
change_policy.freeze_windows_json. A change whose window overlaps →freeze_conflict=true→ routed to CAB regardless of risk (deterministic overlap, not model judgement). Default[](no freeze).
Q3 · cab_gate — model adjudication or deterministic risk-only?
"Adjudicate with the kimi-k2.6 CAB gate, or deterministic risk-only?"
- true (default) → deploys
itsm-change-advisorwithignite.model-user; the gate returns{decision, risk, reason}and narrates the edge cases the deterministic floor can't. - false → deterministic blast/freeze risk only; the handler needs just
k3.editor+ignite.app-developer(noignite.model-user, no token-billed calls).
Q4 · require_pir — mandatory post-implementation review?
"Require a post-implementation review before a change can close?" → true (default) gates
implement → review → closed— a change can't reachclosedwithout its PIR record. false allowsimplement → closeddirectly.
TIP
Industry overlays. This is the cross-industry base (industry: software). The finserv overlay
layers a deterministic compliance hard gate over the CAB verdict (a change to a regulated=true CI in
a freeze → compliance_hold regardless of the model's decision) plus a change_audit trail; the
manufacturing overlay respects maintenance_windows for OT CIs. Each is a small additive diff, not a
fork.
Test
Every command below ran live against DataK3; real results are inline. The pipeline was re-validated
end-to-end on 2026-09-08 on bucket itsm (org IHDIASH) — and this time not alone: all seven ITSM
components were stood up together on that one bucket, which is how the state-machine deadlock above was
found. Validated here: the blast-radius cypher() read (pg-orders-primary → 10 CIs / 4 impacted services,
max hop 3), the typed recursive-CTE narrowing (10 → 9, the part_of BFF dropped), the deterministic freeze
overlap (only CHG5004), the live kimi-k2.6 CAB gate (CHG5001 → cab_review/high, CHG5003 →
cab_review/high, CHG5002 → auto_approve/low on the floor with no Models call), the ON CONFLICT
writeback with decided_by carrying the gateway-vouched identity, the PIR guard (rejected → closed from
scheduled, allowed from review), and idempotency (re-assess → one verdict row per change; a bare
re-INSERT of a committed PK raised 23505).
export BUCKET=itsm
# 1) risk = blast radius: CHG5001's blast on pg-orders-primary, hop-ranked — expect 10 CIs, max hop 3
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"
# hop1 accounts-api, checkout-api, orders-api, pg-orders-replica, reporting-etl
# hop2 payments-gateway, search-index, web-storefront · hop3 cdn-edge, mobile-app-bff
# 2) typed narrowing: depends_on+runs_on drops the part_of BFF — expect 9 (not 10)
dodil data pg -b "$BUCKET" \
"WITH RECURSIVE blast(id) AS (
SELECT src FROM ci_edges WHERE dst = 1 AND rel IN ('depends_on','runs_on')
UNION SELECT e.src FROM ci_edges e JOIN blast b ON e.dst = b.id
WHERE e.rel IN ('depends_on','runs_on'))
SELECT count(*) FROM blast"
# 3) impacted-service rollup — expect 4 services: Online Checkout 5, Order Management 3,
# Customer Accounts 1, Internal Reporting 1
dodil data pg -b "$BUCKET" \
"SELECT s.business_service, count(DISTINCT c.id) FROM graph_khop('cmdb_impact', 1, 5) k
JOIN cis c ON c.id = k.node JOIN services s ON s.service_id = c.service_id
GROUP BY s.business_service"
# 4) empty blast for the leaf change (CHG5002, reporting-etl) — expect 0
dodil data pg -b "$BUCKET" "SELECT count(*) FROM graph_khop('cmdb_impact', 9, 5)" # = 0
# 5) freeze overlap is deterministic — expect only CHG5004 true (Black Friday)
dodil data pg -b "$BUCKET" \
"SELECT change_id, (window_start::timestamp < TIMESTAMP '2026-12-02 00:00:00'
AND window_end::timestamp > TIMESTAMP '2026-11-20 00:00:00') AS conflict
FROM change_calendar ORDER BY change_id"
# 6) the verdicts landed, one per change (idempotent), each attributed to a real user
dodil data pg -b "$BUCKET" \
"SELECT (SELECT count(*) FROM change_approvals) AS approvals,
(SELECT count(DISTINCT approval_id) FROM change_approvals) AS distinct_approvals,
(SELECT count(*) FROM change_approvals WHERE decided_by IS NOT NULL) AS attributed,
(SELECT count(*) FROM change_policy) AS policy"
# approvals = distinct_approvals = attributed (one verdict per change, every one attributed)The live CAB gate returned {"decision":"cab_review","risk":"high","reason":"Prod change with non-empty blast radius spanning 9 CIs and 4 business services."} for the database patch and
{"decision":"auto_approve","risk":"low","reason":"empty blast radius, low-risk standard change"} for the
certificate rotation — valid JSON, keyed on the blast radius, both branches proven. CHG5004 never reached
the model at all: the freeze floor took it first.
One-shot
With the DODIL MCP connected, paste this to scaffold change management at once:
Scaffold ITSM change management on DataK3 (one bucket = SQL + graph + Models). Confirm each step.
1. In bucket `itsm`, create merge-keyed change_approvals (key approval_id, incl. decided_by), change_calendar
(key slot_id), change_policy (key policy_id); optional columns nullable. If itsm/core +
itsm/cmdb-blast-radius are NOT installed, stub cis/ci_edges/changes/services, seed the 13-CI e-commerce
estate (14 typed edges depends_on/runs_on/part_of; mobile-app-bff part_of web-storefront; 4 business
services), and build impacts (reverse of the impact-bearing rels) + CREATE GRAPH cmdb_impact.
2. Risk = blast radius: graph_khop('cmdb_impact', $CHANGE_CI_ID, 5) JOIN cis, hop-ranked. Type it — filter
to impact_rels (depends_on, runs_on) so part_of composition doesn't over-count (pg-orders-primary blast
= 10 with all rels, 9 typed). Roll the blast up to impacted business services via services.
3. Write change_policy (auto_approve_risk=low, require_pir=true, freeze windows Black Friday
2026-11-20..2026-12-02 and Year end 2026-12-18..2027-01-05). Schedule change_calendar windows; flag
freeze_conflict where a window overlaps ANY freeze range.
4. CAB gate on kimi-k2.6: given the change + blast radius + environment + freeze status, return ONLY compact
JSON {decision (auto_approve|cab_review|reject), risk (low|medium|high), reason}. Deterministic floor
first (low+empty blast+no freeze -> auto_approve; freeze conflict -> cab_review regardless); retry until
non-empty. Write change_approvals (stamping decided_by from the gateway-injected identity) + advance
changes.approval_state.
5. Gate POST /assess, POST /changes/{id}/transition and POST /policies on itsm:change:approve; everything
else takes a signed-in user only. Do NOT gate any recompute/heartbeat route.
6. Verify: the pg-orders-primary patch -> cab_review/high with a 10-CI blast across 4 services; the
reporting-etl cert rotation -> auto_approve/low (empty blast); the change scheduled inside Black Friday
-> cab_review on the freeze floor regardless of what the model says.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): thechanges,change_approvals,change_calendar,change_policytables. - Graph over Bolt — a Neo4j driver or
cypher-shellagainstcmdb_impactfor blast-radius traversal.
Print the drop-in pg / bolt / grpc endpoints for the itsm bucket so I can point psql and cypher-shell at it.
data_connectpg postgresql://token:…@pg.uk-lon-1.dodil.io:5432/itsm · bolt bolt+s://bolt.uk-lon-1.dodil.io:7687 (database=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://… URLFull, live-validated walkthrough: Connect your tools.
Ship it — the itsm-change-advisor engine
Steps 2–5 run the pipeline query-by-query. Ship it as an image-mode Ignite app so a change submission
hits a real URL: on POST /assess {change_id} it computes the blast radius (graph), detects freeze
conflicts, calls the CAB gate, and writes the verdict. It's a separate workload, so it gets its own
service account with three least-privilege roles: k3.editor (write the verdict over pg-wire),
ignite.model-user (call kimi-k2.6), and ignite.app-developer (the deploy/invoke identity). With
cab_gate=false it drops ignite.model-user.
Create a service account for itsm-change-advisor, grant it k3.editor, ignite.model-user, and ignite.app-developer, then deploy my ./change-advisor app to Ignite in image mode (build the Dockerfile on deploy) with the SA creds as runtime env. Use its cli- serviceAccountId as DODIL_SERVICE_ACCOUNT_ID.
auth_service-account_create→auth_service-account_grant-role→ignite_app_deployCreated itsm-change-advisor-sa; granted k3-authorization-service k3.editor + ignite-authorization-service ignite.model-user + ignite.app-developer; deployed itsm-change-advisor in image mode serving POST /assess, health /healthz. Runtime DODIL_SERVICE_ACCOUNT_ID is the cli- serviceAccountId (not the uuid).
dodil auth service-account create itsm-change-advisor-sa
# create prints an internal `id` (uuid) AND a `serviceAccountId` (cli-…). The client_credentials
# client_id is the cli- serviceAccountId — the uuid fails invalid_client.
SA_UUID=$(dodil auth service-account list -o json | python3 -c "import sys,json;print([s['id'] for s in json.load(sys.stdin) if s['name']=='itsm-change-advisor-sa'][0])")
SA_ID=$(dodil auth service-account list -o json | python3 -c "import sys,json;print([s['serviceAccountId'] for s in json.load(sys.stdin) if s['name']=='itsm-change-advisor-sa'][0])")
dodil auth service-account grant-role "$SA_UUID" k3-authorization-service k3.editor
dodil auth service-account grant-role "$SA_UUID" ignite-authorization-service ignite.model-user
dodil auth service-account grant-role "$SA_UUID" ignite-authorization-service ignite.app-developer
# IMAGE mode: platform Kaniko build-on-deploy (Dockerfile), NOT --runtime python (compile mode).
dodil ignite app deploy itsm-change-advisor \
--code ./change-advisor --dockerfile-path Dockerfile \
--port 8080 --health-path /healthz --allow-unauthenticated \
--env BUCKET="$BUCKET" --env AUTO_APPROVE_RISK=low --env CAB_GATE=true --env REQUIRE_PIR=true \
--env DODIL_SERVICE_ACCOUNT_ID="$SA_ID" --env DODIL_SERVICE_ACCOUNT_SECRET="$SA_SECRET"
# public FQDN: itsm-change-advisor-<org>-8080.ignite.dodil.cloud ; POST {"change_id":5002} to /assess.The handler — a plain HTTP server, one pg-wire connection for SQL and graph_khop(), one raw
api.dodil.io/v1 call for the gate. ./change-advisor/server.py:
# change-advisor/server.py — IMAGE-mode Ignite app (HTTP server on $PORT).
# GET /healthz -> 200 {"status":"ready"} (probe; no auth)
# POST /assess -> body {"change_id": <id>} (blast radius -> freeze check -> CAB gate -> verdict)
# Only psycopg is third-party; everything else is stdlib.
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from psycopg import errors as pg_errors
BUCKET = os.environ["BUCKET"]
SA_ID = os.environ["DODIL_SERVICE_ACCOUNT_ID"] # the cli-… serviceAccountId, NOT the uuid
SA_SECRET = os.environ["DODIL_SERVICE_ACCOUNT_SECRET"]
AUTO_APPROVE_RISK = os.environ.get("AUTO_APPROVE_RISK", "low") # mirrors change_policy (single source)
CAB_GATE = os.environ.get("CAB_GATE", "true") == "true"
CHAT_MODEL = os.environ.get("CHAT_MODEL", "kimi-k2.6")
PG_HOST, PG_PORT = os.environ.get("PG_HOST", "pg.uk-lon-1.dodil.io"), int(os.environ.get("PG_PORT", "5432"))
MAX_HOPS = int(os.environ.get("MAX_HOPS", "5"))
ID_URL = "https://id.dodil.io/realms/dodil/protocol/openid-connect/token"
CHAT_URL = "https://api.dodil.io/v1/chat/completions"
UA = "itsm-change-advisor/1.0" # explicit UA — stdlib urllib's default is Cloudflare-banned (403 1010)
CAB_SYS = ('You are a Change Advisory Board (CAB) adjudicator. Policy: auto_approve_risk=%s (only a low-risk '
'change with an EMPTY blast radius and no freeze conflict may self-approve); a prod change with a '
'non-empty blast radius requires CAB; any freeze conflict routes to CAB regardless of risk. Return '
'ONLY compact JSON: {"decision": one of [auto_approve, cab_review, reject], "risk": one of '
'[low, medium, high], "reason": short}. No prose, no reasoning, no preamble.' % AUTO_APPROVE_RISK)
def _now():
return datetime.now(timezone.utc).isoformat()
def _http_post(url, data, headers, form=False):
headers = {"User-Agent": UA, **headers}
if form:
body = urllib.parse.urlencode(data).encode(); headers["Content-Type"] = "application/x-www-form-urlencoded"
else:
body = json.dumps(data).encode(); headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
return json.loads(r.read().decode())
def _token():
out = _http_post(ID_URL, {"grant_type": "client_credentials", "client_id": SA_ID,
"client_secret": SA_SECRET}, headers={}, form=True)
return out["access_token"]
def _pg(token):
return psycopg.connect(host=PG_HOST, port=PG_PORT, dbname=BUCKET,
user="token", password=token, sslmode="require",
connect_timeout=20, autocommit=False)
def _extract_json(text):
if not text:
raise ValueError("empty model content")
text = text.strip()
if text.startswith("```"):
text = re.sub(r"^```[a-zA-Z]*\n?", "", text); text = re.sub(r"\n?```$", "", text).strip()
m = re.search(r"\{.*\}", text, re.DOTALL)
return json.loads(m.group(0) if m else text)
def _cab_gate(token, change, blast, services, freeze):
# kimi-k2.6 is a reasoning model: set max_tokens high (4096) AND retry until non-empty content.
user = (f'Change {change["number"]}: "{change["short_description"]}". Type: {change["type"]}. '
f'Environment: {change["environment"]}. Affected CI: {change["ci_name"]}. '
f'CMDB blast radius ({len(blast)} CIs): {", ".join(blast) or "EMPTY"}. '
f'Impacted business services: {", ".join(services) or "none"}. '
f'Freeze conflict: {"yes" if freeze else "none"}. Return ONLY compact JSON, no reasoning/preamble.')
for _ in range(5):
out = _http_post(CHAT_URL, {"model": CHAT_MODEL, "max_tokens": 4096,
"messages": [{"role": "system", "content": CAB_SYS},
{"role": "user", "content": user}]},
headers={"Authorization": f"Bearer {token}"})
env = out.get("data", out) # response is wrapped in "data" on this platform
content = env["choices"][0]["message"]["content"]
if content and content.strip():
return _extract_json(content)
raise ValueError("kimi-k2.6 returned empty content after retries")
def _retry(fn):
for attempt in range(4):
try:
return fn()
except (pg_errors.SerializationFailure, pg_errors.DeadlockDetected):
if attempt == 3:
raise
time.sleep(0.4 * (attempt + 1))
def assess(change_id):
token = _token()
with _pg(token) as conn, conn.cursor() as cur:
cur.execute("""SELECT ch.number, ch.short_description, ch.type, ch.ci_id,
c.name, c.environment
FROM changes ch JOIN cis c ON c.id = ch.ci_id WHERE ch.id=%s""", (change_id,))
number, sd, ctype, ci_id, ci_name, env = cur.fetchone()
change = {"number": number, "short_description": sd, "type": ctype,
"ci_name": ci_name, "environment": env}
# GRAPH — the blast radius (typed cmdb_impact) IS the risk
cur.execute("""SELECT c.name FROM graph_khop('cmdb_impact', %s, %s) k
JOIN cis c ON c.id = k.node ORDER BY k.hop_distance""", (ci_id, MAX_HOPS))
blast = [r[0] for r in cur.fetchall()]
# impacted business services + the count that feeds risk
cur.execute("""SELECT DISTINCT s.business_service FROM graph_khop('cmdb_impact', %s, %s) k
JOIN cis c ON c.id = k.node JOIN services s ON s.service_id = c.service_id""",
(ci_id, MAX_HOPS))
services = [r[0] for r in cur.fetchall()]
# deterministic freeze floor — a scheduled window overlapping a policy freeze window
cur.execute("""SELECT coalesce(bool_or(cal.freeze_conflict), false)
FROM change_calendar cal WHERE cal.change_id=%s""", (change_id,))
freeze = bool(cur.fetchone()[0])
# deterministic decision, then the CAB gate for judgement
if freeze:
verdict = {"decision": "cab_review", "risk": "high",
"reason": "freeze-window conflict (deterministic floor)"}
elif not blast and change["type"] in ("standard",) and AUTO_APPROVE_RISK != "none":
verdict = {"decision": "auto_approve", "risk": "low",
"reason": "empty blast radius, low risk"}
elif CAB_GATE:
verdict = _cab_gate(token, change, blast, services, freeze)
else:
verdict = {"decision": "cab_review", "risk": "high" if blast else "low",
"reason": "non-empty blast radius" if blast else "risk-only"}
# WRITE BACK — one verdict row per change, advance approval_state
approval_state = "approved" if verdict["decision"] == "auto_approve" else "pending"
row = {"approval_id": f"ap-{change_id}", "change_id": change_id,
"approver_group": "auto" if approval_state == "approved" else "cab",
"decision": verdict["decision"], "risk": verdict["risk"],
"blast_radius_json": json.dumps(blast), "impacted_service_count": len(services),
"freeze_conflict": freeze, "gate_verdict": json.dumps(verdict),
"rationale": verdict.get("reason", ""), "decided_at": _now()}
def _w():
with _pg(token) as c2, c2.cursor() as cur2:
cols = list(row)
# approval_id (ap-<change_id>) is stable per change, and the changes row already exists —
# re-assessing re-writes both PKs, so both are ON CONFLICT upserts. A bare re-INSERT of a
# committed PK raises duplicate-key 23505; DuckDB pg-wire supports ON CONFLICT (verified).
setc = ", ".join(f"{c}=EXCLUDED.{c}" for c in cols if c != "approval_id")
cur2.execute(f"INSERT INTO change_approvals ({','.join(cols)}) "
f"VALUES ({','.join(['%s']*len(cols))}) "
f"ON CONFLICT (approval_id) DO UPDATE SET {setc}", [row[c] for c in cols])
cur2.execute("INSERT INTO changes (id, approval_state) VALUES (%s,%s) "
"ON CONFLICT (id) DO UPDATE SET approval_state=EXCLUDED.approval_state",
(change_id, approval_state))
c2.commit()
_retry(_w)
return {"change_id": change_id, **verdict, "blast_radius": blast,
"impacted_services": services, "freeze_conflict": freeze}
class Handler(BaseHTTPRequestHandler):
def _send(self, code, body):
payload = json.dumps(body).encode()
self.send_response(code); self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload))); self.end_headers()
self.wfile.write(payload)
def do_GET(self):
return self._send(200, {"status": "ready"}) if self.path == "/healthz" \
else self._send(404, {"error": "no_route"})
def do_POST(self):
if self.path != "/assess":
return self._send(404, {"error": "no_route"})
try:
n = int(self.headers.get("Content-Length") or 0)
body = json.loads(self.rfile.read(n) or b"{}")
if "change_id" not in body:
return self._send(400, {"error": "missing change_id"})
return self._send(200, assess(int(body["change_id"])))
except Exception as e:
return self._send(500, {"error": type(e).__name__, "detail": str(e)[:600]})
def log_message(self, *a):
pass
if __name__ == "__main__":
port = int(os.environ.get("PORT", "8080"))
print(f"itsm-change-advisor on 0.0.0.0:{port} bucket={BUCKET} cab_gate={CAB_GATE}", flush=True)
ThreadingHTTPServer(("0.0.0.0", port), Handler).serve_forever()The image is a two-file build alongside server.py:
# change-advisor/Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY server.py .
ENV PORT=8080
EXPOSE 8080
CMD ["python", "server.py"]# change-advisor/requirements.txt
psycopg[binary]==3.2.3NOTE
Deploy: image mode (Lane B), validated live 2026-09-02. itsm-change-advisor ships in image mode — a
Dockerfile + --dockerfile-path, Kaniko build-on-deploy (not --runtime python, whose compiler pod is
broken). This deploy/serve/durable-write pattern was validated live 2026-09-02 on the sibling CRM engines
(deploys, serves /healthz + its route unauthenticated, writes durably); the advisor reuses it. If deploy
returns IAM resource registration failed: broken pipe, deploy under a fresh app name — the
half-created app can't be updated or deleted.
The scheduler question — a deployment has to answer it
Change assessment is event-driven, so this component gets off lightly: you invoke /assess from your
change-submit hook and the verdict lands. But the ITSM suite as a whole does not get off lightly, and
it is worth knowing before you design a deployment: Ignite is request-invoked, and there is no
server-side scheduler. An SLA clock that only advances when somebody loads a page is not an SLA clock.
There are exactly two real answers, and a deployment must pick one and say which:
- (a) an always-on pinned app — deploy with
--reserved 1 --max-replicas 1and run a loop inside the pod that calls the recompute on an interval. Self-contained, and the clock is part of the system; the cost is a warm replica that never scales to zero. - (b) an external scheduler — cron, a CI timer, any orchestrator POSTing the route. The app stays scale-to-zero, but the clock now depends on infrastructure outside DODIL, and that dependency is invisible from inside the product.
Either way the caller is a service account over a platform invoke, not a browser user — which is
precisely why a recompute route must not sit behind current_user. The full treatment, and the SLA clock
it actually bites, is in SLA management.
The full supply chain — DODIL git → CI checks → a scanned registry image → versioning and rollback — is walked end to end 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-change-management 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
the configuration in which the state-machine deadlock above is visible: change management and problem
management sharing one changes table is not an integration risk you take on, it is the thing that let the
bug be found before a customer met it.
Conclusion
You now have change management on one DataK3 bucket where the risk is not a dropdown but a graph
traversal — the CMDB blast radius of the CI the change touches, typed so composition edges don't
over-count, rolled up to the impacted business services, adjudicated by a kimi-k2.6 CAB gate, and
floored by a deterministic freeze-window check that overrides the model. The change ticket, the impact
graph, the calendar, and the verdict are the same live rows under two pillars — no Neo4j CMDB, no
Postgres ticket DB, no nightly sync between them.
The reusable skill: model risk as a typed reverse traversal over the dependency graph, let a model add
judgement on top of a deterministic floor, and keep the policy as data so the gate prompt and the
handler read one source. This is the graph's flagship consumer in the itsm suite — compose it onto
itsm/core and itsm/cmdb-blast-radius, or run it
standalone with the Step 1 stub.