What you'll build: a source-to-pay (S2P) platform on one DataK3 bucket — the
requisition → approval → PO → goods → invoice tables (SQL), a supplier & spend graph that reveals
concentration risk hiding behind subsidiary names, a contract-clause vector search ("who allows
net-60 for cloud?"), an Ignite 3-way-match engine that reconciles PO ↔ receipt ↔ invoice, and a
kimi-k2.6 approval gate that auto-approves routine spend and routes the rest to a human — writing
approval_status back onto the requisition.
The problem — and why it matters
Coupa and SAP Ariba are priced like a tax on your own spending: a platform subscription in the six-to-seven figures plus a percentage of the spend you route through them, on top of per-user seats for every buyer and approver. And that price buys a system that is really four systems bolted together: the transactional S2P core (Postgres — requisitions, POs, invoices), a catalog / contract search index (Pinecone — "find a supplier who can do X"), a supplier network store (Neo4j — who owns whom, who supplies what), and a spend-analytics warehouse the whole thing is ETL'd into overnight. Four engines, four bills, four copies of the same rows, and glue to keep them agreeing.
The questions procurement actually cares about cut across those systems. "If this supplier fails, how much spend is exposed?" is a graph traversal the ledger can't do. "Which vendors contractually allow net-60?" is a semantic search over contract text the warehouse doesn't hold. "Does this invoice match its PO and receipt?" is a join the catalog store never sees. So the answers live in three places, at three freshnesses, reconciled by a nightly job.
What collapses onto one bucket: the suppliers/requisitions/PO/invoice tables, the supplier & spend
graph, and the contract-clause vectors are the same rows under three query pillars — SQL (DuckDB
dialect), Graph (Cypher/Bolt + graph_*()), and Vector (pgvector). No ETL, no second copy, no nightly
sync, one auth context. The payoff is a buyer who opens a requisition and it self-approves under policy;
an AP clerk whose invoices reconcile themselves and only the exceptions surface; and a category manager
who sees that "two independent cloud vendors" are one corporate group — before signing the renewal.
What you'll build: a one-bucket blog-procurement system — the transactional S2P tables (SQL), the
supplier & spend graph (Graph), contract-clause search (Vector), an Ignite match-engine app, and a
kimi-k2.6 approval 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-procurement— one bucket is the whole system's data plane.
Step 1 — Stand up the source-to-pay core (SQL)
Create the bucket, then the merge-keyed transactional tables. A PRIMARY KEY (merge-key) is
required — writes are keyed, so re-runs and shard retries upsert idempotently, exactly what you want
for a purchasing ledger a dozen buyers and an AP team touch at once.
Create a DataK3 bucket called blog-procurement, then merge-keyed tables: suppliers, requisitions, purchase_orders, po_lines, invoices.
data_bucket_create→data_table_createCreated bucket blog-procurement (status ACTIVE). Tables suppliers, requisitions, purchase_orders, po_lines, invoices created, each with PRIMARY KEY (id).
dodil data bucket create "$BUCKET" --description "Source-to-pay system of record"
# Suppliers — the vendor master. parent_id links a subsidiary to its parent (the graph in Step 2).
dodil data table create suppliers -b "$BUCKET" --merge-key id \
--columns-json '[{"name":"id","type":"bigint"},{"name":"name","type":"varchar"},{"name":"parent_id","type":"bigint"},{"name":"category","type":"varchar"},{"name":"country","type":"varchar"},{"name":"status","type":"varchar"},{"name":"risk_score","type":"double"}]'
# Requisitions — a buyer's request. approval_status/reason are filled by the Models gate (Step 5).
dodil data table create requisitions -b "$BUCKET" --merge-key id \
--columns-json '[{"name":"id","type":"bigint"},{"name":"requester","type":"varchar"},{"name":"supplier_id","type":"bigint"},{"name":"category","type":"varchar"},{"name":"amount","type":"double"},{"name":"description","type":"varchar"},{"name":"created_at","type":"varchar"},{"name":"approval_status","type":"varchar"},{"name":"approval_reason","type":"varchar"}]'
dodil data table create purchase_orders -b "$BUCKET" --merge-key id \
--columns-json '[{"name":"id","type":"bigint"},{"name":"requisition_id","type":"bigint"},{"name":"supplier_id","type":"bigint"},{"name":"amount","type":"double"},{"name":"status","type":"varchar"},{"name":"created_at","type":"varchar"}]'
# po_lines — ordered vs received quantity: the "receipt" leg of the 3-way match.
dodil data table create po_lines -b "$BUCKET" --merge-key id \
--columns-json '[{"name":"id","type":"bigint"},{"name":"po_id","type":"bigint"},{"name":"item","type":"varchar"},{"name":"qty","type":"bigint"},{"name":"unit_price","type":"double"},{"name":"received_qty","type":"bigint"}]'
# invoices — match_status is written by the 3-way-match engine (Step 4).
dodil data table create invoices -b "$BUCKET" --merge-key id \
--columns-json '[{"name":"id","type":"bigint"},{"name":"po_id","type":"bigint"},{"name":"supplier_id","type":"bigint"},{"name":"invoice_number","type":"varchar"},{"name":"amount","type":"double"},{"name":"billed_qty","type":"bigint"},{"name":"match_status","type":"varchar"}]'Load the vendor master — six suppliers. CloudNova EMEA, CloudNova APAC, and DataGuard all
carry parent_id = 1 (CloudNova Inc): three vendor names, one corporate parent. That looks harmless in
a flat table and becomes the whole story in the graph.
Upsert 6 suppliers into blog-procurement: CloudNova Inc (id 1, top-level), CloudNova EMEA (2)/CloudNova APAC (3)/DataGuard (6) all under parent 1, plus OfficeReem (4) and SteelForge (5) as independents.
data_table_upsertUpserted 6 suppliers (wal_written: true).
# parent_id = 0 means "top-level parent"; a positive id points at the owning supplier.
dodil data table upsert suppliers -b "$BUCKET" \
--row '{"id":1,"name":"CloudNova Inc","parent_id":0,"category":"cloud services","country":"US","status":"active","risk_score":0.12}' \
--row '{"id":2,"name":"CloudNova EMEA Ltd","parent_id":1,"category":"cloud services","country":"UK","status":"active","risk_score":0.15}' \
--row '{"id":3,"name":"CloudNova APAC Pte","parent_id":1,"category":"cloud services","country":"SG","status":"active","risk_score":0.22}' \
--row '{"id":4,"name":"OfficeReem Supplies","parent_id":0,"category":"office supplies","country":"DE","status":"active","risk_score":0.30}' \
--row '{"id":5,"name":"SteelForge GmbH","parent_id":0,"category":"raw materials","country":"DE","status":"active","risk_score":0.45}' \
--row '{"id":6,"name":"DataGuard Security","parent_id":1,"category":"cloud services","country":"US","status":"active","risk_score":0.18}'TIP
Use sentinels, not null, in keyed upserts. Write 0 / "" for "not set yet", as above. A row
whose merge-key upsert carries a JSON null in a column can fail to persist — surfacing as a row that
"vanishes" on the next read. Empty string and a zero sentinel are durable and JOIN-friendly.
Now the transactional rows — four requisitions (one per supplier group), two approved POs with their
receipt lines, and the two supplier invoices AP received. This is a complete source-to-pay trail:
requisition → PO → goods (received_qty) → invoice.
Upsert 4 requisitions (101 cloud/4200, 102 office/850, 103 raw-materials/96000, 104 cloud/15000, all pending), 2 purchase_orders (5001 for req 101, 5002 for req 102), their po_lines (9001 ordered 20/received 20, 9002 ordered 10/received 8), and 2 invoices (7001 for PO 5001, 7002 for PO 5002), match_status unmatched.
data_table_upsertUpserted 4 requisitions, 2 POs, 2 po_lines, 2 invoices (wal_written: true).
dodil data table upsert requisitions -b "$BUCKET" \
--row '{"id":101,"requester":"a.khan","supplier_id":2,"category":"cloud services","amount":4200.0,"description":"Kubernetes cluster scale-up, net-60 terms","created_at":"2026-08-20","approval_status":"pending","approval_reason":""}' \
--row '{"id":102,"requester":"l.meyer","supplier_id":4,"category":"office supplies","amount":850.0,"description":"10 ergonomic chairs for the Berlin office","created_at":"2026-08-21","approval_status":"pending","approval_reason":""}' \
--row '{"id":103,"requester":"s.rossi","supplier_id":5,"category":"raw materials","amount":96000.0,"description":"Structural steel, Q4 build program","created_at":"2026-08-22","approval_status":"pending","approval_reason":""}' \
--row '{"id":104,"requester":"m.dubois","supplier_id":6,"category":"cloud services","amount":15000.0,"description":"Annual SIEM platform license, net-30","created_at":"2026-08-23","approval_status":"pending","approval_reason":""}'
dodil data table upsert purchase_orders -b "$BUCKET" \
--row '{"id":5001,"requisition_id":101,"supplier_id":2,"amount":4200.0,"status":"open","created_at":"2026-08-24"}' \
--row '{"id":5002,"requisition_id":102,"supplier_id":4,"amount":850.0,"status":"open","created_at":"2026-08-24"}'
# received_qty is the receipt: PO 5001 fully received (20/20); PO 5002 short-received (8/10).
dodil data table upsert po_lines -b "$BUCKET" \
--row '{"id":9001,"po_id":5001,"item":"Managed K8s node (m5.xlarge, monthly)","qty":20,"unit_price":210.0,"received_qty":20}' \
--row '{"id":9002,"po_id":5002,"item":"Ergonomic office chair","qty":10,"unit_price":85.0,"received_qty":8}'
dodil data table upsert invoices -b "$BUCKET" \
--row '{"id":7001,"po_id":5001,"supplier_id":2,"invoice_number":"CN-2026-0912","amount":4200.0,"billed_qty":20,"match_status":"unmatched"}' \
--row '{"id":7002,"po_id":5002,"supplier_id":4,"invoice_number":"OR-5567","amount":850.0,"billed_qty":10,"match_status":"unmatched"}'NOTE
Optional — parse real invoice PDFs. Instead of hand-writing invoice rows, install the
invoice-intake recipe (dodil data recipe install invoice-intake -b "$BUCKET"): drop supplier PDFs
into the bucket and it parses vendor, invoice number, line items and totals into a structured table
you upsert into invoices — the same shape the 3-way match in Step 4 reads.
Step 2 — The supplier & spend network (Graph)
This is the pillar spend analytics lives or dies on. A graph is table-backed: a graph_nodes table
is the node set (KEY id — suppliers and spend categories), a single edges table holds
src → dst with a rel property (subsidiary_of for ownership, spend for money flowing to a
category), and CREATE GRAPH binds them. data pg owns the DDL.
In blog-procurement, create graph_nodes(id,label,name) and edges(src,dst,rel,amount); insert supplier + category nodes, the subsidiary_of edges (2→1, 3→1, 6→1) and spend edges (supplier→category with amount); then CREATE GRAPH spend_g over graph_nodes (KEY id) and edges (SRC src DST dst).
data_pgGraph spend_g created over graph_nodes / edges (9 nodes, 8 edges).
dodil data pg -b "$BUCKET" "CREATE TABLE graph_nodes (id BIGINT PRIMARY KEY, label VARCHAR, name VARCHAR)"
dodil data pg -b "$BUCKET" "CREATE TABLE edges (src BIGINT, dst BIGINT, rel VARCHAR, amount DOUBLE, PRIMARY KEY (src,dst,rel))"
# nodes: suppliers (1-6) + spend-category nodes (1001-1003)
dodil data pg -b "$BUCKET" "INSERT INTO graph_nodes VALUES (1,'supplier','CloudNova Inc'),(2,'supplier','CloudNova EMEA Ltd'),(3,'supplier','CloudNova APAC Pte'),(4,'supplier','OfficeReem Supplies'),(5,'supplier','SteelForge GmbH'),(6,'supplier','DataGuard Security'),(1001,'category','cloud services'),(1002,'category','office supplies'),(1003,'category','raw materials')"
# subsidiary_of: child -> parent. spend: supplier -> category (amount = spend into that category)
dodil data pg -b "$BUCKET" "INSERT INTO edges VALUES (2,1,'subsidiary_of',0),(3,1,'subsidiary_of',0),(6,1,'subsidiary_of',0),(2,1001,'spend',4200),(3,1001,'spend',3000),(6,1001,'spend',15000),(4,1002,'spend',850),(5,1003,'spend',96000)"
# insert ALL edges BEFORE CREATE GRAPH — the graph indexes the edge set at creation time.
dodil data pg -b "$BUCKET" "CREATE GRAPH spend_g NODES (graph_nodes KEY id) EDGES (edges SRC src DST dst)"NOTE
CREATE GRAPH snapshots its edges. Insert (or bulk-load) every edge row before you create the
graph. If you add edges afterward, refresh with DROP GRAPH spend_g + CREATE GRAPH … so the new
relationships are traversable. (v1 graphs bind exactly one node source and one edge source — hence a
single edges table with a rel column rather than one table per relationship type.)
Find every subsidiary of a parent in one query. The subsidiary edges point up (child → parent), so anchor the parent on the left of the pattern and follow the incoming edge — Cypher over Bolt returns the three vendors that roll up to CloudNova Inc:
In graph spend_g, which suppliers are subsidiaries of CloudNova Inc (node 1)? Anchor node 1 and follow the incoming subsidiary edges.
data_boltneighbor: 6 (DataGuard), 3 (CloudNova APAC), 2 (CloudNova EMEA) — three vendor names, one parent.
dodil data bolt -b "$BUCKET" -g spend_g "MATCH (b)<-[:edges]-(a) WHERE id(b)=1 RETURN a"The reverse question — what does one vendor roll up to and spend on? — is graph_neighbors (or
graph_khop with hop distance) from that vendor, hydrated by joining graph_nodes in the same
statement. From CloudNova EMEA (node 2) that's its parent and the category it feeds:
From CloudNova EMEA (node 2) in spend_g, what does it connect to within 2 hops? Hydrate names from graph_nodes.
data_pghop 1: CloudNova Inc (parent), cloud services (category it spends on).
dodil data pg -b "$BUCKET" \
"SELECT k.hop_distance, n.label, n.name
FROM graph_khop('spend_g', 2, 2) k
JOIN graph_nodes n ON n.id = k.node
ORDER BY k.hop_distance"Now the concentration-risk query that a flat vendor table hides. Total cloud-services spend, and
how much of it flows to the CloudNova corporate group (the parent + everything under it). The edge
table joins to the suppliers SQL table in the same bucket to hydrate country and risk:
In blog-procurement, what is total cloud-services spend, and how much of it is concentrated in the CloudNova parent group (CloudNova Inc + all its subsidiaries)?
data_pgCloudNova group cloud spend = 22,200 of 22,200 total — 100% of cloud spend is one corporate group behind three vendor names.
dodil data pg -b "$BUCKET" \
"SELECT
(SELECT sum(amount) FROM edges
WHERE rel='spend' AND dst=1001
AND src IN (SELECT src FROM edges WHERE rel='subsidiary_of' AND dst=1)) AS cloudnova_group,
(SELECT sum(amount) FROM edges WHERE rel='spend' AND dst=1001) AS total_cloud"Real output — the two "independent" cloud vendors and the security vendor are one group, so your true single-supplier exposure is 100% of cloud spend, not the 68% you'd read off the biggest line item:
cloudnova_group total_cloud
22200 22200
Step 3 — Contract-clause search (Vector)
Concentration risk answers who — the next question is on what terms? Store each contract clause /
catalog line as a row with a VECTOR(2048) embedding and the pillar answers "which suppliers
contractually allow net-60 for cloud services?" semantically — no Pinecone, no second copy.
Create a contract_clauses table in blog-procurement with a VECTOR(2048) embedding column, then for each clause embed its text with jina-embeddings-v4 and upsert the row.
data_table_create→ignite_models_embed→data_table_upsertCreated contract_clauses; upserted 6 clauses, each with a 2048-dim jina-embeddings-v4 vector.
dodil data table create contract_clauses -b "$BUCKET" --merge-key id \
--columns-json '[{"name":"id","type":"bigint"},{"name":"supplier_id","type":"bigint"},{"name":"clause_type","type":"varchar"},{"name":"clause_text","type":"varchar"},{"name":"embedding","type":"VECTOR(2048)"}]'
# embed a clause → a pgvector literal, then upsert (one row per call: large vectors keep frames small)
EMB=$(dodil ignite models embed jina-embeddings-v4 \
--input "Cloud infrastructure services are billed monthly with net-60 payment terms." \
--output json | jq -c '.data.data[0].embedding')
dodil data table upsert contract_clauses -b "$BUCKET" \
--row "{\"id\":201,\"supplier_id\":2,\"clause_type\":\"payment_terms\",\"clause_text\":\"Cloud infrastructure services are billed monthly with net-60 payment terms.\",\"embedding\":$EMB}"
# …repeat for clauses 202-206 (DataGuard net-30 SIEM, OfficeReem delivery, SteelForge net-45 steel,
# CloudNova EMEA uptime SLA, CloudNova APAC regional cloud net-60).Ask the question in natural language — data vsearch --text embeds the query with the same model
the column was built with and returns the nearest clauses by cosine distance:
In blog-procurement, find suppliers whose contract allows net-60 for cloud services — vector-search contract_clauses.embedding with jina-embeddings-v4, cosine, top 4.
data_vsearchNearest: 201 (0.260, CloudNova EMEA net-60 cloud), 206 (0.376, CloudNova APAC net-60 cloud), then 202 (0.445, DataGuard net-30) and 204 (0.465, SteelForge net-45).
dodil data vsearch -b "$BUCKET" -t contract_clauses --column embedding \
--text "find suppliers whose contract allows net-60 payment for cloud services" \
--model jina-embeddings-v4 --metric cosine --top-k 4The top two are exactly the two net-60 cloud clauses; the net-30 and net-45 clauses rank below
them. Hydrate the winners by joining contract_clauses to suppliers — same bucket, one statement:
Show the supplier name, clause type and text for contract clauses 201 and 206.
data_sql201 CloudNova EMEA Ltd — 'billed monthly with net-60'; 206 CloudNova APAC Pte — 'net-60, in local currency'.
dodil data sql -b "$BUCKET" \
"SELECT c.id, s.name AS supplier, c.clause_type, c.clause_text
FROM contract_clauses c JOIN suppliers s ON s.id = c.supplier_id
WHERE c.id IN (201, 206) ORDER BY c.id"Step 4 — The 3-way-match engine (Ignite + a real handler)
Three-way match is the core AP control: an invoice pays only if it agrees with the PO (was it ordered?), the receipt (did it arrive?), and itself (right price × quantity). It is deterministic rules, not a model — the perfect Ignite workload. An Ignite app is a separate workload, so it needs its own service account to reach DataK3 (client-credentials → bearer). Grant least-privilege roles, inject the creds as runtime env, deploy, then invoke.
Create a service account match-engine-sa, grant it k3.editor on the k3-authorization-service, deploy ./match-engine as a python app with the SA creds + bucket as runtime env, then invoke it to match all unmatched invoices.
auth_service-account_create→auth_service-account_grant-role→ignite_app_deploy→ignite_invokeDeployed match-engine (deployment_state: deployed); invoke reconciled 2 invoices → 7001 matched, 7002 exception_qty.
dodil auth service-account create match-engine-sa # → prints $SA_ID / $SA_SECRET
dodil auth service-account grant-role match-engine-sa k3-authorization-service k3.editor
dodil ignite app deploy match-engine --code ./match-engine --runtime python \
--env DODIL_BUCKET="$BUCKET" \
--env DODIL_SERVICE_ACCOUNT_ID="$SA_ID" \
--env DODIL_SERVICE_ACCOUNT_SECRET="$SA_SECRET"
dodil ignite invoke match-engine --payload '{}' # {} = match every unmatched invoiceThe handler — real, runnable code. ./match-engine/handler.py reconciles PO ↔ receipt ↔ invoice over
one Postgres-wire connection to the bucket and writes match_status back:
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-procurement")
PRICE_TOL = 0.01 # allow rounding noise on the price leg
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 _verdict(po_amount, received_qty, inv_amount, billed_qty):
if po_amount is None: return "exception_no_po" # not ordered
if billed_qty != received_qty: return "exception_qty" # billed != received
if abs(inv_amount - po_amount) > PRICE_TOL: return "exception_price" # price drift
return "matched"
def handler(payload, ctx):
tok = _token()
conn = psycopg.connect(host=PG_HOST, port=PG_PORT, dbname=BUCKET,
user="token", password=tok, sslmode="require")
# one invoice by id, or every still-unmatched invoice
where = "i.id = %(id)s" if payload.get("invoice_id") else "i.match_status = 'unmatched'"
results = []
with conn, conn.cursor() as cur:
cur.execute(f"""
SELECT i.id, po.amount, i.amount, i.billed_qty,
COALESCE(SUM(l.received_qty), 0) AS received_qty
FROM invoices i
LEFT JOIN purchase_orders po ON po.id = i.po_id
LEFT JOIN po_lines l ON l.po_id = i.po_id
WHERE {where}
GROUP BY i.id, po.amount, i.amount, i.billed_qty
""", {"id": payload.get("invoice_id")})
rows = cur.fetchall()
for inv_id, po_amount, inv_amount, billed_qty, received_qty in rows:
status = _verdict(po_amount, received_qty, inv_amount, billed_qty)
cur.execute("UPDATE invoices SET match_status = %s WHERE id = %s", (status, inv_id))
results.append({"invoice_id": inv_id, "match_status": status})
return {"reconciled": len(results), "results": results}requirements.txt is just requests and psycopg[binary]. The exact reconciliation the handler runs,
proven live with a single SQL statement (the same join, verdict inlined):
Reconcile every invoice against its PO and receipt in blog-procurement: matched only if PO amount = invoice amount AND received_qty = billed_qty, else an exception.
data_sql7001 CN-2026-0912 → matched (4200=4200, 20 received = 20 billed); 7002 OR-5567 → exception (10 billed vs 8 received).
dodil data sql -b "$BUCKET" \
"SELECT i.id AS inv, i.invoice_number, po.amount AS po_amt, i.amount AS inv_amt,
l.received_qty, i.billed_qty,
CASE WHEN po.amount = i.amount AND l.received_qty = i.billed_qty
THEN 'matched' ELSE 'exception' END AS verdict
FROM invoices i
JOIN purchase_orders po ON po.id = i.po_id
JOIN po_lines l ON l.po_id = po.id
ORDER BY i.id"Then the engine writes the verdict back — matched clears for payment; the short-received chair
invoice is flagged exception_qty and never auto-pays:
Write the 3-way-match verdicts back onto the invoices: 7001 matched, 7002 exception_qty. Then show the rows.
data_table_update→data_sqlrows_affected: 1 (each). 7001 → matched; 7002 → exception_qty.
dodil data table update invoices -b "$BUCKET" --predicate "id=7001" --updates-json '{"match_status":"matched"}'
dodil data table update invoices -b "$BUCKET" --predicate "id=7002" --updates-json '{"match_status":"exception_qty"}'
dodil data sql -b "$BUCKET" \
"SELECT id, invoice_number, amount, billed_qty, match_status FROM invoices ORDER BY id"Step 5 — The approval gate (Models)
The gate is why this is cheap at scale: instead of every requisition waiting in a buyer's queue, a low-cost model reads the free-text request against policy and returns a structured decision — so routine, low-value spend self-approves and only the material or non-routine requests reach a human. Here is the real call and the real reply for a routine cloud request:
Classify requisition 101 with kimi-k2.6 under policy (auto_approve only if amount < 5000 USD AND category is routine cloud/office; else route_review): 'cloud services, amount 4200 USD'. Return only JSON {decision, reason}.
ignite_models_chat{"decision":"auto_approve","reason":"Routine cloud services under $5000"}
dodil ignite models chat kimi-k2.6 \
--system 'Return ONLY compact JSON: {"decision":"auto_approve"|"route_review","reason":"<=14 words"}. Policy: auto_approve only if amount < 5000 USD AND category is routine (cloud services/office supplies); otherwise route_review.' \
--message 'Requisition #101: cloud services, amount 4200 USD.'Run the gate across the queue and the four requisitions split exactly on policy — the two small routine ones auto-approve, the €96k steel order and the $15k SIEM license route to a buyer:
| Req | Category | Amount | Model decision | → approval_status |
|---|---|---|---|---|
| 101 | cloud services | 4,200 | auto_approve | auto_approved |
| 102 | office supplies | 850 | auto_approve | auto_approved |
| 103 | raw materials | 96,000 | route_review | needs_review |
| 104 | cloud services | 15,000 | route_review | needs_review |
Write each verdict back onto the requisition (partial --merge upsert leaves the other columns
untouched), then confirm the split with a GROUP BY:
Write the gate verdicts back to requisitions.approval_status (101/102 auto_approved, 103/104 needs_review) with the model's reason, then show spend by approval_status.
data_table_upsert→data_sqlUpdated 4 requisitions. auto_approved: 2 reqs / $5,050 · needs_review: 2 reqs / $111,000.
dodil data table upsert requisitions -b "$BUCKET" --merge \
--row '{"id":101,"approval_status":"auto_approved","approval_reason":"Routine cloud services under $5000"}' \
--row '{"id":102,"approval_status":"auto_approved","approval_reason":"Routine category and amount below threshold."}' \
--row '{"id":103,"approval_status":"needs_review","approval_reason":"Amount exceeds 5000 USD and category is non-routine"}' \
--row '{"id":104,"approval_status":"needs_review","approval_reason":"Amount exceeds 5000 USD threshold."}'
dodil data sql -b "$BUCKET" \
"SELECT approval_status, count(*) AS n, sum(amount) AS total FROM requisitions GROUP BY approval_status ORDER BY approval_status"Why the money works: the model auto-cleared $5,050 of routine spend without a human touching it, and escrowed the $111,000 that actually needs judgement — 96% of the dollar value routed to people, 50% of the tickets taken off their desk.
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 end-to-end source-to-pay trail for a requisition is one join across the core:
Trace requisition 101 end to end: its approval_status, the PO it became, and the invoice's match_status.
data_sql101 auto_approved → PO 5001 (open) → invoice 7001 matched — clean, and paid without a human.
dodil data sql -b "$BUCKET" \
"SELECT r.id AS req, r.approval_status, po.id AS po, po.status AS po_status,
i.id AS inv, i.match_status
FROM requisitions r
LEFT JOIN purchase_orders po ON po.requisition_id = r.id
LEFT JOIN invoices i ON i.po_id = po.id
WHERE r.id = 101"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, a spend-BI
dashboard, or a category-manager's notebook at these with zero export:
Print the drop-in pg / bolt / grpc endpoints for blog-procurement so I can point psql and cypher-shell at it.
data_connectpg postgresql://token:…@pg.uk-lon-1.dodil.io:5432/blog-procurement · bolt bolt+s://bolt.uk-lon-1.dodil.io:7687 (database=blog-procurement) · 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 stack
| S2P concern | Coupa/Ariba-style stack | On DataK3 |
|---|---|---|
| Requisition / PO / invoice core | Postgres (transactional S2P) | SQL pillar — requisitions, purchase_orders, po_lines, invoices (merge-keyed) |
| Supplier network + concentration risk | Neo4j | Graph pillar — graph_khop('spend_g', …), subsidiaries in Cypher over Bolt |
| Contract / catalog semantic search | Pinecone / Elasticsearch | Vector pillar — VECTOR(2048) column, data vsearch / pgvector <=> |
| Spend analytics | Nightly ETL → warehouse | One JOIN / GROUP BY — same rows, read-your-writes, no ETL |
| 3-way match + approval automation | Workflow engine + connectors | Ignite handler.py + a kimi-k2.6 gate, one bucket, one auth context |
One bucket, one bill, one auth context — and the concentration-risk traversal, the contract terms, and the invoices they govern are the same live rows, not last night's snapshot spread across three stores plus a percentage-of-spend fee.
Test
Run against live DataK3, then the assertions below hold. What ran live for this post
(tested_at: 2026-09-01, bucket blog-procurement, org IHDIASH): the bucket + all five SQL tables
and the contract_clauses vector table; the spend_g graph; all 6 clause embeddings
(jina-embeddings-v4, dim 2048); the Cypher subsidiary traversal, graph_khop, and the concentration
join; the data vsearch KNN; the kimi-k2.6 approval-gate calls with the verdicts written back; and
the 3-way-match reconciliation with data table update write-back. The handler.py is real and
byte-for-byte the reconciliation executed live as SQL; the ignite app deploy / invoke wrapper is
shown as code.
# 1) 3-way match — expect 7001 matched, 7002 exception (10 billed vs 8 received)
dodil data sql -b "$BUCKET" \
"SELECT i.id, CASE WHEN po.amount=i.amount AND l.received_qty=i.billed_qty
THEN 'matched' ELSE 'exception' END AS verdict
FROM invoices i JOIN purchase_orders po ON po.id=i.po_id
JOIN po_lines l ON l.po_id=po.id ORDER BY i.id"
# 2) Concentration risk — expect cloudnova_group = 22200 = total_cloud (100%)
dodil data pg -b "$BUCKET" \
"SELECT (SELECT sum(amount) FROM edges WHERE rel='spend' AND dst=1001
AND src IN (SELECT src FROM edges WHERE rel='subsidiary_of' AND dst=1)) AS cloudnova_group,
(SELECT sum(amount) FROM edges WHERE rel='spend' AND dst=1001) AS total_cloud"
# 3) Subsidiaries of CloudNova Inc — expect nodes 2, 3, 6
dodil data bolt -b "$BUCKET" -g spend_g "MATCH (b)<-[:edges]-(a) WHERE id(b)=1 RETURN a"
# 4) Contract search — expect clauses 201 and 206 (the two net-60 cloud clauses) nearest
dodil data vsearch -b "$BUCKET" -t contract_clauses --column embedding \
--text "net-60 payment for cloud services" --model jina-embeddings-v4 --metric cosine --top-k 4
# 5) Approval gate rollup — expect auto_approved=2 ($5,050), needs_review=2 ($111,000)
dodil data sql -b "$BUCKET" \
"SELECT approval_status, count(*) AS n, sum(amount) AS total FROM requisitions GROUP BY approval_status"One-shot
Build a source-to-pay platform on one DataK3 bucket named blog-procurement.
1. Create the bucket and merge-keyed tables: suppliers(id,name,parent_id,category,country,status,
risk_score); requisitions(id,requester,supplier_id,category,amount,description,created_at,
approval_status,approval_reason); purchase_orders(id,requisition_id,supplier_id,amount,status,
created_at); po_lines(id,po_id,item,qty,unit_price,received_qty); invoices(id,po_id,supplier_id,
invoice_number,amount,billed_qty,match_status). Use 0/"" sentinels, never JSON null.
2. Upsert 6 suppliers (CloudNova EMEA/APAC/DataGuard all parent_id=1; OfficeReem, SteelForge
independent), 4 requisitions (pending), 2 POs, 2 po_lines (one fully received, one short-received),
2 invoices (unmatched).
3. Build the supplier & spend graph: graph_nodes (suppliers + 3 category nodes) + a single edges table
(rel subsidiary_of | spend) — insert ALL edges, then CREATE GRAPH spend_g over graph_nodes/edges.
Subsidiaries of a parent = MATCH (b)<-[:edges]-(a) WHERE id(b)=$PARENT RETURN a. Concentration =
sum(spend into a category) for the parent group vs total.
4. Contract search: contract_clauses(id,supplier_id,clause_type,clause_text,embedding VECTOR(2048));
embed each clause with jina-embeddings-v4, upsert one row per call, then vsearch --text --model
jina-embeddings-v4 for "net-60 cloud services".
5. Deploy an Ignite match-engine (own service account, k3.editor) whose handler.py joins invoice→PO→
po_lines and writes match_status (matched / exception_qty / exception_price / exception_no_po) back.
6. Run the kimi-k2.6 approval gate over the 4 requisitions (auto_approve if amount<5000 AND routine
category, else route_review) and write approval_status back.
Verify: 7001 matched / 7002 exception_qty; subsidiaries of CloudNova = 2,3,6; cloud concentration 100%;
net-60 search returns clauses 201 & 206; gate rollup auto_approved=2/$5,050, needs_review=2/$111,000.Ship it — make match-engine a public endpoint
The match-engine app above is shown as code. Ship it the last mile so users hit a real URL, not a
snippet — the managed runtime compiles your handler.py and returns a public FQDN:
Deploy the match-engine app publicly with no auth and give me its URL.
ignite_app_deploy→ignite_app_getDeployed match-engine (deployment_state: deployed). Its public FQDN on ignite.dodil.cloud is callable with no token — or call it with dodil ignite invoke match-engine.
dodil ignite app deploy match-engine --code ./match-engine --runtime python --allow-unauthenticated
dodil ignite app get match-engine --output json # → public_urlsThat's the quick path (managed compile). The full supply chain — DODIL git → CI checks → a scanned image in the DODIL registry → versioning and one-command rollback — is its own tutorial: Ship a DODIL App.
Conclusion
You now have a one-bucket source-to-pay platform — the requisition → PO → receipt → invoice SQL core,
a supplier & spend graph that turns three vendor names into one 100%-concentration parent group in a
single query, contract-clause vector search over the same rows, a deterministic Ignite 3-way-match
engine that clears clean invoices and flags the exceptions, and a kimi-k2.6 approval gate that
self-approves routine spend. No Postgres + Pinecone + Neo4j + warehouse + the ETL between them — and no
percentage-of-spend fee on top: one copy of the rows, three query pillars, one bill.
The reusable skill: model a transaction-and-relationship-heavy enterprise system as one DataK3 bucket, running deterministic controls (3-way match) on Ignite and judgement calls (approval) through a model, while SQL, graph, and vector share the same rows. See also the sibling builds a ServiceNow-style ITSM and a cost-gated CRM lead pipeline — same one-bucket, three-pillar pattern, different domain.