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.

PieceLands inPillar
Change tickets (consumed from itsm/core)changes (merge-keyed)SQL
CMDB blast radius (consumed from itsm/cmdb-blast-radius)graph cmdb_impact over cis / impactsGraph
CAB verdict + change calendar + policychange_approvals / change_calendar / change_policySQL
The change advisoritsm-change-advisor — blast → gate → verdictIgnite + 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 dodil CLI authenticated (dodil auth login) or the DODIL MCP connected to your agent (Claude Code, Cursor, VS Code, Codex). Headless? Check auth_status first — an agent can't do the browser login for you. 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 (the bucket param, default itsm).

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.

You

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.

DODIL MCP tools called
data_table_create
Agent

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

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.

You

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

DODIL MCP tools called
data_table_createdata_table_upsertdata_pg
Agent

Stubbed cis (13 rows), ci_edges (14 typed edges), services (4), changes (4). mobile-app-bff is part_of web-storefront (composition, not dependency).

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

You

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.

DODIL MCP tools called
data_pg
Agent

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

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

You

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.

DODIL MCP tools called
data_bolt
Agent

Returns nodes 3, 4, 8, 9, 11 (hop 1), 5, 6, 12 (hop 2), 10, 13 (hop 3) — the same impacted set.

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:

You

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.

DODIL MCP tools called
data_pg
Agent

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

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. Set impact_rels to 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:

You

For CHG5001, roll the blast radius up to impacted business services — join the blast set to services and group by business_service.

DODIL MCP tools called
data_pg
Agent

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

 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:

You

What is the blast radius of CHG5002, the certificate rotation on reporting-etl (CI 9)? Count the impacted CIs.

DODIL MCP tools called
data_pg
Agent

0 — nothing depends on reporting-etl. Empty blast → eligible for auto-approve.

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:

You

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.

DODIL MCP tools called
data_table_upsert
Agent

change_policy 'default' written — low self-approves, medium+ needs CAB, PIR required, two freeze windows (Black Friday, Year end) active.

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:

You

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.

DODIL MCP tools called
data_table_upsertdata_pg
Agent

slots written. Freeze overlap: only CHG5004 (2026-11-25) falls inside Black Friday → freeze_conflict=true. 5001 and 5003 are clear.

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:

You

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

DODIL MCP tools called
ignite_models_chat
Agent

{"decision":"cab_review","risk":"high","reason":"Prod change with non-empty blast radius spanning 9 CIs and 4 business services."}

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.

You

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

DODIL MCP tools called
ignite_models_chat
Agent

{"decision":"auto_approve","risk":"low","reason":"empty blast radius, low-risk standard change"}

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.

You

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.

DODIL MCP tools called
data_table_upsert
Agent

4 change_approvals rows; changes advanced. CHG5004 routed to CAB by the freeze floor regardless of what the gate would have said.

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:

You

Show the CAB worklist: join change_approvals to changes and list change number, description, decision, risk, impacted_service_count, freeze_conflict, approval_state.

DODIL MCP tools called
data_pg
Agent

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

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 409 that 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:

JobThe usual stackOn DataK3
Change tickets + approval statePostgres (ITSM ticket DB)SQL — changes, change_approvals (merge-keyed)
Blast radius / impact analysisNeo4j CMDB + a nightly syncGraph — graph_khop('cmdb_impact', …), Cypher over Bolt
Freeze-window / calendar conflictA spreadsheet + human eyeballingSQL overlap — change_calendar vs change_policy.freeze_windows
CAB risk judgementA person reading two screenskimi-k2.6 gate over the blast bundle → {decision, risk, reason}
Automation runtimeWorkflow engine + connectorsIgnite 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_stateapproved, 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 _dep

The gate audit — four permissions across seven components

ITSM permissions are namespaced <module>:<object>:<verb>, so one customer pool can carry every ERP module's roles without collision — itsm:change:approve is not crm:change:approve. (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:

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

You

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.

Agent

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.

agent — 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 in itsm/sla-management — carries no identity dependency at all: not a permission, not even Depends(current_user). It is a machine heartbeat, called by a service account over a platform invoke, which carries no X-Dodil-User. A current_user dependency there would 401 the clock; the engine would stop and every incident's breach flags would silently go stale — the failure mode of an SLA system that reports green. Exposure is the ingress's job (public_invoke=false), not a user permission. This applies to every recompute route in every module — a rollup, a re-materialization, an embedding backfill: if a machine calls it, gate the door, not the caller.

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, /worklist

Note 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_policy and 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-advisor with ignite.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 (no ignite.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 reach closed without its PIR record. false allows implement → closed directly.

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 (CHG5001cab_review/high, CHG5003cab_review/high, CHG5002auto_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): the changes, change_approvals, change_calendar, change_policy tables.
  • Graph over Bolt — a Neo4j driver or cypher-shell against cmdb_impact for blast-radius traversal.
You

Print the drop-in pg / bolt / grpc endpoints for the itsm bucket so I can point psql and cypher-shell at it.

DODIL MCP tools called
data_connect
Agent

pg 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

Full, 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.

You

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.

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

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

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

NOTE

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 1 and 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.