Ekho-Labs / ton-app · pull request #3

M2 Ingestion
a spreadsheet, then no spreadsheet

A messy real-world workbook becomes a normalized monthly demand history through a two-stage pipeline — Worker orchestrates, Modal parses — and the original file is deleted at the instant the extraction becomes durable. The mapping the engine proposes is always shown before it is used.

branchmilestone/m2-ingestion
basemain
headb4a32b6
commits5
files42 changed
diff+4,374 −57
merged2026-08-14
M2 gate PASS · 5/5 fixtures
00

What M2 actually built

After M1 the product had users and no data. M2 is the whole path from a messy spreadsheet a planner already owns to a normalized monthly demand history — and the moment where the original file is destroyed.

01 · adaptor

A vendored, hardened header engine

headers.py and coerce.py come from the grocery-outlet demo with attribution intact, extended only inside marked blocks with a CUSTOMER kind and ton-app's canonical schema. Header text alone never decides a column.

headers.py 689coerce.py 403 5 real workbooksfixture tests
02 · pipeline

Two stages, two signed crossings

The Worker orchestrates and Modal computes. The raw file travels out over a §0.3-signed URL and the result comes back over a §0.3-signed callback that is idempotent by construction — the same primitive M0 built and proved.

R2D1 Modal proxy authreplay-safe
03 · review

Mapping the user can argue with

Seven canonical fields, a confidence badge on each, an 8-row preview of what was read, and a data-quality report of everything dropped, merged or renamed — all before the mapping is confirmed.

7 fields0.70 threshold click to confirmDQ report
r2
ton-app-dev
uploads/ then datasets/ — never both
d1
datasets · jobs
migration 0002_mapping
kv
map:<user>:<hash>
suggestion cache, 24 h TTL
modal
parse endpoint
proxy auth, stateless, no CF credential
openai
bounded fallback
LLM_MODE = live | stub | off
01

The two-stage pipeline

One upload crosses the trust boundary twice: outbound with proxy credentials, inbound with a signed callback. Hover any node to isolate its edges. The red edges are the authenticated crossings; the amber node is where the uploaded file ceases to exist.

call chain authenticated crossing response state / cache hover a node to isolate · scroll horizontally if clipped
tier 1Browser
screen
UploadPage.tsx
Picks a workbook, POSTs it as multipart, then polls the job. The retention promise is on screen before the file leaves the browser.
screen
MappingPage.tsx
Confidence badges per field, click-to-apply suggestions, an 8-row preview and the DQ report. Confirming is the only way forward.
tier 2 · ingestCloudflare Worker
route
POST /api/datasets
Extension allow-list, 20 MB cap, R2.put(uploads/…/raw), D1 rows, then invokes Modal. Answers 202 with dataset_id.
guard
GET /internal/…/raw
§0.3 signature only — exactly exp and sig, no other query parameter accepted. Streams the workbook to Modal.
401
callback
POST …/parse-result
Writes extracted.json, then attempts the one state-conditional transition. A replayed callback changes nothing.
the promise
R2.delete(raw)
Reached only when the transition actually changed a row and debug retention is off. This is where the uploaded file stops existing.
tier 2 · reviewCloudflare Worker
route
GET …/mapping
Reads extracted.json, computes the unresolved set (column null or confidence < 0.70) and returns proposal + preview + DQ.
fallback
llm/mapping.ts
Bounded by construction: only the unresolved fields, one call, cached per user. LLM_MODE=stub on previews spends nothing.
route
POST …/mapping
Every column index is range-checked against the header count; sku plus either period+ordered or month columns is mandatory.
Why the raw file is fetched rather than pushed

The Worker never sends the workbook to Modal. It sends a path, and Modal comes back for the bytes over a URL it has to sign itself. That keeps the 20 MB body out of the outbound invocation, keeps Modal stateless, and means the only credential in the container is the shared HMAC secret — no Cloudflare token ever leaves the edge.

02

The retention promise, in three lines

The product tells the client their file is parsed and then deleted. That sentence is enforced in exactly one place, and it is bound to a state transition rather than to the arrival of a message.

