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

You

Create a DataK3 bucket called blog-procurement, then merge-keyed tables: suppliers, requisitions, purchase_orders, po_lines, invoices.

DODIL MCP tools called
data_bucket_createdata_table_create
Agent

Created bucket blog-procurement (status ACTIVE). Tables suppliers, requisitions, purchase_orders, po_lines, invoices created, each with PRIMARY KEY (id).

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.

You

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.

DODIL MCP tools called
data_table_upsert
Agent

Upserted 6 suppliers (wal_written: true).

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.

You

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.

DODIL MCP tools called
data_table_upsert
Agent

Upserted 4 requisitions, 2 POs, 2 po_lines, 2 invoices (wal_written: true).

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.

You

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

DODIL MCP tools called
data_pg
Agent

Graph spend_g created over graph_nodes / edges (9 nodes, 8 edges).

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:

You

In graph spend_g, which suppliers are subsidiaries of CloudNova Inc (node 1)? Anchor node 1 and follow the incoming subsidiary edges.

DODIL MCP tools called
data_bolt
Agent

neighbor: 6 (DataGuard), 3 (CloudNova APAC), 2 (CloudNova EMEA) — three vendor names, one parent.

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:

You

From CloudNova EMEA (node 2) in spend_g, what does it connect to within 2 hops? Hydrate names from graph_nodes.

DODIL MCP tools called
data_pg
Agent

hop 1: CloudNova Inc (parent), cloud services (category it spends on).

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:

You

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

DODIL MCP tools called
data_pg
Agent

CloudNova group cloud spend = 22,200 of 22,200 total — 100% of cloud spend is one corporate group behind three vendor names.

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.

You

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.

DODIL MCP tools called
data_table_createignite_models_embeddata_table_upsert
Agent

Created contract_clauses; upserted 6 clauses, each with a 2048-dim jina-embeddings-v4 vector.

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:

You

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.

DODIL MCP tools called
data_vsearch
Agent

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

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

You

Show the supplier name, clause type and text for contract clauses 201 and 206.

DODIL MCP tools called
data_sql
Agent

201 CloudNova EMEA Ltd — 'billed monthly with net-60'; 206 CloudNova APAC Pte — 'net-60, in local currency'.

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.

You

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.

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

Deployed match-engine (deployment_state: deployed); invoke reconciled 2 invoices → 7001 matched, 7002 exception_qty.

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

You

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.

DODIL MCP tools called
data_sql
Agent

7001 CN-2026-0912 → matched (4200=4200, 20 received = 20 billed); 7002 OR-5567 → exception (10 billed vs 8 received).

Then the engine writes the verdict back — matched clears for payment; the short-received chair invoice is flagged exception_qty and never auto-pays:

You

Write the 3-way-match verdicts back onto the invoices: 7001 matched, 7002 exception_qty. Then show the rows.

DODIL MCP tools called
data_table_updatedata_sql
Agent

rows_affected: 1 (each). 7001 → matched; 7002 → exception_qty.

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:

You

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

DODIL MCP tools called
ignite_models_chat
Agent

{"decision":"auto_approve","reason":"Routine cloud services under $5000"}

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:

ReqCategoryAmountModel decisionapproval_status
101cloud services4,200auto_approveauto_approved
102office supplies850auto_approveauto_approved
103raw materials96,000route_reviewneeds_review
104cloud services15,000route_reviewneeds_review

Write each verdict back onto the requisition (partial --merge upsert leaves the other columns untouched), then confirm the split with a GROUP BY:

You

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.

DODIL MCP tools called
data_table_upsertdata_sql
Agent

Updated 4 requisitions. auto_approved: 2 reqs / $5,050 · needs_review: 2 reqs / $111,000.

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:

You

Trace requisition 101 end to end: its approval_status, the PO it became, and the invoice's match_status.

DODIL MCP tools called
data_sql
Agent

101 auto_approved → PO 5001 (open) → invoice 7001 matched — clean, and paid without a human.

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:

You

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

DataK3 vs. the multi-system stack

S2P concernCoupa/Ariba-style stackOn DataK3
Requisition / PO / invoice corePostgres (transactional S2P)SQL pillar — requisitions, purchase_orders, po_lines, invoices (merge-keyed)
Supplier network + concentration riskNeo4jGraph pillar — graph_khop('spend_g', …), subsidiaries in Cypher over Bolt
Contract / catalog semantic searchPinecone / ElasticsearchVector pillar — VECTOR(2048) column, data vsearch / pgvector <=>
Spend analyticsNightly ETL → warehouseOne JOIN / GROUP BY — same rows, read-your-writes, no ETL
3-way match + approval automationWorkflow engine + connectorsIgnite 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:

You

Deploy the match-engine app publicly with no auth and give me its URL.

DODIL MCP tools called
ignite_app_deployignite_app_get
Agent

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

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