What you'll build: an Ironclad-style contract lifecycle platform on one DataK3 bucket — the
contracts / clauses / obligations / parties tables (SQL) that carry a contract from
draft → review → executed → active, semantic clause search ("find clauses like this unlimited
indemnity") over a VECTOR(2048) column, a party/entity graph that rolls a corporate family into a
single counterparty-concentration number, an Ignite renewal scanner that flags obligations coming
due, and a kimi-k2.6 risk gate that writes risk_level back onto each clause.
The problem — and why it matters
A CLM suite (Ironclad, DocuSign CLM, Icertis) is sold per user seat to a legal/procurement org — list price lands around $40k–$150k+ a year for a mid-size deployment, and the enterprise tiers run into the hundreds of thousands before a single custom workflow. And that price buys a system that is really four systems stitched together: the contract database (Postgres — parties, clauses, dates, lifecycle state), a clause semantic-search index (Pinecone — "have we agreed to language like this before?"), an entity/relationship store (Neo4j — which counterparties are affiliates of which parent group), and a reporting warehouse the whole thing is ETL'd into for the renewal calendar and the risk register. Four engines, four bills, four copies of the same rows, and glue code to keep them agreeing.
The questions a legal team actually needs answered cut across those systems. "What's our total contractual exposure to the Northwind group?" is a graph rollup (Northwind Trading and Northwind US are separate signing entities under one parent) joined to contract values the graph DB doesn't hold. "Which of our live contracts contain uncapped indemnities?" is a semantic search joined to lifecycle state. Every one of them is a join the single-purpose stores can't do without a nightly export.
What collapses onto one bucket: the contract tables, the clause vectors, and the party graph are
the same rows under three query pillars — SQL (DuckDB dialect, Postgres-wire), Vector (pgvector
<=> / data vsearch), and Graph (Cypher/Bolt + graph_*()). No ETL, no second copy, no nightly sync,
one auth context. The payoff for the team: the renewal calendar, the risk register, and the
counterparty-concentration report are all live JOINs over one set of rows — not last night's snapshot
in a separate warehouse.
What you'll build: a one-bucket blog-clm system — the transactional tables (SQL), clause search
(Vector), the party graph (Graph), an Ignite renewal-scanner app, and a kimi-k2.6 risk gate —
queried by content, by meaning, and by relationship over one copy of the rows.
Prerequisites
- The
dodilCLI (dodil auth login) and an agent connected over MCP (Claude Code, Cursor, VS Code, Codex). Every step shows an Ask your agent tab and a CLI tab. export BUCKET=blog-clm— one bucket is the whole system's data plane.
Step 1 — Stand up the contract core (SQL)
Create the bucket, then the merge-keyed transactional tables. A PRIMARY KEY (merge-key) is
required — writes are keyed, so a re-import of a contract or a shard retry upserts idempotently
instead of duplicating a clause.
Create a DataK3 bucket called blog-clm, then merge-keyed tables: contracts, clauses (with a VECTOR(2048) embedding column), obligations, parties.
data_bucket_create→data_table_createCreated bucket blog-clm. Tables contracts, clauses, obligations, parties created, each with PRIMARY KEY (id).
dodil data bucket create "$BUCKET" --description "Contract Lifecycle Management — contracts, clauses, obligations, party graph"
# The lifecycle core: draft → review → executed → active/expiring; auto_renew drives the renewal scanner
dodil data table create contracts -b "$BUCKET" --merge-key id \
--columns-json '[{"name":"id","type":"bigint"},{"name":"title","type":"varchar"},{"name":"counterparty_id","type":"bigint"},{"name":"contract_type","type":"varchar"},{"name":"status","type":"varchar"},{"name":"effective_date","type":"date"},{"name":"expiration_date","type":"date"},{"name":"value","type":"double"},{"name":"auto_renew","type":"boolean"},{"name":"governing_law","type":"varchar"}]'
# Clauses — embedding = VECTOR(2048) for semantic search (Step 2); risk_level filled by the gate (Step 5)
dodil data table create clauses -b "$BUCKET" --merge-key id \
--columns-json '[{"name":"id","type":"bigint"},{"name":"contract_id","type":"bigint"},{"name":"clause_type","type":"varchar"},{"name":"text","type":"varchar"},{"name":"risk_level","type":"varchar"},{"name":"embedding","type":"VECTOR(2048)"}]'
# Obligations — carry a due_date; the Ignite scanner sets alert on the ones coming due (Step 4)
dodil data table create obligations -b "$BUCKET" --merge-key id \
--columns-json '[{"name":"id","type":"bigint"},{"name":"contract_id","type":"bigint"},{"name":"description","type":"varchar"},{"name":"owner","type":"varchar"},{"name":"due_date","type":"date"},{"name":"status","type":"varchar"},{"name":"alert","type":"boolean"}]'
# Parties — every signing entity; projected into the graph node table in Step 3
dodil data table create parties -b "$BUCKET" --merge-key id \
--columns-json '[{"name":"id","type":"bigint"},{"name":"name","type":"varchar"},{"name":"party_type","type":"varchar"},{"name":"jurisdiction","type":"varchar"}]'Load the parties first — six signing entities. Note two of them are affiliates of a third: Northwind Trading Ltd (UK) and Northwind Trading US Inc are both subsidiaries of Northwind Holdings PLC. That corporate structure is what the graph in Step 3 turns into a concentration number.
Upsert 6 parties into blog-clm: Northwind Trading Ltd, Northwind Holdings PLC, Globex Logistics GmbH, Initech Software Inc, Acme Cloud Services LLC, Northwind Trading US Inc.
data_table_upsertUpserted 6 rows (watermark …).
dodil data table upsert parties -b "$BUCKET" \
--row '{"id":1,"name":"Northwind Trading Ltd","party_type":"counterparty","jurisdiction":"England & Wales"}' \
--row '{"id":2,"name":"Northwind Holdings PLC","party_type":"counterparty","jurisdiction":"England & Wales"}' \
--row '{"id":3,"name":"Globex Logistics GmbH","party_type":"counterparty","jurisdiction":"Germany"}' \
--row '{"id":4,"name":"Initech Software Inc","party_type":"counterparty","jurisdiction":"Delaware, USA"}' \
--row '{"id":5,"name":"Acme Cloud Services LLC","party_type":"counterparty","jurisdiction":"California, USA"}' \
--row '{"id":6,"name":"Northwind Trading US Inc","party_type":"counterparty","jurisdiction":"New York, USA"}'Now the contracts — six spanning the lifecycle (draft, review, executed, active), two of them
auto_renew and expiring within 60 days (the ones the scanner must catch). Each counterparty_id
points at a party row.
Upsert 6 contracts into blog-clm across draft/review/executed/active, each pointing at its counterparty_id, two auto_renew and expiring soon.
data_table_upsertUpserted 6 rows (watermark …).
dodil data table upsert contracts -b "$BUCKET" \
--row '{"id":1001,"title":"Master Services Agreement — Northwind Trading","counterparty_id":1,"contract_type":"MSA","status":"active","effective_date":"2024-10-01","expiration_date":"2026-09-30","value":480000.0,"auto_renew":true,"governing_law":"England & Wales"}' \
--row '{"id":1002,"title":"SaaS Subscription — Acme Cloud","counterparty_id":5,"contract_type":"subscription","status":"active","effective_date":"2025-01-15","expiration_date":"2027-01-14","value":120000.0,"auto_renew":true,"governing_law":"California, USA"}' \
--row '{"id":1003,"title":"Mutual NDA — Globex Logistics","counterparty_id":3,"contract_type":"NDA","status":"executed","effective_date":"2026-03-01","expiration_date":"2028-03-01","value":0.0,"auto_renew":false,"governing_law":"Germany"}' \
--row '{"id":1004,"title":"Software License — Initech","counterparty_id":4,"contract_type":"license","status":"review","effective_date":"2026-09-15","expiration_date":"2027-09-14","value":250000.0,"auto_renew":false,"governing_law":"Delaware, USA"}' \
--row '{"id":1005,"title":"Supply Agreement — Northwind US","counterparty_id":6,"contract_type":"supply","status":"draft","effective_date":"2026-10-01","expiration_date":"2028-09-30","value":900000.0,"auto_renew":false,"governing_law":"New York, USA"}' \
--row '{"id":1006,"title":"Data Processing Addendum — Acme Cloud","counterparty_id":5,"contract_type":"DPA","status":"active","effective_date":"2025-01-15","expiration_date":"2026-10-20","value":0.0,"auto_renew":true,"governing_law":"California, USA"}'Finally the clauses — eight extracted paragraphs across the six contracts, a mix of market-standard and
deliberately dangerous language (an uncapped indemnity, an unlimited-liability carve-out, two
auto-renewals). Each carries a VECTOR(2048) embedding of its text, which you produce with the
jina-embeddings-v4 model and write as the [f1,f2,…] literal. Leave risk_level as "" — the gate in
Step 5 fills it. Because embeddings are large, upsert one row per call.
TIP — never send JSON
nullon a keyed upsert. A null can silently drop the row on the next read. Use a sentinel (""forrisk_levelhere) and let a later step fill it, or write the column with a--mergeupsert.
For each of the 8 clauses, embed its text with jina-embeddings-v4, then upsert the row (id, contract_id, clause_type, text, risk_level='', embedding). One row per call.
ignite_models_embed→data_table_upsertUpserted 8 clauses; each embedding is a 2048-dim vector literal (has_vec = true for all).
# One clause: embed its text → a pgvector literal, then upsert (repeat for all 8)
EMB=$(dodil ignite models embed jina-embeddings-v4 \
--input "Supplier shall indemnify, defend and hold harmless the Customer from any and all claims, losses, damages and liabilities of every kind and without limitation arising out of or relating to the provision of the Services." \
--output json | jq -c '.data.data[0].embedding')
dodil data table upsert clauses -b "$BUCKET" \
--row "{\"id\":1,\"contract_id\":1001,\"clause_type\":\"indemnification\",\"text\":\"Supplier shall indemnify … without limitation …\",\"risk_level\":\"\",\"embedding\":$EMB}"
# …repeat for clauses 2–8: limitation_of_liability (capped), auto_renewal (90-day), unlimited-liability,
# auto_renewal (30-day), confidentiality, IP indemnity (capped), GDPR data-processing.Step 2 — Semantic clause search (Vector)
This is the flagship pillar. The clauses.embedding column you just populated is the semantic index
— no Pinecone, no second copy. Ask the question a redline reviewer actually asks: "find clauses like
this uncapped indemnity language." data vsearch --text embeds the query client-side (pass the same
model the column was built with) and returns the nearest clauses by cosine distance.
In blog-clm, find the clauses most similar to 'supplier shall indemnify and hold harmless without limitation for all claims and liabilities' — vector search the clauses.embedding column with jina-embeddings-v4, cosine, top 4.
data_vsearchNearest: 1 (0.1172, the uncapped indemnity), 7 (0.2921, IP indemnity), 2 (0.3410, liability cap), 4 (0.3510).
dodil data vsearch -b "$BUCKET" -t clauses --column embedding \
--text "supplier shall indemnify and hold harmless without limitation for all claims and liabilities" \
--model jina-embeddings-v4 --metric cosine --top-k 4Real output — the two nearest are exactly the two indemnity clauses in the book, the uncapped one first:
id score
1 0.1172 ← "…indemnify …without limitation…" (MSA, uncapped)
7 0.2921 ← IP-infringement indemnity (Initech, capped)
2 0.3410 ← limitation-of-liability cap
4 0.3510 ← unlimited-liability carve-out
The same KNN in SQL — which is what the engine runs, because it can hydrate the contract and filter to
live contracts in one shot (pgvector's <=> with a query-vector literal):
Show the 4 clauses nearest to the indemnity query embedding, joined to their contract title, with the cosine distance.
data_sql1 indemnification / MSA / 0.117, 7 indemnification / Initech / 0.292, 2 lol / MSA / 0.341, 4 lol / Acme / 0.351.
# $QVEC = the query embedding as a '[f1,f2,…]' literal (from `ignite models embed`)
dodil data sql -b "$BUCKET" \
"SELECT cl.id, cl.clause_type, co.title AS contract,
ROUND((cl.embedding <=> '$QVEC')::numeric, 4) AS distance
FROM clauses cl JOIN contracts co ON co.id = cl.contract_id
ORDER BY cl.embedding <=> '$QVEC' LIMIT 4" id clause_type contract distance
1 indemnification Master Services Agreement — Northwind Trading 0.117
7 indemnification Software License — Initech 0.292
2 limitation_of_liability Master Services Agreement — Northwind Trading 0.341
4 limitation_of_liability SaaS Subscription — Acme Cloud 0.351
The
<=>operator needs a vector literal on the right-hand side — embed the query text and interpolate the[…]string. A correlated sub-SELECTof another row's vector does not drive the KNN.
The other flagship question — "which contracts auto-renew?" — has two complementary answers. The
structured one is a plain WHERE auto_renew on the SQL column; the semantic one finds the language even
in contracts whose flag was never set, by searching for auto-renewal clause text (returns clauses 3 and 5):
Which contracts auto-renew? Give me both the structured list (auto_renew flag) and a semantic search for auto-renewal clause language.
data_sql→data_vsearchFlagged: 1001 (2026-09-30), 1006 (2026-10-20), 1002 (2027-01-14). Semantic: clauses 3 (0.1628), 5 (0.3497).
dodil data sql -b "$BUCKET" \
"SELECT id, title, expiration_date, value FROM contracts WHERE auto_renew = true ORDER BY expiration_date"
dodil data vsearch -b "$BUCKET" -t clauses --column embedding \
--text "agreement automatically renews for successive terms unless notice of non-renewal is given" \
--model jina-embeddings-v4 --metric cosine --top-k 3Step 3 — The party/entity graph (counterparty concentration)
The relationship pillar. A graph is table-backed, and the graph node KEY must be an integer — so
you project both parties and contracts into one integer-keyed node table, and put every relationship
in one edge table with a rel column (with_party, affiliate_of). Populate fully, then
CREATE GRAPH — it snapshots its edges at creation time, so any edge inserted afterward isn't
traversable until you re-create the graph.
In blog-clm, build a node table graph_node projecting parties + contracts (KEY id), and one edge table party_edge(src,dst,rel) with rel in {with_party, affiliate_of}. Populate both fully, then CREATE GRAPH clm_graph.
data_pggraph_node: 12 nodes (6 parties + 6 contracts). party_edge: 6 with_party + 2 affiliate_of. Graph clm_graph created.
# Node table — project both business tables into integer node ids (party ids 1–6, contract ids 1001+)
dodil data pg -b "$BUCKET" "CREATE TABLE graph_node (id BIGINT, kind VARCHAR, biz_key VARCHAR, name VARCHAR, PRIMARY KEY (id))"
dodil data pg -b "$BUCKET" "INSERT INTO graph_node SELECT id, 'party', party_type, name FROM parties"
dodil data pg -b "$BUCKET" "INSERT INTO graph_node SELECT id, 'contract', status, title FROM contracts"
# One edge table, a rel column for the two relationship types
dodil data pg -b "$BUCKET" "CREATE TABLE party_edge (src BIGINT, dst BIGINT, rel VARCHAR, PRIMARY KEY (src,dst,rel))"
dodil data pg -b "$BUCKET" "INSERT INTO party_edge SELECT id, counterparty_id, 'with_party' FROM contracts" # contract → counterparty
dodil data pg -b "$BUCKET" "INSERT INTO party_edge VALUES (1,2,'affiliate_of'),(6,2,'affiliate_of')" # Northwind subs → parent
# Snapshot the graph AFTER both tables are fully populated
dodil data pg -b "$BUCKET" "CREATE GRAPH clm_graph NODES (graph_node KEY id) EDGES (party_edge SRC src DST dst)"TIP — the v1 Cypher subset filters properties by
idonly. You can't writeWHERE e.rel='affiliate_of'in a CypherMATCH; the traversal walks the edge structure. Model direction into the graph instead: a forward walkcontract →[with_party]→ subsidiary →[affiliate_of]→ parentrolls a contract up to its ultimate group without any relationship-property filter.
Because graph_khop walks outgoing edges, a forward traversal from a contract climbs the ownership
chain to the parent group. From the Supply Agreement (a draft with Northwind US) that's two hops to
Northwind Holdings:
From contract 1005 (Supply Agreement — Northwind US), roll up the ownership chain with graph_khop and hydrate node names.
data_pghop 1: Northwind Trading US Inc (party), hop 2: Northwind Holdings PLC (party).
dodil data pg -b "$BUCKET" \
"SELECT k.hop_distance, n.kind, n.name
FROM graph_khop('clm_graph', 1005, 3) k
JOIN graph_node n ON n.id = k.node
ORDER BY k.hop_distance"But concentration is the reverse question — given a parent group, what's our total exposure across
every subsidiary? Build a reverse party_edge_rev (the edges flipped) and a clm_reach graph over it;
now a single literal-start graph_khop from Northwind Holdings reaches the whole family and every
contract signed by any member, and you sum the value by joining contracts in the same statement.
Build the reverse reach graph: party_edge_rev = flip of party_edge, then CREATE GRAPH clm_reach. From Northwind Holdings (2) give me the family + every contract exposed to it, and the total exposure.
data_pgFamily: Northwind Trading Ltd, Northwind Trading US Inc (hop 1). Contracts: MSA, Supply Agreement (hop 2). Group exposure: 2 contracts, £1,380,000.
dodil data pg -b "$BUCKET" "CREATE TABLE party_edge_rev (src BIGINT, dst BIGINT, rel VARCHAR, PRIMARY KEY (src,dst,rel))"
dodil data pg -b "$BUCKET" "INSERT INTO party_edge_rev SELECT dst AS src, src AS dst, rel FROM party_edge"
dodil data pg -b "$BUCKET" "CREATE GRAPH clm_reach NODES (graph_node KEY id) EDGES (party_edge_rev SRC src DST dst)"
# THE flagship query: total contractual exposure to a corporate family, in one statement
dodil data pg -b "$BUCKET" \
"SELECT count(*) AS contracts, sum(co.value) AS group_exposure
FROM graph_khop('clm_reach', 2, 5) k
JOIN contracts co ON co.id = k.node"Real output — and why the graph earns its place. The naive per-counterparty view shows Northwind US and Northwind Trading as two mid-size vendors; the graph rolls them into your single largest concentration:
-- graph rollup: exposure to the Northwind group --
contracts group_exposure
2 1380000
-- naive per-counterparty view (what you'd see WITHOUT the graph) --
name value
Northwind Trading US Inc 900000
Northwind Trading Ltd 480000 ← the graph merges these two into £1.38M
Initech Software Inc 250000
Acme Cloud Services LLC 120000
The same traversal in Cypher over Bolt — return the node variable and join graph_node for properties
(the graph plane hands back node keys, and functions/filters live in the enclosing SQL):
Same rollup in Cypher over Bolt: from contract 1005 follow the graph up to 2 hops and return the reachable nodes.
data_boltReturns node 6 (hop 1, Northwind US) and node 2 (hop 2, Northwind Holdings).
dodil data bolt -b "$BUCKET" -g clm_graph "MATCH (c)-[:party_edge*1..2]->(p) WHERE id(c)=1005 RETURN p"Step 4 — The renewal scanner (Ignite + a real handler)
An auto-renewing contract with a missed non-renewal notice is how legal teams get locked into another
year — so the obligations that matter are the ones coming due. An Ignite app is a separate workload; it
needs its own service account to reach the bucket (client-credentials → bearer token). Grant
least-privilege roles, inject the creds as runtime env, deploy, then invoke. The handler scans open
obligations, sets alert on the ones due inside the window, and writes back over one Postgres-wire
connection.
Create a service account renewal-scanner-sa, grant it k3.editor, deploy ./renewal-scanner as a python app with the SA creds as runtime env, then invoke it with a 60-day window.
auth_service-account_create→auth_service-account_grant-role→ignite_app_deploy→ignite_invokeDeployed renewal-scanner (deployment_state: deployed); invoke flagged 4 obligations (ids 6, 1, 2, 4).
dodil auth service-account create renewal-scanner-sa # → prints $SA_ID / $SA_SECRET
dodil auth service-account grant-role renewal-scanner-sa k3-authorization-service k3.editor
dodil ignite app deploy renewal-scanner --code ./renewal-scanner --runtime python \
--env DODIL_BUCKET="$BUCKET" \
--env DODIL_SERVICE_ACCOUNT_ID="$SA_ID" \
--env DODIL_SERVICE_ACCOUNT_SECRET="$SA_SECRET"
dodil ignite invoke renewal-scanner --payload '{"window_days":60}'The handler — real, runnable code. ./renewal-scanner/handler.py:
TOKEN_URL = "https://id.dodil.io/realms/dodil/protocol/openid-connect/token"
PG_HOST, PG_PORT = "pg.uk-lon-1.dodil.io", 5432
BUCKET = os.environ.get("DODIL_BUCKET", "blog-clm")
def _token():
r = requests.post(TOKEN_URL, timeout=30, data={
"grant_type": "client_credentials",
"client_id": os.environ["DODIL_SERVICE_ACCOUNT_ID"],
"client_secret": os.environ["DODIL_SERVICE_ACCOUNT_SECRET"]})
r.raise_for_status()
return r.json()["access_token"]
def handler(payload, ctx):
window = int(payload.get("window_days", 60))
tok = _token()
conn = psycopg.connect(host=PG_HOST, port=PG_PORT, dbname=BUCKET,
user="token", password=tok, sslmode="require")
flagged = []
with conn, conn.cursor() as cur:
# open obligations due inside the window — the renewal/notice deadlines that matter
cur.execute("""SELECT o.id, o.description, o.due_date, c.title
FROM obligations o JOIN contracts c ON c.id = o.contract_id
WHERE o.status = 'open'
AND o.due_date <= CURRENT_DATE + %s
ORDER BY o.due_date""", (window,))
rows = cur.fetchall()
# set the alert flag (obligations has no vector column, so a plain UPDATE works — see the TIP)
for oid, desc, due, title in rows:
cur.execute("UPDATE obligations SET alert = true WHERE id = %s", (oid,))
flagged.append({"id": oid, "due_date": str(due), "contract": title, "description": desc})
return {"window_days": window, "flagged_count": len(flagged), "flagged": flagged}requirements.txt is just requests and psycopg[binary]. The scan window over the seeded book flags
four obligations — the Initech countersignature, the Northwind non-renewal notice, the Acme DPA
cancellation window, and the Q3 service-credit report:
id contract description due_date days_out
6 Software License — Initech Countersign Initech license before effective date 2026-09-05 4
1 Master Services Agreement — Northwind Trading Serve 90-day non-renewal notice or MSA auto-renews 2026-09-15 14
2 Data Processing Addendum — Acme Cloud Serve 30-day cancellation notice for Acme DPA renewal 2026-09-20 19
4 Master Services Agreement — Northwind Trading Deliver Q3 service credit report 2026-10-10 39
TIP —
UPDATEworks on a plain table but not on a table with aVECTORcolumn. The scanner'sUPDATE obligations SET alert=trueruns fine (no vector). Writingrisk_levelback toclauses(Step 5) withUPDATEfails (point key missing PK column 'clause_id') — use a partialdata table upsert --mergethere instead; it also preserves the 2048-dim embedding untouched.
Step 5 — The clause-risk gate (Models)
The gate is why this scales past what a human redline team can read: a model reads each clause and returns
a structured risk_level, so the risk register builds itself and lawyers spend their hours on the high
rows. Here is the real call and the real replies — an uncapped indemnity and an unlimited-liability
carve-out both come back high; a market-standard confidentiality term comes back low:
Classify each clause with kimi-k2.6, JSON only {risk_level: high|medium|low, reason}. High = unlimited/uncapped liability or non-standard indemnity; medium = auto-renewal lock-in; low = market-standard. Run it on the uncapped indemnity, the unlimited-liability carve-out, and a standard confidentiality clause.
ignite_models_chatindemnity → {"risk_level":"high","reason":"Uncapped unlimited indemnity for all claims without limitation or carve-outs."}; liability → {"risk_level":"high",…}; confidentiality → {"risk_level":"low","reason":"Standard mutual confidentiality with market-standard 5-year limitation."}
dodil ignite models chat kimi-k2.6 \
--system 'You are a contract risk reviewer. Reply with ONLY compact JSON: {"risk_level": one of [high, medium, low], "reason": short}. High = unlimited/uncapped liability, unlimited or non-standard indemnity. Medium = auto-renewal that can lock the company in. Low = standard mutual caps and market-standard terms. No prose.' \
--message 'Clause (limitation_of_liability): "The Provider'\''s total liability with respect to any breach of its confidentiality and data protection obligations under this Agreement shall be unlimited and uncapped."'The verdict lands back in each clause's risk_level column. Because clauses has a VECTOR column, the
write-back is a partial --merge upsert (not UPDATE) — it sets risk_level and leaves the embedding
intact:
Write the risk verdicts back to clauses: 1 & 4 high, 3 & 5 medium, the rest low — with a partial merge upsert so the embeddings stay intact. Then show the risk register.
data_table_upsert→data_sql8 rows written (has_vec still true). Register: 1 & 4 high, 3 & 5 medium.
dodil data table upsert clauses -b "$BUCKET" --merge \
--row '{"id":1,"risk_level":"high"}' --row '{"id":4,"risk_level":"high"}' \
--row '{"id":3,"risk_level":"medium"}' --row '{"id":5,"risk_level":"medium"}' \
--row '{"id":2,"risk_level":"low"}' --row '{"id":6,"risk_level":"low"}' \
--row '{"id":7,"risk_level":"low"}' --row '{"id":8,"risk_level":"low"}'
dodil data sql -b "$BUCKET" \
"SELECT cl.id, cl.clause_type, co.title AS contract, cl.risk_level
FROM clauses cl JOIN contracts co ON co.id = cl.contract_id
WHERE cl.risk_level IN ('high','medium') ORDER BY cl.risk_level, cl.id" id clause_type contract risk_level
1 indemnification Master Services Agreement — Northwind Trading high
4 limitation_of_liability SaaS Subscription — Acme Cloud high
3 auto_renewal Master Services Agreement — Northwind Trading medium
5 auto_renewal SaaS Subscription — Acme Cloud medium
Query it — one bucket, three pillars, drop-in clients
The same rows answer by content (SQL), by meaning (vector), and by relationship (graph) — no ETL, no second copy. The whole redline picture of a live contract — its high-risk clauses and its due obligations — is one join:
For contract 1001, show its high-risk clauses and its open obligations coming due — one report.
data_sqlMSA 1001: 1 high-risk clause (uncapped indemnity), 2 alerted obligations (non-renewal notice, Q3 credit report).
dodil data sql -b "$BUCKET" \
"SELECT c.title, c.expiration_date, c.auto_renew,
count(DISTINCT cl.id) FILTER (WHERE cl.risk_level='high') AS high_risk_clauses,
count(DISTINCT o.id) FILTER (WHERE o.alert) AS obligations_due
FROM contracts c
LEFT JOIN clauses cl ON cl.contract_id = c.id
LEFT JOIN obligations o ON o.contract_id = c.id
WHERE c.id = 1001
GROUP BY c.title, c.expiration_date, c.auto_renew"And any Postgres/pgvector or Bolt/Neo4j client points straight at the bucket — data connect prints the
endpoints (DB name = bucket, credential = your login token). Point psql, cypher-shell, Grafana, or a
BI tool at these with zero export:
Print the drop-in pg / bolt / grpc endpoints for blog-clm so I can point psql and cypher-shell at it.
data_connectpg postgresql://token:…@pg.uk-lon-1.dodil.io:5432/blog-clm · bolt bolt+s://bolt.uk-lon-1.dodil.io:7687 (database=blog-clm) · grpc table-rpc.uk-lon-1.dodil.io:443
dodil data connect "$BUCKET" # pg / bolt / grpc endpoints
dodil data connect "$BUCKET" -o psql # a ready-to-paste postgresql://… URLDataK3 vs. the multi-system CLM stack
| CLM concern | Classic stack | On DataK3 |
|---|---|---|
| Contracts / clauses / obligations / parties | Postgres (contract DB) | SQL pillar — merge-keyed tables, lifecycle in status |
| Clause semantic search | Pinecone | Vector pillar — VECTOR(2048) column, pgvector <=> / data vsearch |
| Party/entity relationships | Neo4j | Graph pillar — graph_khop('clm_reach', …), Cypher over Bolt |
| Renewal calendar / risk register | Nightly ETL → warehouse | One JOIN — same rows, read-your-writes, no ETL |
| Renewal automation | Workflow engine + connectors | Ignite handler.py, one bucket, one auth context |
One bucket, one bill, one auth context — and the counterparty-concentration rollup and the contracts it sums are the same live rows, not last night's snapshot in a separate graph DB.
Test
Run against live DataK3, then the assertions below hold. What ran live for this post
(tested_at: 2026-09-01, bucket blog-clm, org IHDIASH): the bucket + all four SQL tables; the 8
clause embeddings (jina-embeddings-v4, dim 2048); the vsearch and pgvector <=> clause KNN; the
clm_graph + reverse clm_reach graphs and the graph_khop / Bolt traversals; the kimi-k2.6 risk
classification; and both write-backs (UPDATE on obligations, --merge upsert on clauses). The
handler.py is real and runnable; the ignite app deploy / invoke wrapper is shown as code.
# Clause search — expect the uncapped indemnity (clause 1) nearest, IP indemnity (7) second
dodil data vsearch -b "$BUCKET" -t clauses --column embedding \
--text "supplier shall indemnify and hold harmless without limitation for all claims and liabilities" \
--model jina-embeddings-v4 --metric cosine --top-k 4
# Counterparty concentration — expect 2 contracts, 1380000 for the Northwind group
dodil data pg -b "$BUCKET" \
"SELECT count(*) AS contracts, sum(co.value) AS group_exposure
FROM graph_khop('clm_reach', 2, 5) k JOIN contracts co ON co.id = k.node"
# Renewal scan — expect 4 open obligations due within 60 days of 2026-09-01
dodil data sql -b "$BUCKET" \
"SELECT count(*) AS due FROM obligations WHERE status='open' AND due_date <= DATE '2026-09-01' + 60"
# Risk register — expect 2 high, 2 medium
dodil data sql -b "$BUCKET" "SELECT risk_level, count(*) AS n FROM clauses GROUP BY risk_level ORDER BY n DESC"One-shot
Build an Ironclad-style contract lifecycle platform on one DataK3 bucket named blog-clm.
1. Create the bucket and merge-keyed tables: contracts(id,title,counterparty_id,contract_type,status,
effective_date,expiration_date,value,auto_renew,governing_law); clauses(id,contract_id,clause_type,
text,risk_level,embedding VECTOR(2048)); obligations(id,contract_id,description,owner,due_date,status,
alert); parties(id,name,party_type,jurisdiction).
2. Upsert 6 parties (two Northwind entities under a shared parent), 6 contracts across
draft/review/executed/active (two auto_renew, expiring soon), and 8 clauses. Embed each clause text
with jina-embeddings-v4 into the [ … ] literal; upsert one clause per call; leave risk_level "".
3. Clause search: vsearch / pgvector `embedding <=> '<qvec>'` for indemnity + auto-renewal language.
4. Party graph: project parties+contracts into graph_node (integer KEY); one party_edge(src,dst,rel)
table with rel in {with_party, affiliate_of}; populate FULLY then CREATE GRAPH clm_graph. Add the
reverse party_edge_rev + CREATE GRAPH clm_reach; counterparty concentration =
graph_khop('clm_reach', <parent_id>, 5) joined to contracts, summing value.
5. Deploy an Ignite renewal-scanner (own SA: k3.editor) whose handler.py flags open obligations due
within a window (UPDATE obligations SET alert=true).
6. Risk gate: classify each clause with kimi-k2.6 → {risk_level}, write back with `data table upsert
--merge` (UPDATE fails on the VECTOR table). Verify: nearest indemnity clause = 1; Northwind group
exposure = £1,380,000; 4 obligations due; 2 high / 2 medium clauses.Ship it — make renewal-scanner a public endpoint
The renewal-scanner app above is shown as code. Ship it the last mile so users hit a real URL, not a
snippet — the managed runtime compiles your handler.py and returns a public FQDN:
Deploy the renewal-scanner app publicly with no auth and give me its URL.
ignite_app_deploy→ignite_app_getDeployed renewal-scanner (deployment_state: deployed). Its public FQDN on ignite.dodil.cloud is callable with no token — or call it with dodil ignite invoke renewal-scanner.
dodil ignite app deploy renewal-scanner --code ./renewal-scanner --runtime python --allow-unauthenticated
dodil ignite app get renewal-scanner --output json # → public_urlsThat's the quick path (managed compile). The full supply chain — DODIL git → CI checks → a scanned image in the DODIL registry → versioning and one-command rollback — is its own tutorial: Ship a DODIL App.
Conclusion
You now have a one-bucket contract lifecycle platform — the contract/clause/obligation/party SQL core,
semantic clause search that finds an uncapped indemnity by meaning, a party graph that rolls a
corporate family into your single largest counterparty concentration (£1.38M across two Northwind
entities), an Ignite renewal scanner that flags what's coming due, and a kimi-k2.6 gate that builds the
risk register itself. No Postgres + Pinecone + Neo4j + warehouse + the ETL between them: one copy of
the rows, three query pillars, one bill.
The reusable skill: model a document-heavy enterprise system as one DataK3 bucket, run semantic search over the vector pillar and entity-concentration rollups over the graph pillar while the SQL pillar shares the same rows. See also the sibling builds a ServiceNow-style ITSM with a CMDB graph and a cost-gated CRM lead pipeline — same one-bucket, three-pillar pattern, different domain.