// worker/routes/internal.ts — POST /internal/datasets/:id/parse-result R2.put(`datasets/${uid}/${ds}/extracted.json`, extraction + proposal + dq) const updated = UPDATE jobs SET status = 'awaiting_mapping' WHERE dataset_id = ? AND stage = 'parse' AND status = 'running' if (updated.meta.changes > 0) { UPDATE datasets SET status, months, series_count, dq_summary_json if (!debugRetention) await R2.delete(`uploads/${uid}/${ds}/raw`) ← the promise } The WHERE status = 'running' clause is the whole idempotency argument: the transition can succeed exactly once, and every side effect — the dataset row, the summary counters, the deletion — hangs off the row count it returns.
A replayed callback is a no-op. Second delivery finds awaiting_mapping, changes 0 rows, touches nothing.
Failure takes the same shape. The error path is its own conditional UPDATE off running, so a late success cannot resurrect a failed job.
Deletion is not a timer. The file is gone at the instant the extraction is durable, not 24 hours later.
Debug retention is the only exception — a qa-role, time-boxed flag added in M5, swept automatically and still under M0's 9-day bucket lifecycle backstop.
03

What the deterministic engine decides

Everything below happens before any model is asked anything. The LLM only ever sees the columns this pass could not resolve.

pythonton_app/parse.py · propose_mapping
# header text AND column contents both score
mapping = auto_map(raw_headers, columns)
months  = [i for i, h in headers if looks_like_month(h)]

MappingProposal(
  sku, customer, period, ordered, invoiced, name, uom,
  month_columns = months,
  # a wide sheet is 3+ month columns and no quantity column
  wide = len(months) >= 3 and ordered.column is None,
  value_check_rejections = mapping.value_check_rejections,
)
pythonton_app/parse.py · apply_mapping
for row in raw_body_rows:
    if is_total_row(row): totals_skipped += 1; continue
    # wide -> one output row per month column
    period = to_period_key(header or cell)
    quantity = parse_number(cell)      # None -> invalid_rows
    customer = text or "Unassigned"

retained  = source_months[-36:]        # disclosed in the DQ report
aggregate = sum duplicates per (sku, customer, period)

The data-quality report

Returned with the proposal, rendered next to it, and stored on the dataset row.
fieldwhat it countswhy the user cares
seriesdistinct (sku, customer) pairs keptThe unit everything downstream forecasts.
thin_seriesseries with fewer than 3 positive monthsWarns before M3 hands them a named fallback instead of a model.
months_retained
total_source_months
window kept vs window foundSays out loud that only the last 36 months were used.
duplicates_summedrows merged into an existing (sku, customer, period)Explains why a total on screen differs from the source sheet.
totals_skippedsubtotal / grand-total rows discardedThe classic double-count; fixture 1 asserts exactly one.
invalid_rowsunparseable period or quantitySilent drops become a number instead of a mystery.
unassigned_customer_rowsrows with no customer column valueTells the planner their file is SKU-level, not account-level.
zero_share · zero_rows
omits_zeros
sparsity of the completed panelDistinguishes “no demand” from “row not present” — the difference M3's SBC routing is built on.
invoiced_presentwhether an invoiced column was mappedGates the fill-rate exception M4 can raise.
04

The gate, and its evidence

M2's exit condition is the five real messy workbooks, end to end, plus the browser flow a client would actually walk. Both were driven against a real running Worker — miniflare R2/D1/KV with the real Python parse job behind a local transport — not a mock.

assertion 1pass

Five workbooks, four assertions each

scripts/qa/m2.sh uploads every fixture, polls to awaiting_mapping, checks the proposal resolved the mandatory columns for that shape, and — the important one — re-fetches the raw file over a freshly signed URL and requires a 404.

scripts/qa/m2.sh — the assertions it prints
$ bash scripts/qa/m2.sh <base-url> -- 1-forecast-wide-messy.xlsx job -> awaiting_mapping proposal resolved (sku=… wide=true) totals row skipped exactly once raw deleted (signed fetch -> 404) -- 2-sales-history-long.xlsx … 5-awkward-headers.xlsx M2 QA gate PASS

