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 dodil CLI (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.

You

Create a DataK3 bucket called blog-clm, then merge-keyed tables: contracts, clauses (with a VECTOR(2048) embedding column), obligations, parties.

DODIL MCP tools called
data_bucket_createdata_table_create
Agent

Created bucket blog-clm. Tables contracts, clauses, obligations, parties created, each with PRIMARY KEY (id).

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.

You

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.

DODIL MCP tools called
data_table_upsert
Agent

Upserted 6 rows (watermark …).

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.

You

Upsert 6 contracts into blog-clm across draft/review/executed/active, each pointing at its counterparty_id, two auto_renew and expiring soon.

DODIL MCP tools called
data_table_upsert
Agent

Upserted 6 rows (watermark …).

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 null on a keyed upsert. A null can silently drop the row on the next read. Use a sentinel ("" for risk_level here) and let a later step fill it, or write the column with a --merge upsert.

You

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.

DODIL MCP tools called
ignite_models_embeddata_table_upsert
Agent

Upserted 8 clauses; each embedding is a 2048-dim vector literal (has_vec = true for all).

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.

You

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.

DODIL MCP tools called
data_vsearch
Agent

Nearest: 1 (0.1172, the uncapped indemnity), 7 (0.2921, IP indemnity), 2 (0.3410, liability cap), 4 (0.3510).

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

You

Show the 4 clauses nearest to the indemnity query embedding, joined to their contract title, with the cosine distance.

DODIL MCP tools called
data_sql
Agent

1 indemnification / MSA / 0.117, 7 indemnification / Initech / 0.292, 2 lol / MSA / 0.341, 4 lol / Acme / 0.351.

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

You

Which contracts auto-renew? Give me both the structured list (auto_renew flag) and a semantic search for auto-renewal clause language.

DODIL MCP tools called
data_sqldata_vsearch
Agent

Flagged: 1001 (2026-09-30), 1006 (2026-10-20), 1002 (2027-01-14). Semantic: clauses 3 (0.1628), 5 (0.3497).

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

You

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.

DODIL MCP tools called
data_pg
Agent

graph_node: 12 nodes (6 parties + 6 contracts). party_edge: 6 with_party + 2 affiliate_of. Graph clm_graph created.

TIP — the v1 Cypher subset filters properties by id only. You can't write WHERE e.rel='affiliate_of' in a Cypher MATCH; the traversal walks the edge structure. Model direction into the graph instead: a forward walk contract →[with_party]→ subsidiary →[affiliate_of]→ parent rolls 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:

You

From contract 1005 (Supply Agreement — Northwind US), roll up the ownership chain with graph_khop and hydrate node names.

DODIL MCP tools called
data_pg
Agent

hop 1: Northwind Trading US Inc (party), hop 2: Northwind Holdings PLC (party).

But concentration is the reverse questiongiven 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.

You

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.

DODIL MCP tools called
data_pg
Agent

Family: Northwind Trading Ltd, Northwind Trading US Inc (hop 1). Contracts: MSA, Supply Agreement (hop 2). Group exposure: 2 contracts, £1,380,000.

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

You

Same rollup in Cypher over Bolt: from contract 1005 follow the graph up to 2 hops and return the reachable nodes.

DODIL MCP tools called
data_bolt
Agent

Returns node 6 (hop 1, Northwind US) and node 2 (hop 2, Northwind Holdings).

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.

You

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.

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

Deployed renewal-scanner (deployment_state: deployed); invoke flagged 4 obligations (ids 6, 1, 2, 4).

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 — UPDATE works on a plain table but not on a table with a VECTOR column. The scanner's UPDATE obligations SET alert=true runs fine (no vector). Writing risk_level back to clauses (Step 5) with UPDATE fails (point key missing PK column 'clause_id') — use a partial data table upsert --merge there 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:

You

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.

DODIL MCP tools called
ignite_models_chat
Agent

indemnity → {"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."}

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:

You

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.

DODIL MCP tools called
data_table_upsertdata_sql
Agent

8 rows written (has_vec still true). Register: 1 & 4 high, 3 & 5 medium.

 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:

You

For contract 1001, show its high-risk clauses and its open obligations coming due — one report.

DODIL MCP tools called
data_sql
Agent

MSA 1001: 1 high-risk clause (uncapped indemnity), 2 alerted obligations (non-renewal notice, Q3 credit report).

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:

You

Print the drop-in pg / bolt / grpc endpoints for blog-clm 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/blog-clm · bolt bolt+s://bolt.uk-lon-1.dodil.io:7687 (database=blog-clm) · grpc table-rpc.uk-lon-1.dodil.io:443

DataK3 vs. the multi-system CLM stack

CLM concernClassic stackOn DataK3
Contracts / clauses / obligations / partiesPostgres (contract DB)SQL pillar — merge-keyed tables, lifecycle in status
Clause semantic searchPineconeVector pillar — VECTOR(2048) column, pgvector <=> / data vsearch
Party/entity relationshipsNeo4jGraph pillar — graph_khop('clm_reach', …), Cypher over Bolt
Renewal calendar / risk registerNightly ETL → warehouseOne JOIN — same rows, read-your-writes, no ETL
Renewal automationWorkflow engine + connectorsIgnite 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:

You

Deploy the renewal-scanner app publicly with no auth and give me its URL.

DODIL MCP tools called
ignite_app_deployignite_app_get
Agent

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

That'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.