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
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-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.
Create a DataK3 bucket called blog-expense, then merge-keyed tables: employees, expense_reports, expense_lines (with a VECTOR(2048) embedding column), receipts, policies.
data_bucket_create→data_table_createCreated bucket blog-expense (status ACTIVE). Tables employees, expense_reports, expense_lines, receipts, policies created, each with PRIMARY KEY (id).
dodil data bucket create "$BUCKET" --description "Expense management system of record"
# employees — manager_id is the reporting edge (the graph in Step 3); approval_limit gates sign-off.
dodil data table create employees -b "$BUCKET" --merge-key id \
--columns-json '[{"name":"id","type":"bigint"},{"name":"name","type":"varchar"},{"name":"email","type":"varchar"},{"name":"department","type":"varchar"},{"name":"manager_id","type":"bigint"},{"name":"cost_center","type":"varchar"},{"name":"approval_limit","type":"double"}]'
# expense_reports — the submit→approve→reimburse envelope. approver_id/reimbursed_at fill in as it moves.
dodil data table create expense_reports -b "$BUCKET" --merge-key id \
--columns-json '[{"name":"id","type":"bigint"},{"name":"employee_id","type":"bigint"},{"name":"title","type":"varchar"},{"name":"status","type":"varchar"},{"name":"total_amount","type":"double"},{"name":"submitted_at","type":"varchar"},{"name":"approver_id","type":"bigint"},{"name":"reimbursed_at","type":"varchar"}]'
# expense_lines — one row per line item. embedding = VECTOR(2048); violation/policy_status are written
# by the Ignite engine (Step 4) and the Models gate (Step 5).
dodil data table create expense_lines -b "$BUCKET" --merge-key id \
--columns-json '[{"name":"id","type":"bigint"},{"name":"report_id","type":"bigint"},{"name":"category","type":"varchar"},{"name":"amount","type":"double"},{"name":"merchant","type":"varchar"},{"name":"description","type":"varchar"},{"name":"expense_date","type":"varchar"},{"name":"has_receipt","type":"boolean"},{"name":"violation","type":"boolean"},{"name":"policy_status","type":"varchar"},{"name":"policy_reason","type":"varchar"},{"name":"embedding","type":"VECTOR(2048)"}]'
# receipts — the OCR'd image behind a line (object_key points at the raw file in the bucket).
dodil data table create receipts -b "$BUCKET" --merge-key id \
--columns-json '[{"name":"id","type":"bigint"},{"name":"line_id","type":"bigint"},{"name":"report_id","type":"bigint"},{"name":"merchant","type":"varchar"},{"name":"amount","type":"double"},{"name":"receipt_date","type":"varchar"},{"name":"ocr_text","type":"varchar"},{"name":"object_key","type":"varchar"}]'
# policies — the cap table the engine + gate check against.
dodil data table create policies -b "$BUCKET" --merge-key id \
--columns-json '[{"name":"id","type":"bigint"},{"name":"category","type":"varchar"},{"name":"cap_amount","type":"double"},{"name":"receipt_required_over","type":"double"},{"name":"notes","type":"varchar"}]'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.
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).
data_table_upsertUpserted 6 employees and 5 policies (wal_written: true).
# manager_id = 0 means top-level; a positive id points at the approver above you.
dodil data table upsert employees -b "$BUCKET" \
--row '{"id":1,"name":"Dana Cole","email":"[email protected]","department":"Executive","manager_id":0,"cost_center":"CC-100","approval_limit":1000000}' \
--row '{"id":2,"name":"Victor Ng","email":"[email protected]","department":"Finance","manager_id":1,"cost_center":"CC-200","approval_limit":50000}' \
--row '{"id":3,"name":"Ana Ruiz","email":"[email protected]","department":"Sales","manager_id":2,"cost_center":"CC-300","approval_limit":5000}' \
--row '{"id":4,"name":"Tom Blake","email":"[email protected]","department":"Sales","manager_id":3,"cost_center":"CC-300","approval_limit":0}' \
--row '{"id":5,"name":"Priya Shah","email":"[email protected]","department":"Sales","manager_id":3,"cost_center":"CC-300","approval_limit":0}' \
--row '{"id":6,"name":"Leo Marsh","email":"[email protected]","department":"Engineering","manager_id":3,"cost_center":"CC-300","approval_limit":0}'
dodil data table upsert policies -b "$BUCKET" \
--row '{"id":1,"category":"meals","cap_amount":75,"receipt_required_over":25,"notes":"Per-meal cap $75; receipt required over $25."}' \
--row '{"id":2,"category":"lodging","cap_amount":300,"receipt_required_over":0,"notes":"Nightly cap $300; receipt always required."}' \
--row '{"id":3,"category":"airfare","cap_amount":1200,"receipt_required_over":0,"notes":"Economy up to $1200; receipt always required."}' \
--row '{"id":4,"category":"ground_transport","cap_amount":100,"receipt_required_over":25,"notes":"Taxi/rideshare cap $100; receipt over $25."}' \
--row '{"id":5,"category":"software","cap_amount":500,"receipt_required_over":0,"notes":"SaaS/tools cap $500; receipt always required."}'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.
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.
data_table_upsertUpserted 3 reports and 8 lines (wal_written: true). 401/404 are the duplicated dinner; 407/408 the split.
dodil data table upsert expense_reports -b "$BUCKET" \
--row '{"id":301,"employee_id":4,"title":"Client dinner + travel, Berlin","status":"submitted","total_amount":0,"submitted_at":"2026-08-24","approver_id":0,"reimbursed_at":""}' \
--row '{"id":302,"employee_id":5,"title":"Berlin offsite expenses","status":"submitted","total_amount":0,"submitted_at":"2026-08-25","approver_id":0,"reimbursed_at":""}' \
--row '{"id":303,"employee_id":4,"title":"August software + meals","status":"submitted","total_amount":0,"submitted_at":"2026-08-26","approver_id":0,"reimbursed_at":""}'
# lines carry category/amount/merchant/description; embedding is added in Step 2.
dodil data table upsert expense_lines -b "$BUCKET" \
--row '{"id":401,"report_id":301,"category":"meals","amount":92.40,"merchant":"Nusr-Et Steakhouse","description":"Client dinner at Nusr-Et Steakhouse, Berlin","expense_date":"2026-08-22","has_receipt":true,"violation":false,"policy_status":"unchecked","policy_reason":""}' \
--row '{"id":402,"report_id":301,"category":"airfare","amount":640.00,"merchant":"Lufthansa","description":"Round-trip economy flight FRA to TXL","expense_date":"2026-08-20","has_receipt":true,"violation":false,"policy_status":"unchecked","policy_reason":""}' \
--row '{"id":403,"report_id":301,"category":"ground_transport","amount":38.00,"merchant":"Berlin Funk Taxi","description":"Taxi from airport to hotel","expense_date":"2026-08-20","has_receipt":false,"violation":false,"policy_status":"unchecked","policy_reason":""}'
# …plus 404 (Priya's duplicate Nusr-Et dinner, 92.40), 405 (Hotel Adlon 280), 406 (GitHub 44),
# 407 + 408 (Figma 300 each — the split). See the One-shot for all eight.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.
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.
ignite_models_embed→data_table_upsertEmbedded 8 lines with jina-embeddings-v4 (dim 2048); upserted each vector into expense_lines.embedding.
# embed one line's text → a pgvector literal, then merge just the embedding column onto the row.
EMB=$(dodil ignite models embed jina-embeddings-v4 \
--input "Nusr-Et Steakhouse. Client dinner at Nusr-Et Steakhouse, Berlin. Amount 92.40 USD." \
--output json | jq -c '.data.data[0].embedding')
dodil data table upsert expense_lines -b "$BUCKET" --merge \
--row "{\"id\":401,\"embedding\":$EMB}"
# …repeat for lines 402-408 (one row per call keeps the write frame small).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:
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.
data_sql404 (0.0019, Priya's report 302 — the duplicate), then 405 (0.250), 402 (0.335), 403 (0.340), 406 (0.423).
dodil data sql -b "$BUCKET" \
"SELECT id, report_id, merchant, amount,
embedding <=> (SELECT embedding FROM expense_lines WHERE id=401) AS dist
FROM expense_lines WHERE id <> 401 ORDER BY dist LIMIT 5"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:
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.
data_sql401↔404 (dist 0.0019, reports 301↔302 — duplicate dinner) and 407↔408 (dist 0.078, report 303 — split Figma purchase).
dodil data sql -b "$BUCKET" \
"SELECT a.id AS line_a, a.report_id AS rpt_a, b.id AS line_b, b.report_id AS rpt_b,
a.merchant, a.amount, 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"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.
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).
data_pgGraph approval_g created over emp_node / reports_to (6 nodes, 5 edges).
dodil data pg -b "$BUCKET" "CREATE TABLE emp_node (id BIGINT PRIMARY KEY, name VARCHAR, role VARCHAR, approval_limit DOUBLE)"
dodil data pg -b "$BUCKET" "CREATE TABLE reports_to (src BIGINT, dst BIGINT, PRIMARY KEY (src,dst))"
dodil data pg -b "$BUCKET" "INSERT INTO emp_node VALUES (1,'Dana Cole','CFO',1000000),(2,'Victor Ng','VP Finance',50000),(3,'Ana Ruiz','Sales Manager',5000),(4,'Tom Blake','Sales Rep',0),(5,'Priya Shah','Sales Rep',0),(6,'Leo Marsh','Engineer',0)"
# reports_to: employee -> manager. Insert every edge BEFORE CREATE GRAPH.
dodil data pg -b "$BUCKET" "INSERT INTO reports_to VALUES (4,3),(5,3),(6,3),(3,2),(2,1)"
dodil data pg -b "$BUCKET" "CREATE GRAPH approval_g NODES (emp_node KEY id) EDGES (reports_to SRC src DST dst)"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):
In graph approval_g, who is above Tom Blake (node 4) in the reporting chain, up to 5 hops?
data_bolthop 1: node 3 (Ana Ruiz), hop 2: node 2 (Victor Ng), hop 3: node 1 (Dana Cole).
dodil data bolt -b "$BUCKET" -g approval_g "MATCH (a)-[:reports_to*1..5]->(b) WHERE id(a)=4 RETURN b"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:
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.
data_pg$612 → Ana Ruiz (hop 1, limit 5,000). $52,000 → escalates past Victor (50,000) to Dana Cole (hop 3, limit 1,000,000).
# $612 report → the nearest manager who can sign it
dodil data pg -b "$BUCKET" \
"SELECT k.hop_distance, n.name, n.role, n.approval_limit
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"
# $52,000 report → same query, higher threshold — escalates to the CFO
dodil data pg -b "$BUCKET" \
"SELECT k.hop_distance, n.name, n.role, n.approval_limit
FROM graph_khop('approval_g', 4, 5) k JOIN emp_node n ON n.id = k.node
WHERE n.approval_limit >= 52000 ORDER BY k.hop_distance LIMIT 1"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.
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.
auth_service-account_create→auth_service-account_grant-role→ignite_app_deploy→ignite_invokeDeployed policy-engine (deployment_state: deployed); invoke flagged 3 lines (401 over_cap, 403 missing_receipt, 404 over_cap) and 2 duplicate pairs.
dodil auth service-account create policy-engine-sa # → prints $SA_ID / $SA_SECRET
dodil auth service-account grant-role policy-engine-sa k3-authorization-service k3.editor
dodil auth service-account grant-role policy-engine-sa ignite-authorization-service ignite.developer
dodil ignite app deploy policy-engine --code ./policy-engine --runtime python \
--env DODIL_BUCKET="$BUCKET" \
--env DODIL_SERVICE_ACCOUNT_ID="$SA_ID" \
--env DODIL_SERVICE_ACCOUNT_SECRET="$SA_SECRET"
dodil ignite invoke policy-engine --payload '{}' # {} = check every unchecked lineThe 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:
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.
data_sql401 meals 92.4 → over_cap; 403 taxi 38 no-receipt → missing_receipt; 404 meals 92.4 → over_cap; 402/405/406/407/408 → ok.
dodil data sql -b "$BUCKET" \
"SELECT l.id, l.category, l.amount, p.cap_amount, l.has_receipt,
CASE WHEN l.amount > p.cap_amount THEN 'over_cap'
WHEN l.amount > p.receipt_required_over AND NOT l.has_receipt THEN 'missing_receipt'
ELSE 'ok' END AS check
FROM expense_lines l JOIN policies p ON p.category = l.category ORDER BY l.id"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:
Write violation=true onto lines 401, 403, 404, then show flagged lines and flagged amount per report with the employee name.
data_table_upsert→data_sqlUpdated 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.
dodil data table upsert expense_lines -b "$BUCKET" --merge \
--row '{"id":401,"violation":true}' --row '{"id":403,"violation":true}' --row '{"id":404,"violation":true}'
dodil data sql -b "$BUCKET" \
"SELECT r.id AS report, e.name AS employee,
count(*) FILTER (WHERE l.violation) AS flagged_lines,
sum(l.amount) FILTER (WHERE l.violation) AS flagged_amount,
sum(l.amount) AS report_total
FROM expense_reports r JOIN employees e ON e.id=r.employee_id
JOIN expense_lines l ON l.report_id=r.id
GROUP BY r.id, e.name ORDER BY r.id"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:
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.
ignite_models_chat401 → {"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."}
SYS='Return ONLY compact JSON {"policy_status":"compliant"|"violation"|"needs_review","reason":"<=14 words"}. Policy caps: meals $75, lodging $300/night, airfare $1200, ground_transport $100, software $500. A line over its cap = violation. A duplicate/split of another line = needs_review.'
dodil ignite models chat kimi-k2.6 --system "$SYS" \
--message 'Line 401: meals, Nusr-Et Steakhouse, amount 92.40 USD, has receipt.'
# → {"policy_status":"violation","reason":"Meal expense exceeds $75 policy cap."}
dodil ignite models chat kimi-k2.6 --system "$SYS" \
--message 'Line 407: software, Figma design license, amount 300.00 USD. NOTE: a near-duplicate line 408 (Figma, 300.00) on the same report; combined 600 exceeds the 500 software cap.'
# → {"policy_status":"needs_review","reason":"Near-duplicate split with line 408 exceeds software cap"}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:
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.
data_table_upsert→data_sqlUpdated 8 lines. compliant: 3 lines / $964 · needs_review: 2 / $600 · violation: 3 / $222.80.
dodil data table upsert expense_lines -b "$BUCKET" --merge \
--row '{"id":401,"policy_status":"violation","policy_reason":"Meal expense exceeds $75 policy cap."}' \
--row '{"id":403,"policy_status":"violation","policy_reason":"Missing receipt for taxi over $25."}' \
--row '{"id":404,"policy_status":"violation","policy_reason":"Exceeds $75 meal cap; duplicate of line 401."}' \
--row '{"id":407,"policy_status":"needs_review","policy_reason":"Near-duplicate split with line 408 exceeds software cap."}' \
--row '{"id":408,"policy_status":"needs_review","policy_reason":"Near-duplicate split with line 407 exceeds software cap."}' \
--row '{"id":402,"policy_status":"compliant","policy_reason":"Airfare within policy limit."}' \
--row '{"id":405,"policy_status":"compliant","policy_reason":"Lodging within nightly cap."}' \
--row '{"id":406,"policy_status":"compliant","policy_reason":"Software within cap."}'
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"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:
For report 301 in blog-expense, show each line's category, amount, and policy_status, plus the report's approver from the graph.
data_sql301: 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).
dodil data sql -b "$BUCKET" \
"SELECT l.id, l.category, l.amount, l.policy_status
FROM expense_lines l WHERE l.report_id = 301 ORDER BY l.id"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:
Print the drop-in pg / bolt / grpc endpoints for blog-expense so I can point psql and cypher-shell at it.
data_connectpg 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
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
| Expense concern | Concur-style stack | On DataK3 |
|---|---|---|
| Employees / reports / lines / receipts core | Postgres (transactional expense DB) | SQL pillar — employees, expense_reports, expense_lines, receipts, policies (merge-keyed) |
| Duplicate & split-expense detection | Pinecone / a receipt-hash service | Vector pillar — VECTOR(2048) column, <=> self-join / data vsearch |
| Approval routing by amount + hierarchy | A workflow engine | Graph pillar — graph_khop('approval_g', …) + a limit filter, chain in Cypher over Bolt |
| Spend analytics / leakage reporting | Nightly ETL → warehouse | One JOIN / GROUP BY — same rows, read-your-writes, no ETL |
| Policy check + classification | Rules engine + connectors | Ignite 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:
Deploy the policy-engine app publicly with no auth and give me its URL.
ignite_app_deploy→ignite_app_getDeployed 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.
dodil ignite app deploy policy-engine --code ./policy-engine --runtime python --allow-unauthenticated
dodil ignite app get policy-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 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).