The 404 is checked with a valid signature, so it proves the object is gone rather than that the request was refused.

assertion 2pass

The flow a client walks

Login → upload → mapping review → confirm, driven in real Chromium against the same stack, watching the console and the layout rather than just the API responses.

quoted from the PR body Verified end to end locally against a real running Worker (miniflare R2/D1/KV + the real Python parse job driven through a local transport): scripts/qa/m2.sh PASSES all five fixtures, and the full browser flow (login → upload → mapping review → confirm) was driven in real Chromium with zero console errors, responsive to 360 px.

Fixture 1 is the adversarial one: a wide sheet with month columns, a title row above the header row, and a grand-total row that must be counted exactly once as skipped — the assertion is dq.totals_skipped == 1, not >= 1.

What the gate could not run at merge time

The CI M2 gate runs against a deployed preview, and every route it touches needs R2 — which was not enabled on the Cloudflare account when this branch was written. The PR shipped saying so. R2 has since been enabled and commit f900269 on main turned the bindings on for both environments. See finding R1.

05

What landed, by area

42 files, +4,374 −57. The two largest source files are the vendored adaptor; the largest single file is a real 528-line demand-history fixture.

modal · engine9 files · 1,881
  • adaptor/headers.py689
  • adaptor/coerce.py403
  • ton_app/parse.py245
  • uv.lock237
  • ton_app/jobs.py112
  • ton_app/parse_models.py83
  • tests/test_parse.py73
  • tests/test_headers.py31
  • ton_app/app.py25
worker7 files · 313
  • routes/mapping.ts138
  • llm/mapping.ts100
  • routes/uploads.ts75
  • routes/internal.ts74
  • routes/jobs.ts20
  • index.ts · modal.ts · env.ts17
  • migrations/0002_mapping.sql1
spa7 files · 1,215
  • src/styles.css532
  • upload/MappingPage.tsx240
  • upload/MappingTable.tsx179
  • upload/UploadPage.tsx140
  • upload/DataQualityReport.tsx105
  • api/client.ts76
  • upload/PreviewTable.tsx33
fixtures6 files · 612
  • 3-demand-history.csv528
  • README.md84
  • 1-forecast-wide-messy.xlsxbin
  • 2-sales-history-long.xlsxbin
  • 4-forecast-wide-datecols.xlsxbin
  • 5-awkward-headers.xlsxbin
gate & ci4 files · 86
  • scripts/qa/m2.sh67
  • .github/workflows/ci.yml9
  • .github/workflows/deploy-dev.yml9
  • scripts/gen-pr-config.mjs1
config5 files · 47
  • src/dashboard/DashboardPage.tsx31
  • wrangler.jsonc6
  • modal-app/pyproject.toml4
  • src/App.tsx4
  • .gitignore1
06

Findings & design notes

One blocker that shipped open and has since closed, plus the five decisions in this branch worth knowing before touching it. Click any row to expand.

resolved open notes
07

Where this leaves the plan

M2 turns a file into a forecastable panel

The milestone's real output is not the upload screen — it is a normalized, deduplicated, window-bounded monthly demand history whose every discrepancy against the source workbook is counted and shown. M3 can therefore assume its input is clean and argue only about models, and the client-facing retention sentence has a single, replay-safe enforcement point rather than a policy document.

deliberately not here
  • No forecasting — confirming a mapping only stores it; M3 wires the forecast job.
  • No export, no deletion UI, no retention sweeper — M5.
  • No narratives; the only model call is the bounded column-mapping fallback.
  • Nothing is cached at the edge yet; every read is no-store.
what M3 inherits
  • extracted.json as the single input contract, fetched over a signed URL.
  • The idempotent callback shape, copied verbatim for forecast-result.
  • A DQ report that already says which series are too thin to model.
  • The same §0.3 primitive, now exercised by two jobs instead of a ping.