The mailbox-driven path into the Bid Center
Of the three Bid Center intake paths, Path 1 is the asynchronous one. A customer (or a vendor on behalf of a program) emails a bid, RFP, or pricing update to bids@ai-globalfoodsolutions.co. Cloudflare Email Routing forwards the message to the Worker, where src/email.ts handles the inbound. Headers + body are parsed, supported attachments (PDF / XLSX) are detected and routed through document_converter to Markdown, and the source bytes are persisted to the bid-attachments R2 bucket keyed by email_id.
Customer identity is then resolved via name_synonyms.json — exact match first, then alias, then fuzzy (Levenshtein). Confidence ≥ 0.85 auto-suggests; anything below forces Mike to pick. The customer's latest sy_version is read from bid_quote_versions (or a fresh SY-version-v0.1 is minted if first-time), and the pair customer_id + sy_version becomes the trace thread for every downstream row — the Bid Center equivalent of the SO PO# (otherrefnum) thread.
A single proposed_actions row stages the whole intake for HITL approval (ADR-031). On approve, a chat session is opened with the bid Markdown, customer.json, and the latest bid_quote_versions row preloaded into context, and the dispatcher fires one of the 11 standardized skills (LOG_EXTERNAL_BID, ADD_UPDATE_CUSTOMER_PRICING, …). Outputs are 8 D1 bid_* tables (migration 136), a regenerated .docx quote, a refreshed STATUS.json, and a re-rendered dashboard at gfs-pricing.pages.dev. No NetSuite write happens here — NS writes are deferred to Path 3 (Jul 1 rollover).
Diagram: ns-bid-center-path-1-email-intake.html. Sibling paths: Path 2 direct chat · Path 3 July 1 rollover. Master: master wiki.
Trigger conditions
- Any customer or vendor emails a bid, RFP, or pricing update to
bids@ai-globalfoodsolutions.co. - An external RFP / ITB arrives that needs logging into the 44-bid pipeline.
- A school-year customer sends a re-quote request (price refresh mid-year).
- An auto-forward from another inbox lands in
bids@with a parseable attachment.
Path 1 only writes to D1 + R2 + folder + dashboard. NetSuite writes are deferred to Path 3 (Jul 1 rollover) where the year of accumulated SY-versions becomes named price lists. This is intentional — Path 1 captures & stages, Path 3 commits.
From the moment the email is parsed, customer_id + sy_version is the trace thread. Sample: ACC Distributors / SY2627-v2.5. Grep this pair across proposed_actions, bid_quote_versions, bid_price_snapshots, and STATUS.json for a complete audit trail — analogous to the SO otherrefnum (PO#) thread.
ACC Distributors emails a cheddar re-quote
April 14, 2026, 10:42 AM. Cheddar cost moved 8% in the prior two weeks. ACC Distributors' AP clerk emails bids@ai-globalfoodsolutions.co with subject "Price refresh request — cheddar items" and a 3-page PDF listing 47 SKUs.
Cloudflare Email Routing forwards to the Worker. src/email.ts writes an events.bid.email_received row, attaches the PDF bytes to R2 under bid-attachments/2026-04-14/<email_id>.pdf, and routes the PDF through document_converter to get Markdown.
Customer resolution runs: sender domain is accdist.com, body mentions "ACC Distributors", and name_synonyms.json resolves to ACC Distributors at confidence 0.94. bid_quote_versions shows the latest as SY2627-v2.4 for ACC, so we'll bump to SY2627-v2.5 on approve.
The payload is assembled (email_id, customer_id, sy_version, bid_markdown, suggested_skill=ADD_UPDATE_CUSTOMER_PRICING) and a proposed_actions row is staged: kind=bid_email_intake, status=pending, risk_level=3. Mike sees the card on admin-dashboard at 10:46 AM and taps Approve.
A chat session opens. Preloaded context: the bid Markdown, ACC's customer.json, and the SY2627-v2.4 row from bid_quote_versions. The dispatcher routes to ADD_UPDATE_CUSTOMER_PRICING, which snapshots the 47 line items, applies the HPS MAP GPO allowance for SY2627, and writes UPSERTs into bid_customers, bid_customer_prices, bid_quote_versions (new row marked is_latest=1, prior row demoted), and bid_price_snapshots (47 rows). quotes/SY2627-v2.5.docx regenerates. STATUS.json updates ACC's freshness to "today, v2.5". The dashboard at gfs-pricing.pages.dev re-renders.
Two events fire: events.bid.review_completed and events.bid.quote_versioned. No NS write. The new pricing sits in the Bid Center until July 1, when Path 3 picks up ACC's latest SY-version (v2.5) and stages it into the Jul 1 cohort.
Email arrives → HITL approve → review runs
- 01
Email arrives at
bids@Cloudflare Email Routing rule forwards to the Worker's
emailhandler. - 02
src/email.tsparsesExtracts from / to / subject / body / attachments. Emits
events.bid.email_received. - 03
Detect attachments
PDF / XLSX routed to
document_converter. Other types ignored + logged. Body-only fallback if no supported attachment. - 04
document_converter→ MarkdownBytes → Markdown string. Source bytes persisted to R2 (
bid-attachmentsbucket) keyed byemail_id. - 05
Resolve customer
name_synonyms.json+bid_customers. exact → alias → fuzzy. ≥ 0.85 auto-suggest, < 0.85 ambiguous (Mike picks). - 06
Pick SY-version
SELECT sy_version FROM bid_quote_versions WHERE customer_id=? AND is_latest=1. None →currentSchoolYear() + '-v0.1'. The paircustomer_id + sy_versionis the trace thread from here on. - 07
Build proposed payload
Bundle email_id, customer_id + confidence, sy_version, bid_markdown, r2_source_key, and a
suggested_skill(router heuristic on bid content). - 08
★ Stage
proposed_actions(HITL gate)INSERT row: kind=
bid_email_intake, status=pending, risk_level=3. ADR-031 invariant. - 09
Notify Mike
admin-dashboard pending HITL queue surfaces the card: "new bid inbound — customer X (conf 0.91) — run review?"
- 10
★ Mike approves / rejects / reassigns
APPROVE → fires
bid.intake_approved; REJECT → closes row, email stays in R2; REASSIGN → update customer or skill, then approve. - 11
Open chat session
Session preloaded with bid Markdown +
customer.json+ latestbid_quote_versionsrow. - 12
Dispatch 11-skill review
One of:
CREATE_NEW_CUSTOMER,ADD_UPDATE_CUSTOMER_PRICING,ADD_UPDATE_ITEM,FILL_REGIONAL_PRICES,REBUILD_REGIONAL_SHEETS,COMPARE_CUSTOMER_QUOTES,LOG_EXTERNAL_BID,BID_PIPELINE,ARCHIVE_CUSTOMER,SYSTEM_HEALTH_CHECK,PREPARE_NEXT_SCHOOL_YEAR. - 13
UPSERT D1
bid_*tablesMigration 136 (Agent M owns). Until landed, writes go to folder + STATUS.json only.
- 14
Regen
.docx+ refresh STATUS.jsonPer-customer per-SY-version
.docx; freshness metadata updated. - 15
Dashboard re-renders
gfs-pricing.pages.dev reads latest STATUS.json on next request.
- 16
Events fired
events.bid.email_intake_completed,events.bid.review_completed,events.bid.quote_versioned.
What's different after Path 1 runs
- One new
bid_quote_versionsrow (is_latest=1) per approved intake; prior version demoted. - R2 retains every source attachment for audit (
bid-attachmentsbucket). proposed_actionsrow carries the full HITL audit trail (decided_by, decided_at, payload snapshot).- Per-customer
.docxquote regenerated; STATUS.json freshness updated. - Dashboard at
gfs-pricing.pages.devreflects the new SY-version on next page load.
What can go wrong
Sender domain unrecognized + body doesn't mention a known alias → ambiguous match. Today HITL surfaces the ambiguity to Mike; auto-classifier confidence is under bake.
Body-only parse runs; suggested_skill defaults to ambiguous → Mike picks. Common with one-line "please refresh" emails.
PDF bytes → Markdown throws; proposed_actions still stages with bid_markdown empty + error flag. Mike sees "intake parse failed — open R2 source" and decides next step.
Mike rejects the row; events.bid.intake_rejected fires; email stays in R2 for audit. Recovery: admin tool can re-queue if rejected by mistake.
D1 bid_* writes no-op gracefully; folder + STATUS.json remain authoritative until Agent M lands the migration.
Adjacent flows + diagrams
Code paths + invariants
| Concern | Where |
|---|---|
| Mailbox | bids@ai-globalfoodsolutions.co |
| Email pipeline | src/email.ts |
| Document converter | src/document_converter.ts |
| Name synonyms | data/name_synonyms.json |
| R2 bucket | bid-attachments (keyed by email_id) |
| HITL row kind | proposed_actions.kind = 'bid_email_intake' |
| D1 tables | bid_customers, bid_quote_versions, bid_price_snapshots, … |
| Dispatcher | router.dispatch(suggested_skill, payload) |
| Events fired | bid.email_received, bid.intake_approved, bid.review_completed, bid.quote_versioned |
Dated trail
| Date | Round | Change | Touched by |
|---|---|---|---|
2026-05-26 | R597 | Wiki ships alongside Path 1 diagram. JSON-LD mirrored from diagram. Customer + SY-version threading documented as the Bid Center analog of the SO PO# trace thread. | Mike + Claude (Agent L) |
The machine-readable spec
workflow_type · bid_center_email_intake_path · risk_level 3. Parent: bid_center_lifecycle (master).
Customer + SY-version threading (the trace thread)
Analogous to the SO PO# (otherrefnum) thread. Grep this pair across every Path 1 artifact for a complete audit trail.
| Record / table | Field carrying customer + SY-version | Sample value |
|---|---|---|
proposed_actions | payload.customer_id + payload.sy_version | "ACC Distributors / SY2627-v2.5" |
chat_session | context.customer_id + context.sy_version | "ACC Distributors / SY2627-v2.5" |
bid_customers | customer_id | "ACC Distributors" |
bid_quote_versions | customer_id + sy_version (thread origin) | "ACC Distributors / SY2627-v2.5" |
bid_price_snapshots | customer_id + sy_version + sku | "ACC / SY2627-v2.5 / SKU-419" |
STATUS.json | customers[customer_id].latest_sy_version | "SY2627-v2.5" |
events | payload.customer_id + payload.sy_version | every bid.* row |
D1 tables written (Path 1)
| Table | Operation | Purpose |
|---|---|---|
proposed_actions | INSERT then UPDATE | HITL gate row |
bid_customers | UPSERT | customer normalize on first sight |
bid_quote_versions | INSERT + demote prior | new SY-version row, prior loses is_latest |
bid_price_snapshots | INSERT-many | line-level snapshot for the version |
bid_customer_prices | UPSERT | workshop layer (locked per version) |
events | INSERT | append-only audit trail |
Endpoints
| Method | Path | Purpose |
|---|---|---|
POST | /api/proposed-actions/decide | single approve / reject / reassign |
GET | /api/proposed-actions?kind=bid_email_intake | pending Path 1 queue |
GET | /quote/<slug>/<version> | customer-facing rendered quote |
Events fired
| event_type | When |
|---|---|
bid.email_received | email lands at Worker |
bid.intake_approved | Mike approves the proposed_action |
bid.intake_rejected | Mike rejects |
bid.review_completed | 11-skill dispatcher finishes |
bid.quote_versioned | new bid_quote_versions row written |
It broke — what now
Scenario · Customer emailed but no proposed_actions row exists
Customer reports they emailed a bid; admin-dashboard shows nothing.
- Check email landed:
SELECT * FROM events WHERE event_type='bid.email_received' AND created_at > datetime('now','-1 day') - Check Email Routing rule: CF dashboard → Email Routing →
bids@rule active? - Check Worker logs:
npx wrangler tailfilter on email events - Check R2 bucket:
npx wrangler r2 object list bid-attachments --prefix=$(date +%Y-%m-%d) - Manual re-stage: upload PDF via admin-dashboard chat, run Path 2 instead
Scenario · Customer-match confidence stuck at 0.0 for a known customer
Long-known customer suddenly fails to match; ambiguous queue grows.
- Inspect synonyms:
cat data/name_synonyms.json | jq '.<customer>' - Add alias: append the new sender alias and re-deploy or hot-reload
- Re-run intake: reassign the staged row to the right customer in admin-dashboard
- Bake auto-classifier: if happening frequently, raise the issue in the auto-classifier backlog
Scenario · document_converter throws on a malformed PDF
proposed_actions stages but bid_markdown is empty / error flagged.
- Open R2 source:
npx wrangler r2 object get bid-attachments/<email_id>.pdf - Try local convert: open in Preview / use
pdftotextto confirm corruption - Decide: reject + ask customer to resend, OR copy-paste body into Path 2 chat
Logs to check
events·bid.email_received/bid.intake_approvedtrailproposed_actions· kind=bid_email_intakeworkflow_run_log· per-skill dispatcher auditnpx wrangler tail· live Worker logs (filter onemail)- R2
bid-attachmentsbucket · source bytes by email_id
Kill switches
kill:bid_email_intake· pauses Path 1 staging (incoming emails still archived to R2)kill:proposed_apply· stops HITL approvals from executing fan-out
What's not done · what's uncertain
- STUBMigration 136 (8 bid_* tables)
Agent M owns. Until landed, Path 1 writes go to folder + STATUS.json only.
- STUBAuto-classifier confidence threshold
Today HITL gates every customer match. Once
name_synonyms+ sender-domain confidence is measured over 100+ intakes, raise auto-approve threshold (likely 0.95 for known senders). - OPENSuggested-skill router heuristic
Today the router uses simple "has_pricing_table / has_rfp_doc" heuristics. Bake an LLM call on the bid markdown to pick the right skill more accurately.
- OPENAuto-reply to sender
Confirm receipt via
noreply@when intake stages? Pro: closes the loop for the sender. Con: noise on the dashboard. - DECISIONSpam / unrelated handling
Reject vs. silent-archive? Today Mike rejects. Could pre-filter via sender allowlist if volume grows.