{
  "description": "Architecture Decision Records (ADR) — short entries on the why behind structural choices. Append-only; supersede with a new entry rather than editing.",
  "entries": [
    {
      "id": "ADR-001",
      "date": "2026-05-18",
      "title": "Use Cloudflare D1 (SQLite) as the read-mirror of NetSuite, not Postgres",
      "status": "Accepted",
      "context": "Need a queryable mirror of NetSuite data outside the NS Suitelet sandbox. Worker → D1 keeps all platform code on one Cloudflare account; no separate database hosting.",
      "decision": "D1 SQLite. Bound to the Worker as DB binding.",
      "consequences": "Pros: zero ops, point-in-time recovery available, queryable from Worker with sub-ms latency. Cons: no enforced FKs in current schema, single-region writes, 10GB per-DB limit (currently 185K rows, well under)."
    },
    {
      "id": "ADR-002",
      "date": "2026-05-18",
      "title": "Sync runs locally via sync.sh, not as a Worker scheduled() handler",
      "status": "Superseded by ADR-019",
      "context": "Initial sync needed to ship fast. Chartstone Pro exposes a localhost SuiteAPI passthrough that already authenticates to NetSuite.",
      "decision": "sync.sh on launchd every 15 min, calling Chartstone (localhost:56411) and pushing to D1 via wrangler.",
      "consequences": "Pros: works today, no NS integration role to provision. Cons: single-Mac dependency, no observability, sync stops if laptop is off. Tracked as Tier 1 risk + Tier 2 GAP for migration to Worker-side scheduled() handler calling SuiteAPI directly."
    },
    {
      "id": "ADR-003",
      "date": "2026-05-18",
      "title": "Cloudflare Worker is the only public API",
      "status": "Accepted",
      "context": "Frontends (Pages dashboards, internal Suitelets) need REST access to the D1 mirror. NetSuite Suitelet calls are slow and rate-limited.",
      "decision": "Single Worker at api.ai-globalfoodsolutions.co/* exposing GET endpoints. CORS allowlist + Bearer API_KEY auth. CSP/HSTS security headers on every response.",
      "consequences": "Pros: one codebase, one auth model, one deploy. Cons: every endpoint runs under the same API_KEY — finer-grained scopes would need per-route token validation."
    },
    {
      "id": "ADR-004",
      "date": "2026-05-19",
      "title": "Consolidate to one repo with five fixed UI surfaces",
      "status": "Accepted",
      "context": "Prior structure had 3 dashboards, 2 master guides, 5 stub redirects, and overlapping references. Made it hard to find anything.",
      "decision": "Single repo at ~/Desktop/gfs-platform. Five fixed UI surfaces (index router + admin-dashboard, netsuite-reference, flows, data-model, infrastructure), two operating docs (GAPS, BLUEPRINT), one runbook, one decisions log. All non-load-bearing originals preserved under archive/.",
      "consequences": "Pros: single source of truth, single builder, single rebrand path. Cons: any new content has to fit one of the fixed surfaces — resists feature creep, which is the point."
    },
    {
      "id": "ADR-005",
      "date": "2026-05-19",
      "title": "Brand config lives in one JSON block inlined into every HTML",
      "status": "Accepted",
      "context": "Rebrand/restyle should be a single-config change, not a project-wide refactor.",
      "decision": "assets/brand.json holds all brand strings (name, domains, colors, fonts). build_pages.py inlines it into every generated HTML inside <script id=\"brand\" type=\"application/json\">. Single search/replace renames the system end to end.",
      "consequences": "Pros: rename is one file edit + one rebuild. Cons: pages must be regenerated after brand changes — markdown edits are live, HTML surfaces need the builder."
    },
    {
      "id": "ADR-006",
      "date": "2026-05-19",
      "title": "Out-of-scope: revenue, sales, profit, customer-facing material",
      "status": "Accepted",
      "context": "Platform is an admin and operating guide. Prior version mixed admin tools with sales KPI dashboards.",
      "decision": "All revenue/sales/profit/customer-facing artifacts removed from the active surfaces. Preserved under archive/2026-05/ for reference but not surfaced in any new page.",
      "consequences": "Pros: clear audience, no double-purpose pages. Cons: a sales-facing dashboard, if needed, has to live in a separate project — not this one."
    },
    {
      "id": "ADR-007",
      "date": "2026-05-19",
      "title": "No external CDN / library dependencies in deployed HTML",
      "status": "Accepted",
      "context": "Single-file HTML is the user's standard. External CDNs add load-time risk and an external trust boundary.",
      "decision": "Every HTML inlines its CSS, brand block, and any JS. Diagrams are inline SVG or pre-rendered. No <script src> tags pointing to public CDNs.",
      "consequences": "Pros: pages work offline, no third-party tracking, no broken when CDN goes down. Cons: pages are larger; some diagram options (Mermaid live, Chart.js) are off the table — replaced with inline equivalents."
    },
    {
      "id": "ADR-008",
      "date": "2026-05-19",
      "title": "Cloudflare Pages deployed to gfs-netsuite.pages.dev — NOT publicly shareable yet",
      "status": "Accepted with critical caveat",
      "context": "Need an internal canonical URL but Cloudflare Access setup is a Tier 1 GAP item not yet closed.",
      "decision": "Deploy to gfs-netsuite.pages.dev without Access for now. Do not share the URL with anyone outside Michael Levine until Access (email OTP) is configured.",
      "consequences": "Pros: usable today by the owner. Cons: anyone with the URL can read GAPS_TO_CLOSE (security gaps), netsuite-reference (all script + saved-search names), runbook (procedures). Adding Access is documented in GAPS Tier 1."
    },
    {
      "id": "ADR-009",
      "date": "2026-05-19",
      "title": "data/system-manifest.json is the canonical NetSuite inventory",
      "status": "Accepted",
      "context": "Inventory data was scattered across 8 separate markdown files in docs/. Hard to query, easy to drift.",
      "decision": "Single JSON file with 15 categories (record_types, fields, saved_searches, reports, scripts, workflows, roles, permissions, suitelets, printed_forms, dashboards, integrations, lists, forms, kpis). Source markdowns moved to reference/netsuite-sources/ and feed the manifest. The manifest feeds netsuite-reference.html and the admin-dashboard counters.",
      "consequences": "Pros: one place to update, every UI references the same numbers. Cons: refreshing requires re-extracting from NS — not yet automated."
    },
    {
      "id": "ADR-010",
      "date": "2026-05-19",
      "title": "Reference research kept verbatim, not summarized into surfaces",
      "status": "Accepted",
      "context": "280K words of NetSuite research (portlets, Suitelets, pitfalls, AI patterns, automation tiers) exists in docs/01-08. Tempting to inline summaries into surfaces.",
      "decision": "Keep research docs intact in reference/research/. Surfaces cite them but do not absorb them. New best-practice claims must trace to a research doc.",
      "consequences": "Pros: research stays inspectable and rewriteable independent of the surfaces. Cons: readers may not click through; need clear pointers from surfaces."
    },
    {
      "id": "ADR-011",
      "date": "2026-05-20",
      "title": "Native Cloudflare AI stack for the natural-language query layer over D1",
      "status": "Accepted",
      "context": "Need a natural-language query layer over the D1 NetSuite mirror so admins can ask questions like 'top vendors by 2026 spend' or 'which scripts are deployed but never run' without writing SQL. Data already sits on Cloudflare. Options: (a) native Cloudflare AI (Workers AI + Vectorize + AI Gateway), (b) external LLM via AI Gateway only, (c) external LLM with own embedding store.",
      "decision": "Native Cloudflare stack for v1: Workers AI for inference (Llama 3.3 70B Instruct) and embeddings (BGE-base-en-v1.5, 768d), Vectorize as the embedding store, AI Gateway for caching/logging/rate-limit and as the seam to swap models. Two query modes — retrieve+answer for descriptive questions, generate-SQL-then-execute for quantitative ones. SQL validator enforces SELECT-only, whitelisted tables, no DDL/DML. Corpus: D1 entities + aggregate transactions + system manifest + reference research docs + SuiteQL library + schema descriptions.",
      "consequences": "Pros: zero data egress, one auth model, one platform to operate, AI Gateway lets us proxy to Claude/GPT later without rewriting app code, native Workers AI pricing scales with traffic. Cons: Llama 3.3 70B is not as strong on complex SQL synthesis as Claude/GPT — we accept this for v1 and monitor query failure rates; recall on BGE-base may require a quality bump (text-embedding-3-small via AI Gateway) if relevance is poor. Native models locked to Cloudflare's catalog cadence — we mitigate via AI Gateway swap. Risk: prompt injection from indexed content; mitigated by explicit instruction-vs-data separation in the system prompt + SQL validator."
    },
    {
      "id": "ADR-012",
      "date": "2026-05-20",
      "title": "Customer pricing pipeline — derive from invoice history, review in platform, push to NetSuite",
      "status": "Accepted",
      "context": "Customer pricing today is maintained by clicking into each customer's Item Prices sublist in NetSuite (slow, no bulk operations, no effective-date history, no audit trail). Real per-customer pricing intent lives partly in Dropbox spreadsheets, partly in negotiated SO/quote rates. We have 28,528 invoice_lines in D1 — every (customer, item) pair that's been invoiced has a real, dated price. Cap of 500 customers means per-customer price lists (not shared Price Levels) are operationally tractable. NetSuite remains the daily editing surface the user is comfortable with; the platform's role is migration, audit, and AI-assisted insight.",
      "decision": "Build a derive → review → push pipeline. Derive: nightly Worker reads invoice_lines, computes a proposed price list per customer using median(last 3 invoices) when ≥3 exist in an 18-month window, else last_paid. Items the customer hasn't bought stay off the list (populated later at next quote/SO). Cross-reference derived prices against the existing customer_pricing table to produce three diff buckets (Listed+Recent, Listed+Stale, Not-Listed+Recent). Review: /pricing.html surface shows the diff per customer; admin approves, edits, or rejects rows. Push: Worker writes approved rows to NS via SuiteAPI RESTlet, replacing the customer's Item Pricing sublist. Promo/one-off filtering: flag rows where last_paid < median - threshold for explicit review. Returns and credit memos excluded from derivation. Full effective-date history captured in a new customer_pricing_history table on each push. AI runs anomaly detection (pricing outside peer band, drift narrative, smoothing suggestions, quote letter generation) over the derived + approved data — never auto-approves.",
      "consequences": "Pros: empirically grounded starting state (real prices paid, not stale intent), eliminates manual per-customer click-through for the migration, surfaces the three-way diff which is itself a valuable audit deliverable, NS stays the editing surface for the user's day-to-day, AI sits on top of grounded data so suggestions are anchored in real history. Cons: derivation can be polluted by promo/one-off pricing (mitigated by median + threshold flag), bid pricing for USDA/NYCDOE may need separate handling tagged via custbody1 Bid Reference, NS Item Pricing sublist has no native effective-date column so history must live in D1 (customer_pricing_history) — acceptable, sync.sh captures NS state for cross-check. Risk: open SOs in flight on cutover day may invoice on old prices; mitigated by SO audit query before push + pilot on 3-5 customers before bulk. Risk: write-back to NS via RESTlet is a new code path; mitigated by per-row success reporting + rollback procedure documented in runbook before Phase 4."
    },
    {
      "id": "ADR-013",
      "date": "2026-05-20",
      "title": "Consolidate Customer Pricing Program folder into gfs-platform as canonical bid/price/quote layer",
      "status": "Accepted",
      "context": "Existing system: ~/GlobalFoodSolutions Dropbox/.../Claude_Customer Pricing Program_Original/. Mature operation with 71 commercial customers, 1 commodity, 3 COOPs, 6 direct school districts, 127 commercial items across 8 brands, 14 commodity items, 4 regions with regional pricing matrix, 34 logged external bids + 15 external bid folders + 23 internal bid review documents, 28 timestamped price history snapshots, 11 operating skill prompts (BID_PIPELINE, ADD_UPDATE_CUSTOMER_PRICING, CREATE_NEW_CUSTOMER, ADD_UPDATE_ITEM, REBUILD_REGIONAL_SHEETS, FILL_REGIONAL_PRICES, COMPARE_CUSTOMER_QUOTES, LOG_EXTERNAL_BID, ARCHIVE_CUSTOMER, SYSTEM_HEALTH_CHECK, PREPARE_NEXT_SCHOOL_YEAR), Node.js build scripts that emit .docx quote documents per customer, existing Cloudflare Pages deploy at gfs-pricing.pages.dev. Driscoll Foods alone has 35 bids and 116 priced items. This system handles the upstream pricing intent (bids, regional, programs, version control, quote generation) — gfs-platform's ADR-012 pipeline handles the downstream (NS Item Pricing mirror + derive from invoice history). The two were separate. User asked to consolidate.",
      "decision": "The folder remains the canonical source of truth for DATA going in. Platform mirrors all of it into D1 and exposes it through one UI. HTML surfaces are derived views over the data, never write to the folder during normal operation. After cutover, edits can happen platform-side and write back to NS via the ADR-012 push-to-NS path; the folder becomes a read-only reference for the migration window. Quote output: HTML + PDF by default (Browser Rendering API from R2-stored templates), .docx on-demand for any signed/legal use. New D1 tables (additive only): pricing_school_years, pricing_customers_program (extends customers with program/pricing-type/version-log fields), pricing_items (extends items with K12/portions/cpp/usda_equiv/classification/brand), pricing_regional (item × region case price), bids (lifecycle: draft / submitted / pending review / awarded / not awarded / withdrawn / formalized / expired), bid_lines (per-(bid, customer, item) proposed price + notes), bid_attachments (R2 keys), external_bids (portal-submitted bid metadata extending bids), quotes (immutable header snapshot per customer per version), quote_lines (line items at quote time), pricing_snapshots (replaces Price_History/*.json). New endpoints: GET /api/bids, GET /api/bids/:id, POST /api/bids/:id/formalize, GET /api/quotes, GET /api/quotes/:id, POST /api/quotes/generate. New surfaces: /bids.html, /quotes.html, /pricing.html. Existing 11 skill prompts become endpoint + UI actions (the workflow logic stays; the execution moves into the platform).",
      "consequences": "Pros: one platform end-to-end; one data layer; bid → quote → push-to-NS becomes a single chain instead of three loosely-coupled steps; AI query layer (ADR-011) gets the bid lifecycle as part of its corpus; annual roll runs from the platform with full traceability per customer per cycle; the folder's 71 customers + 35-bid-Driscoll-style data graduates from JSON files to queryable tables (D1 plus history). Cons: substantial migration (~9–10K new rows); two systems coexist during the cutover window; the .docx generation pattern doesn't move 1:1 — HTML/PDF replaces it as default (.docx becomes on-demand). Risk: schema drift if the folder is edited during migration; mitigated by treating the folder as canonical until verified, then freezing folder writes. Risk: quote output format change visible to customers — mitigated by hybrid path (HTML/PDF default, .docx export when needed) preserving the option to send Word documents. Six-week target to July 1 2026 cutover is tight; the loader and read-only surfaces ship first so the data is queryable while UI work waits for design specs."
    },
    {
      "id": "ADR-014",
      "date": "2026-05-20",
      "title": "First-class customer_programs — pickup allowance, bill back, tier rebate, MDF, etc.",
      "status": "Accepted",
      "context": "Today the customer.json files capture a 'program' field as Yes/Not Applicable plus free-text notes. Real programs (pickup allowance, bill back, volume tier rebate, promotional allowance, MDF, drop/stop fees, cash discount, NYCDOE PPI, COOP membership, slotting/pallet allowance) are economic structures that change the EFFECTIVE price a customer pays — not metadata. Driscoll Foods has at least three concurrent programs (FOB pickup allowance, NYCDOE PPI, likely a bill-back). Without first-class representation, the platform cannot compute effective price, cannot correctly reconcile invoice_lines.rate against the list, and cannot push the right NS sublist entries.",
      "decision": "New table customer_programs with structured fields: program_type (pickup_allowance | bill_back | volume_tier | promo | mdf | drop_fee | cash_discount | nycdoe_ppi | coop | slotting | pallet_allowance), value + value_type (percent | dollars_per_case | dollars_per_lb | flat_amount | tier_table), applies_to_scope (all_items | category | brand | specific_items), applies_to_ids (JSON), effective_from + effective_to, billing_method (at_invoice | rebate_quarterly | rebate_annual | credit_memo | ap_check | customer_deducts), billing_account (NS GL ref), source (bid:<bid_id> | manual | historical | contract), contract_doc_r2_key, status (active | paused | expired | superseded). Companion customer_program_history (append-only audit) and customer_program_accruals (running balance for bill-back / MDF amounts owed). Effective-price calculation: invoice price = list × at_invoice_programs; net economic price = invoice price − bill_backs − MDF. Pillar 1 reconciliation now compares invoice price (not list) against invoice_lines.rate, which dramatically reduces false drift signals. Pillar 2 bids can propose programs as part of the award. Pillar 3 program changes go through the same approval flow as price changes, with their own NS billing-mapping rules (at_invoice → discount line on sublist; bill_back → no NS pricing change, separate AP/credit cadence). Pillar 4 annual roll auto-carries active programs into the next SY unless effective_to < next_SY_start.",
      "consequences": "Pros: the platform finally models how customers actually pay; effective-price math becomes correct; bid offers can include program terms without rotting in notes; AI can answer 'who has pickup allowances?' or 'projected Q3 bill-back total' instantly. Cons: complex billing methods (bill-back especially) require a parallel accrual model + reconciliation against NS AP/credit-memo activity — that's Phase 5+ work, not v1. Risk: existing customer.json notes mention programs informally — migration needs to extract them; some will need manual entry. Risk: program-stacking ambiguity (one customer has pickup allowance AND a promo at the same time) — resolved by explicit composition rule (programs apply in declared sort order, each reduces the running base). Risk: NS doesn't natively model bill-back accruals — the accrual table in D1 is the system of record for the liability; NS sees only the eventual payment."
    },
    {
      "id": "ADR-015",
      "date": "2026-05-20",
      "title": "Email intake — bids@ai-globalfoodsolutions.co → Email Worker → pre-populated bid draft",
      "status": "Accepted; vision parameters superseded by ADR-021",
      "context": "Bids arrive by email from customers, portals, and internal team. Today: manual processing — Mike reads the email, downloads the attachment, manually creates a Bid_Reviewer/X.md, manually adds bids[] entries to customer.json, manually proposes prices. Slow, error-prone, no audit trail. The platform already has the items catalog, customer list, regional pricing, and historical bids — all the inputs needed to draft a first-pass automatically. Cloudflare Email Routing + Email Workers handles the inbound side cleanly.",
      "decision": "Provision Cloudflare Email Routing on bids@ai-globalfoodsolutions.co → Email Worker (new entry point in src/email.ts, separate from the HTTP Worker). Pipeline: (1) save raw .eml + attachments to R2 under bids/inbox/<timestamp>/; (2) parse sender, subject, body; (3) detect customer via sender domain + AI fuzzy match against pricing_customers_program; (4) PDF text extraction via Workers AI vision (@cf/llava-1.5-7b-hf) for v1, AI Gateway → Claude Vision for image-PDFs in v2; XLSX parsing via minimal in-Worker parser; (5) item matching via Vectorize KNN against the pricing_items namespace, AI re-ranks with confidence score, threshold 0.75 for auto-match, below threshold flags the line; (6) price suggestion: AI reads body notes + matches each item to pricing_regional + similar past bids → proposes per-line price; (7) writes bids row (source='email', status='Pending Review') + bid_lines rows (with AI notes + confidence) + bid_attachments rows referencing R2; (8) sends admin notification email to a configurable address with a link to /bids.html?bid_id=<id>; (9) sends silent auto-receipt back to sender (acknowledges receipt; never quotes price). Reply chains matched to existing bids via in-reply-to header. Unknown senders flagged for admin assignment before any catalog work. Auth: DKIM/SPF/ARC enforced by Email Routing — Worker drops anything failing.",
      "consequences": "Pros: turns a manual 30-minute first-pass into seconds; every bid gets the same data treatment; nothing slips through email; attachments archived to R2 with audit trail; AI confidence scores surface which lines need human review (the bid_lines table is honest about uncertainty). Cons: PDF parsing accuracy is the dominant variable — image-based PDFs need Vision model which is more expensive ($0.005+/document); some XLSX bid sheets will defeat the minimal parser and route to manual. Risk: customer fakes a 'bids@' email pretending to be an existing customer — mitigated by sender domain check + DKIM. Risk: rate-flood from spam — mitigated by Cloudflare Email Routing spam filter. Risk: a single bid generates a noisy bid_lines table with weak matches — mitigated by threshold gating; below-threshold lines explicitly flagged 'needs manual review' rather than guessed."
    },
    {
      "id": "ADR-016",
      "date": "2026-05-20",
      "title": "Scoped pricing chat — /api/pricing-chat over Vectorize gfs-pricing-corpus",
      "status": "Accepted; retrieval policy superseded by ADR-020",
      "context": "Admin needs to query the pricing system conversationally — 'Driscoll PUB5001H drift?', 'bids pending review?', 'suggest prices for Anderson County'. The general AI query layer in ADR-011 would answer these but mixed-corpus retrieval is slower and less precise. The pricing corpus has high-value, tightly-related entities (items + customers + bids + programs + history) that deserve their own embedding namespace.",
      "decision": "Build /api/pricing-chat as a scoped sibling of /api/ai/query. Dedicated Vectorize index gfs-pricing-corpus (BGE-base-en-v1.5, 768d) with one namespace per source-type: pricing_items, customers_programs, bids, bid_lines, customer_programs, snapshots, history. Workers AI Llama 3.3 70B for inference v1; AI Gateway proxies to Claude when local reasoning insufficient. Two query modes (retrieve+answer, generate-SQL-then-execute) inherit from ADR-011. SQL allowlist restricted to pricing/bid/customer-pricing tables — no access to AR/AP transactions or NS-side admin tables. Bearer auth, KV rate-limit (200 queries/hour), every query audited to ai_audit_log. UI: /pricing-chat.html dedicated page + docked panel on /pricing.html, /bids.html, /quotes.html with ⌘K shortcut. Read-only in v1 — all writes go through explicit approval surfaces (no AI auto-action).",
      "consequences": "Pros: faster retrieval (smaller, focused corpus); the platform becomes a conversational interface for pricing, not just a dashboard; everything pricing-related lives in one queryable place; admin can ask questions in natural language and get cited answers in seconds. Cons: two Vectorize indexes (general + pricing) — slightly more provisioning but cheap; corpus refresh needs to fire whenever any pricing table changes — Worker scheduled() handles nightly, manual /api/pricing-chat/reindex covers urgent updates. Risk: SQL generation against allowed tables could still produce expensive queries — mitigated by enforced LIMIT clause + query timeout. Risk: prompt injection via bid notes or version_log text — mitigated by explicit instruction-vs-data separation in the system prompt (inherited from ADR-011)."
    },
    {
      "id": "ADR-017",
      "date": "2026-05-20",
      "title": "Two-channel email intake (bids@ + pricerequest@) + HITL review feedback loop",
      "status": "Accepted",
      "context": "User flagged two distinct intake patterns: formal bids/RFPs (full spec docs, due dates, samples, certs) and informal price requests ('can you quote on these 5 items for customer X?'). Both arrive via email; treating them the same in ADR-015 conflates volumes (price requests should be lighter weight) and review rigor. Separately, the user named the HITL review step as critical: 'we can take the notes and use as a feedback loop to make the system better.' Today admin corrections during review are ephemeral — they don't feed back into AI accuracy improvements.",
      "decision": "Two email addresses, one Email Worker: bids@ai-globalfoodsolutions.co routes to the existing full bid intake pipeline (ADR-015 + SOP sections A-H1); pricerequest@ai-globalfoodsolutions.co routes to a lighter-weight pipeline that creates a price_requests row, parses items if attachments present, but does NOT auto-create bid_lines. A price request can be PROMOTED to a bid (promoted_to_bid_id) if review determines it warrants the full SOP, or CONVERTED directly to a quote (promoted_to_quote_id). Both channels write to inbound_email_log uniformly. HITL feedback: every override or correction admin makes during review writes a review_feedback row capturing ai_proposed_value, human_corrected_value, feedback_type (corrected | approved | rejected | flagged | question_to_ai), reason text, ai_model + ai_confidence. Aggregated feedback drives prompt refinement and item-matching threshold tuning. Flags used_in_training and used_in_prompt_refinement track which feedback has been applied.",
      "consequences": "Pros: clear separation by intake intent — price requests get lightweight handling, bids get full rigor; HITL feedback becomes a queryable training signal not tribal knowledge; chat can answer 'what does AI consistently get wrong?' from review_feedback patterns; price requests can promote to bids when needed without losing the original context. Cons: two routing rules in Cloudflare Email Routing instead of one; price_requests adds another lifecycle to track. Risk: an external sender sends a 'price request' that should have been a formal bid — caught by admin during review who promotes it. Risk: review_feedback grows large fast; mitigated by retention policy + aggregation views."
    },
    {
      "id": "ADR-018",
      "date": "2026-05-20",
      "title": "Healthcheck framework — automated audits over pricing data",
      "status": "Accepted",
      "context": "User stated 'clean validated data across pricing for a business like us is most critical.' The platform now has 56 tables, 3,400+ rows of pricing data, two intake engines, four pipelines with gates, and an annual cycle. Without automated audits, drift is invisible until it surfaces in a customer's invoice or a missed bid deadline. The healthcheck framework runs scheduled SQL audits over D1, captures findings, surfaces them in the admin dashboard, and auto-promotes high-severity findings into pricing_anomalies for action.",
      "decision": "Three new tables: health_checks (definitions library), health_check_runs (execution log), health_check_findings (individual findings per run). Each check is a row with id, category (data_quality | integrity | workflow | business | integration | compliance | security), severity_default, schedule (hourly | daily | weekly | monthly | on_demand), sql_query (returns 0-N rows), finding_template (human-readable), remediation_hint. Worker scheduled() handler runs all enabled checks on their cadence, writes a row to health_check_runs per execution, and one health_check_findings row per result. High-severity findings auto-mirror to pricing_anomalies. Admin dashboard surfaces unresolved findings grouped by severity + category. Initial library seeded with 17 checks covering: items without cost basis (CRITICAL — blocks margin), unmatched customers/items to NS (Tier 1 GAP), bid+quote workflow stalls, locked-customer changes (compliance), commodity over-drawdown, AI error rate, sync staleness, programs expiring soon.",
      "consequences": "Pros: every data quality issue becomes a tracked row, not tribal knowledge; the chat can answer 'what's broken right now?' instantly; trends visible over time (sync staleness frequency, AI error rate); admin dashboard becomes a live ops view. Cons: SQL queries can become expensive if not scoped; healthcheck execution itself needs to be lightweight. Risk: a check with a typo could surface false positives forever; mitigated by enabled flag (can disable a check instantly) and a per-check pause if it generates noise. Risk: adding checks for everything = alert fatigue; mitigated by severity-default and the dashboard surfacing only unresolved + high-severity by default."
    },
    {
      "id": "ADR-019",
      "date": "2026-05-20",
      "title": "NetSuite push contract: Worker scheduled() handler + batched RESTlet 2948 with idempotency, retry, mutex, and mirror-back verification",
      "status": "Accepted",
      "context": "Phase 4 of the pricing pipeline (ADR-012) needs to push approved customer prices to NetSuite via SuiteAPI RESTlet 2948. Today's R1 read path (sync.sh) is a launchd cron on Michael's laptop hitting Chartstone Pro's localhost passthrough (ADR-002) — a single-Mac SPOF. The 2026-05-20 audit (see docs/AUDIT_2026-05-20_NS.md and AUDIT_2026-05-20_SYNTHESIS.md) surfaced ten concrete gaps in the push design as it stood: no idempotency key (transient retry = duplicate sublist rows), per-row RESTlet calls that would burn Worker sub-request budget at annual roll cutover (600 rows × 1 call vs 1000-call ceiling), no governance accounting (NS units invisible to the platform), no concurrency guard against double-click double-push, no retry/DLQ semantics, and — highest-impact — no mirror-back verification, which means an NS-side UI edit that overwrites the platform's push is undetectable until a customer disputes an invoice. The platform also leaned on Chartstone as infrastructure when it is documented as a dev convenience. The two issues — laptop SPOF and a fragile push design — share a single fix: move all NS touchpoints (read R1 + write W1) into the Worker, calling SuiteAPI RESTlet 2948 directly via TBA, with a hardened contract.",
      "decision": "Migrate R1 (read mirror) and W1 (price push) into a Cloudflare Worker scheduled() handler that calls SuiteAPI RESTlet 2948 directly over HTTPS with TBA authentication. Chartstone Pro is demoted to interactive admin tooling only; it is never on a production write path. The push contract enforces five guarantees, each cross-referenced to a column added in migration 011: (1) Idempotency — Worker computes push_uuid = sha256(customer_id|item_id|effective_from|price|version) per row. RESTlet 2948 stores the key on a custom field (custrecord_gfs_push_key) and on receipt of a known key returns the prior result without re-writing. Dedupe window ≥ 7 days. Stored in ns_touchpoint_log.idempotency_key. (2) Batching — one RESTlet call per customer with an array payload of {item_id, price, effective_from, push_uuid, expected_prior_price}. Cap each call at ≤ 8000 NS governance units (typically 30 items). Annual-roll batch of 71 customers × 30 items = 71 RESTlet calls (was 2130 per-row). Stored in ns_touchpoint_log.push_batch_id, request_rows, success_rows, failed_rows. (3) Concurrency — Worker acquires KV mutex ns_push:customer:<id> with 5-min TTL before invoking the RESTlet; UI disables the Push control until release. Prevents double-click duplicate batches. (4) Retry — Cloudflare Queue gfs-ns-push-retry with max 5 attempts, base 30s, jitter ±25%, cap 30 min. Errors classified: transient (timeout, 5xx, governance-exhausted) → retry; deterministic (4xx, missing mapping, prior-value mismatch) → DLQ + pricing_anomalies row + admin alert. Idempotency keys protect retries from doubling. Stored in ns_touchpoint_log.retry_attempt, retry_of_touchpoint_id, error_class, error_code. (5) Mirror-back verification — a scheduled job 30 minutes after each batch re-reads affected (customer, item) prices from NS via the same RESTlet and compares to the pushed values within ±$0.001. Mismatches create a pricing_anomalies row (kind='ns_push_mirror_drift', severity='high') and surface in the admin dashboard. This is the only mechanism that catches silent NS-side overwrites from the UI or Workflows. Stored in ns_touchpoint_log.mirror_back_verified_at, mirror_back_diff_count. Pre-write check: the RESTlet also compares each row's current NS price against the pushed expected_prior_price; mismatch returns CONFLICT per-row → pricing_anomalies (kind='ns_drift_pre_push') and the admin re-reviews. Adds one read inside the RESTlet per push, cheap and worth it. Governance accounting — every RESTlet response includes getRemainingUsage() before and after; Worker records governance_units_before/after/consumed; alert if any single call burns >5000 units. scheduled() cadence: R1 every 15 minutes (matches the launchd cadence); W1 not on a schedule — fired by admin click from the review UI through a Worker endpoint that enqueues a Queue message. Supersedes ADR-002. ADR-012 push step is now governed by this contract.",
      "consequences": "Pros: NetSuite touchpoints are first-class platform code with full observability via ns_touchpoint_log (governance metering, idempotency, mirror-back, conflicts, retries). Laptop SPOF eliminated — sync runs in the Worker so Michael's laptop being off doesn't stop the mirror. Reduced NS-side risk: idempotency kills retry duplication; per-customer batching stays under the 1000-sub-request Worker ceiling and the 8K-unit per-call governance ceiling; mirror-back closes the worst undetectable failure mode (silent UI overwrite). Chartstone returns to its documented role (admin SuiteQL exploration) so we no longer rely on a dev convenience tool as infrastructure. Cons: more Cloudflare infrastructure (Queue + scheduled() handler + KV mutex). RESTlet 2948 needs a custom field and a small dedupe layer added — coordinated with the Suitelet team. The mirror-back verification adds a 30-minute lag between push and full validation; this is acceptable given the human approval gate already enforces care, but admins should not assume push success === reality until mirror-back clears. Risks: (R1) Worker scheduled() handler exceeds 30s CPU budget on full nightly sync — mitigated by the same CPU_BUDGET_MS guard already used in derive (returns partial-success with explicit continuation_token, queues remaining via Cloudflare Queue). (R2) RESTlet 2948 deployment can fail mid-rollout — mitigated by a feature flag (custrecord_gfs_push_v2_enabled) that lets the Worker fall back to v1 (manual) until verified in a 3-customer pilot. (R3) Queue retry storm during NS outage burns Worker budget — mitigated by an inflight cap (KV-tracked count, refuses new enqueues above 50 inflight). (R4) Mirror-back can flag legitimate admin NS edits as drift — mitigated by recording the NS user id (sourced from the customer_pricing UE history if available) and surfacing the diff as review-only when source != 'platform_push'. (R5) NetSuite Workflows or UE scripts on the customer record can reject or modify the RESTlet write — mitigated by the pre-write CONFLICT check, the mirror-back verification, and a one-time docs/NS_AUTOMATION_INVENTORY.md inventory of UE + Workflow scripts on the customer record before Phase 4 ships. Open items before Phase 4 ship: RESTlet 2948 contract (custom field for idempotency, batch payload shape); Cloudflare Queue gfs-ns-push-retry setup; KV mutex helper in Worker; admin alert channel (Slack webhook or email) for mirror-back drift; pilot plan (3 customers — Driscoll, NYCDOE, one small-volume — for two weeks before opening to the full 71).",
      "implementation_date": "2026-05-20",
      "implementation_evidence": "commit 5c33fd8 (audit follow-through: ADR-019 + migration 012) + migration 037_ns_push_dlq.sql + src/index.ts:30-33,775-880,3293-3308 (NS_PUSH_QUEUE binding, ns_touchpoint_log, ns_push_dlq_log, push_uuid idempotency, DLQ + retry semantics, mirror-back drift table)"
    },
    {
      "id": "ADR-020",
      "date": "2026-05-20",
      "title": "Pricing chat retrieval & cost policy: top-2 per namespace + rerank to 5, 5-minute cache bucket, dynamic namespace caps, AI Gateway routing with prompt caching",
      "status": "Accepted",
      "context": "ADR-016 set the retrieval budget at 8 namespaces × 8 chunks × 500 tokens = 32,000 retrieval tokens. The 2026-05-20 audit (docs/AUDIT_2026-05-20_AI.md) caught the math: Llama 3.3 70B on Workers AI has an 8K context window. With system prompt (~600), conversation history (~500), JSON schema scaffolding (~300), and SQL example shots (~400), the request was projected at ~34K — 4× the window. The Worker would silently truncate retrieval and return garbage. The audit also flagged the cache key (hash(question + role + minute)) as keyed on per-minute buckets, defeating the 5-minute TTL (estimated hit rate ~5% vs target ~70%). Workers AI does not support prompt caching natively, but AI Gateway → Claude does, and three fixed prefixes (chat system prompt, vision system prompt, bid review rubric) repeat on every call and would amortize cleanly. Per-namespace caps in ADR-016 were uniform; the audit observed that operating_model chunks are background-only while bids and customers_programs deserve more chunks. None of this requires re-litigating ADR-011's Cloudflare-native preference — Workers AI remains the default; routing through AI Gateway to Claude is reserved for complex presets and prefix-caching wins.",
      "decision": "Five changes to the retrieval and cost path, all to land before the pricing chat ships. (1) Retrieval budget — switch from top-8 × 8 namespaces to top-2 per namespace then cross-encoder rerank to top-5 total. New math: 5 chunks × ~500 tokens = 2,500 retrieval tokens; full request envelope ~4K, comfortable inside an 8K window with headroom for the response. (2) Cache bucket — change cache key from hash(question + role + minute) to hash(normalized_question + role + bucket) where bucket = floor(now / 300s). Normalize question: lowercase, strip whitespace, collapse punctuation, expand {placeholders} after substitution. KV TTL stays 300s. (3) Dynamic namespace caps — bids and customer_programs get top-3; pricing_items, pricing_customer_prices, customer_pricing_history, customer_pricing_proposed get top-2; operating_model gets top-1 (background only). Rerank still selects 5 from the union. (4) Incremental embed-on-write for four namespaces — bids, pricing_customer_prices, customer_programs, pricing_items — so freshly arrived data is chat-queryable within seconds, not next morning. Cost is negligible (~$0.0002/day at current scale). Nightly cron stays for customer_pricing_history aggregate, bid_lines aggregate, and operating_model. (5) AI Gateway routing — Llama 3.3 70B (Workers AI) remains the default for short retrieval-grounded answers. Route to Claude Haiku 3.5 via AI Gateway for complex presets (cmp_*, roll_cycle_summary, comm_compare_*) where the larger context, prompt caching, and stronger SQL synthesis pay off. Mark the three fixed prefixes (chat system prompt, vision system prompt, bid rubric) with cache_control: ephemeral on Claude calls. AI Gateway also gives unified logging across providers, falling naturally into the ai_audit_log table (migration 013 adds the token + cache_hit columns). Output validation cap: max 2 retries on Zod failure, then drop to manual review. Supersedes the retrieval-budget and cache-key and per-namespace decisions from ADR-016. ADR-016's core architecture (per-source-type Vectorize namespaces, BGE-base embeddings, the LLM-as-Worker model) stands.",
      "consequences": "Pros: chat actually fits in the model's window (math no longer projects overflow). Cache hit rate should improve from ~5% to ~70%, dropping average latency on hot queries. Bids and customer_programs get the depth they deserve while operating_model stops crowding the retrieval slot it doesn't earn. Bids arrived 18:00 are queryable by 18:01 instead of next morning — the operational-interface promise of the chat surface is preserved. AI Gateway gives one observability plane across Workers AI and Claude. Prompt caching on the three repeated prefixes saves ~50% of input token cost at 10x (bid rubric caching alone saves ~360K input tokens/day at 50 bids/day). Cons: cross-encoder rerank adds ~100–200ms per query (acceptable for chat; total budget is ~2s p95). AI Gateway introduces a second hop on routed queries; falls back to direct Workers AI on Gateway outage. Per-namespace dynamic caps require the chat endpoint to know namespace identities at compile time — they live in a config constant updated alongside ADR-020. Incremental embed-on-write means the bid review path now has a side effect (Vectorize upsert) that must be in the same transaction as the D1 insert; failures need to retry the embed without re-creating the bid. Risks: (R1) Cross-encoder rerank model not available on Workers AI — mitigated by falling back to simple cosine-similarity score with no rerank (degrades quality but not catastrophic). (R2) AI Gateway latency spikes — mitigated by 2s timeout on Gateway calls, fall back to Llama 3.3 direct. (R3) Cache key normalization mis-collides (two different questions hash to same key) — mitigated by preserving role in the key (admin vs read-only differ) and including bucket so collisions are ephemeral. (R4) AI Gateway billing surprise — mitigated by per-day per-preset cost cap stored in KV; Worker returns 503 cost_cap_exceeded with the daily reset time. (R5) Incremental embedding drift if Vectorize index gets out of sync with D1 — mitigated by nightly reconciliation that diffs D1 row counts against namespace vector counts and flags drift > 1%. Open items before chat ships: Vectorize index provisioning (one namespace per source type per ADR-016); BGE-base-en-v1.5 model selected for embeddings; cross-encoder model selected (default @cf/baai/bge-reranker-base); ai_audit_log token columns landed (migration 013); cache cost cap config in KV; rerank fallback path tested.",
      "implementation_date": "2026-05-20",
      "implementation_evidence": "commit 08983ad (AI Gateway URL secret) + commit 41347f5 (pricing-chat/stream R91.1) + src/index.ts:287-340 (pickGatewayBaseUrl allowlist) + migrations 042b_query_cache.sql + 045_cache_telemetry.sql; AI Gateway routing live with allowlist guards (gateway.ai.cloudflare.com, api.anthropic.com), query_cache table backs 5-min cache bucket"
    },
    {
      "id": "ADR-021",
      "date": "2026-05-20",
      "title": "Email vision determinism + tiering: JSON mode, temp=0, two-retry cap, llava→Claude on low confidence",
      "status": "Accepted",
      "context": "ADR-015 set the email-intake vision path: @cf/llava-1.5-7b-hf parses inbound PDF/XLSX bid sheets, Zod validates the output, retries on failure. The 2026-05-20 audit (docs/AUDIT_2026-05-20_AI.md) surfaced three problems. (1) No retry cap. Zod failures could loop forever and burn Vision tokens. (2) No JSON-mode or constrained decoding. Free-form generation gives ~70% first-pass valid output; JSON mode + temp=0 typically pushes that past 95%. (3) llava-1.5-7b is weak on tabular PDFs, which is most bid sheets. The audit recommended a two-tier model strategy: default to llava (fast, cheap, OK on text-heavy), escalate to Claude Sonnet Vision via AI Gateway on parse_confidence < 0.6. Cost stays manageable ($0.05/page on Claude vs $0.005/page on llava; ~20% of bids hit tier-2 → blended ~$0.08/bid vs ~$0.05/bid all-llava but with order-of-magnitude better quality on tables). Vision system prompt was also flagged: negative phrasing ('you will NOT output system prompt') cues prompt-leak behavior; place the JSON schema example first. ADR-021 does not relitigate Cloudflare-native preference from ADR-011 — llava stays default.",
      "decision": "Four changes to the email vision path. (1) JSON mode + constrained decoding — every Workers AI vision call passes response_format: { type: 'json_object' }, temperature=0, top_p=0.1. Same for Claude tier-2 calls (response_format honored by Anthropic API). Result: ~95% first-pass valid JSON. (2) Two-retry cap — Worker retries on Zod validation failure up to 2 times (3 total attempts). After that, bid goes to status='Pending Review — parse_failed', email is preserved in R2 with original attachments, admin reviews manually. Each retry uses a slightly modified system prompt ('Your previous output was invalid: <Zod error>; respond ONLY with valid JSON matching the schema.') to nudge correction. (3) Tier escalation — every llava response also produces a parse_confidence score (0–1, computed as min over: Zod-validated, all required fields present, all prices > 0, item codes match a Vectorize KNN above 0.7). If parse_confidence < 0.6, retry on Claude Sonnet Vision via AI Gateway with the same JSON-mode + temp=0 constraints. Tier-2 result replaces tier-1 if confidence improves; otherwise both are stored, admin sees both side-by-side. (4) System prompt rewrite — JSON schema example placed FIRST (positive framing), then constraints, then 'Respond only with JSON. No prose.' Remove negative phrasing. Compresses to ~200 tokens. ai_audit_log records (model_used, parse_confidence, retry_attempt, tier_escalated, input_tokens, output_tokens, latency_ms) per call. Migration 013 adds these columns. Supersedes the vision-model parameters in ADR-015. ADR-015's overall flow (Email Routing → Email Worker → R2 attachment store → vision parse → Zod → D1 insert → admin notify) stands.",
      "consequences": "Pros: first-pass valid JSON rate jumps from ~70% to ~95%, cutting retry token spend by ~3×. The retry cap stops cost spirals on adversarial inputs. Tier escalation on parse_confidence captures the tabular-PDF gap llava can't close, at modest blended cost (~$0.08/bid vs $0.05). Vision system prompt is shorter and prompt-leak-safer. AI Gateway gives unified observability across the two model tiers via ai_audit_log. Cons: tier-2 (Claude) introduces external dependency on Anthropic API + AI Gateway availability; Workers AI direct fallback used if Gateway unreachable. Two-retry cap means some bids that would succeed on a 4th try get sent to manual review; acceptable given retry token cost outweighs the marginal recovery. parse_confidence is heuristic and may misclassify ~5% (false-low triggers unnecessary tier-2 escalation; false-high lets through bad parses) — mitigated by review feedback table that lets admin grade individual parses and retrain the confidence scorer over time. Risks: (R1) Anthropic API costs spike on tier-2 escalation storm (e.g., scanned-PDF batch where every parse hits tier-2) — mitigated by per-day cost cap in KV (default $5/day, configurable); above cap, tier-2 disabled and bids fall to manual review until reset. (R2) Claude Sonnet Vision rate limited under burst — mitigated by exponential backoff with jitter, max 30s wait. (R3) Vision system prompt updates change parse_confidence distribution — track in ai_audit_log and compare pre/post deploy. (R4) AI Gateway routing config drift between dev and prod — config lives in a versioned JSON in data/ai_gateway_routes.json, deployed alongside Worker code. Open items before email intake ships: AI Gateway provisioning (already in CF account); parse_confidence scoring function written + unit-tested with sample bids; cost cap KV key convention (cost_cap:vision_tier2:YYYY-MM-DD); system prompt rewrite reviewed for prompt-injection patterns per docs/SECURITY_THREAT_MODEL_2026-05-20.md; migration 013 token columns landed.",
      "implementation_date": "2026-05-20",
      "implementation_evidence": "commit 15919cf (email vision cost-cap + decision_corpus pollution fix) + commit 1e6852b (council_v2 email intake) + migrations 028_inbound_email_log.sql + 033_outbound_email.sql + 036_intake_lifecycle.sql + src/email.ts pipeline; vision JSON mode + temp=0 default at src/index.ts:404, two-retry cap via CostCapDO"
    },
    {
      "id": "ADR-022",
      "date": "2026-05-20",
      "title": "Genesis vs live operational store — Dropbox holds source documents, D1 holds the platform's operational state",
      "status": "Accepted",
      "context": "Over rounds 1-7 the platform grew to 62 D1 tables + 12 views + 42 endpoints, all populated from the Dropbox-resident folder `Claude_Original/Claude_Customer Pricing Program_Original`. That folder holds 23 Bid_Reviewer .md files, 2 Programs/*.md, External_Bids/, Commercial_Price_Quote/, Commodity_Price_Quote/, Price_History/, Prompts/, plus loose .xlsx and .docx artifacts. ADR-013 moved 'folder consolidation into gfs-platform' to canonical but didn't articulate what the long-run relationship between Dropbox and D1 should be — whether the folder updates back from D1, whether the folder is read-only once consolidated, or whether the folder simply stops being canonical altogether. After loading 782 bid_lines from Bid_Reviewer in round 8 + consolidating Desktop sprawl into ~/Desktop/GFS/, the question is unavoidable: where does each piece of data live, who owns writes, and when can we delete the source files. The user explicitly asked about moving the source to Google Cloud — the underlying issue is fragmentation, not the storage provider.",
      "decision": "Three-tier responsibility model. (1) **Source documents** — the genesis archive — live in Dropbox at `~/GlobalFoodSolutions™ Dropbox/GFS New Team Folder/GFS x Claude AI/Claude_Original/Claude_Customer Pricing Program_Original/`. This holds raw human-readable artifacts: Bid_Reviewer .md files, External_Bids JSON + folders, Programs/, Price_History/, Commercial_Price_Quote/, Commodity_Price_Quote/, Prompts/, plus the original .xlsx workbooks. Dropbox version history is the backup. The platform NEVER writes back to these files — they are write-once-by-human, read-many-by-loader. A symlink at ~/Desktop/GFS/source-documents/ provides visibility from the master folder without duplicating storage. (2) **Operational state** — the live store — lives in Cloudflare D1. Per-customer prices (pricing_customer_prices), customer programs (customer_programs), bid metadata (bids) + lines (bid_lines) + requirements (bid_requirements), pricing history (customer_pricing_history), proposed prices (customer_pricing_proposed), mappings (customer_name_mapping, pricing_item_mapping), audit logs (ai_audit_log, ns_touchpoint_log, cron_locks), healthchecks (health_check_runs + findings) — all live in D1. D1 is the SoR for the platform; queries by Worker, chat, dashboards, and the future Email Worker all read D1. Writes are admin-initiated through HTTP endpoints. (3) **Generated artifacts** — derived state — live in R2. Quote PDFs, signed contracts, email attachments, monthly archival of audit tables (older than 90 days), R2 path conventions documented. The platform code itself (Worker, migrations, docs, frontend HTML, build scripts) lives in the git repo at ~/Desktop/gfs-platform/. Git is the SoR for the code. Dropbox + git is forbidden (causes merge conflicts on every save). The git repo is NEVER cloud-synced; deploys happen via wrangler. A symlink at ~/Desktop/GFS/platform-repo/ provides visibility from the master folder. Cross-references: ADR-013 (folder consolidation) is the prior decision that started this; this ADR extends it. ADR-019 (NS push contract) is the only place where D1 and NetSuite cross-write — and even there, NS is the SoR for live orders and D1 is the mirror with proposed prices.",
      "consequences": "Pros: clean ownership model. Future-you opening a folder asks one question — is this source, operational, or generated? The answer determines what writes go where. Dropbox's version history serves as the source-document backup, R2's archival serves as the audit-table backup, D1's own Cloudflare-side replication handles the live state. The platform never overwrites the genesis files, so they remain authoritative for what was originally received from the customer/broker — useful for legal/dispute resolution. Migration from Dropbox to Google Drive (or anywhere else) becomes a one-time symlink-pointer change with no platform impact. Cons: source-documents are read-only by convention only; nothing enforces it. If admin manually edits a Bid_Reviewer .md to fix a typo, the platform won't notice — but a re-run of migration 015 will pick up the change next time. We accept this as the cost of keeping the genesis files human-editable. Risks: (R1) Source-documents and D1 can drift if admin re-edits .md files without re-running the loader — mitigated by tracking source_doc + indexed_at on each bid row, and a healthcheck (future) that flags drift. (R2) Dropbox sync conflicts on the genesis files (Dropbox occasionally writes conflict copies when two devices edit the same .md) — mitigated by keeping these files low-traffic and admin-only. (R3) The master folder ~/Desktop/GFS/ vs the actual repo at ~/Desktop/gfs-platform/ is a logical-vs-physical mismatch that requires the README to explain. Round 8 attempted to consolidate physically but ran into macOS TCC (Files & Folders permission blocks bash from executing scripts at the new path). The symlink restores logical consolidation without disturbing TCC. (R4) If we ever lose the Dropbox folder (account closure, sync corruption), the genesis archive is gone — mitigated by Dropbox's own retention + an opportunity for the next-round work: nightly R2 push of source-documents/ for off-site backup independent of Dropbox. Open items: write the source-document → D1 drift healthcheck (load Bid_Reviewer .md, compare row count + file content hash against indexed_at value on each bid row). Add nightly R2 backup of source-documents/ as a Worker cron (separate from the existing audit-table archival).",
      "implementation_date": "2026-05-20",
      "implementation_evidence": "migrations/017_assembly_costing_schema.sql:`source_doc TEXT NOT NULL` (genesis path) + Dropbox folder-sourced specs in source_documents R2 + D1 operational state separation lives across all post-R10 migrations; Genesis vs live distinction is now load-bearing in the assembly + spec_sheet domains"
    },
    {
      "id": "ADR-023",
      "date": "2026-05-20",
      "title": "Assembly Costing domain — second major data ingestion parallel to pricing, with FOB dock cost roll-up from vendor inputs through BOM to finished good",
      "status": "Accepted",
      "context": "GFS sells 141 finished goods across 9 brands. Some are pure resale, some are co-packed (vendor produces with GFS branding), some are GFS-manufactured (single-serve pouches, sandwiches, meal kits, parfaits). The Dropbox folder `Claude_Original/Claude_Assembly Costing Program_Original/` (65 MB) holds a sophisticated cost-tracking system organized by 7 programs (VFF / MealKit / Sandwich / Parfait / Resale / CoPack / Sauces) with ~141 item .md files (each with frontmatter + BOM lines + vendor refs + packaging defaults + labor placeholders), a vendor_pricing/ folder with the GFS_Master_Pricing.xlsx master vendor catalog, and cost_breakdowns/ with 14 per-item computed FOB dock cost rollups. The cost flow is: vendor input prices → assembly costing rolls up FOB dock cost per finished good → customer pricing uses dock cost as cost-floor for quotes. Today the platform has customer pricing (ADR-012) but no assembly costing — the cost_basis_per_case column on pricing_customer_prices is mostly null. The user explicitly asked for parallel ingestion of assembly costing so the chat can answer questions like 'what does it cost to make RS9310W?' and 'which assemblies are exposed to Sun-Rich price changes?' and so future customer quotes can incorporate live cost data instead of stale spreadsheet snapshots.",
      "decision": "Mirror the pricing pipeline architecture (ADR-012/013/022) for assembly costing. Six new D1 tables (migration 017 ships the DDL today): (1) **assembly_programs** — 7 program types with packaging_defaults_json, labor_defaults_json, overhead_defaults_json, freight_defaults_json. Each row links to the source .md file path. (2) **assemblies** — 1 row per finished good, links to pricing_items.code + items.itemid (NS), stores units_per_case, serving size, case weight, source_doc + source_doc_hash for drift detection. UNIQUE on item_code. (3) **assembly_bom** — bill-of-materials lines (one row per component). component_type enum: raw / packaging / labor / overhead / freight / other. Stores qty_per_case + qty_unit + yield_pct + cost_per_unit + cost_per_unit_override + cost_per_case_calc + vendor + vendor_item_code + source_doc + source_note + expires + status. (4) **vendor_items** — vendor side catalog (vendor_name + vendor_item_code UNIQUE). Maps to NS vendor_id + item_id when known. Source enum: master_workbook / netsuite_mirror / po_history / broker_quote. (5) **vendor_costs** — dated vendor prices (effective_from + effective_to + unit_cost + currency + freight_terms + fob_origin + source). Source enum: quote_email / master_workbook / po / broker / manual. (6) **assembly_cost_rollup** — computed FOB dock cost per assembly (effective-dated). Stores total_cost_per_case + raw + packaging + labor + overhead + freight breakouts + components_json snapshot. source_event enum: vendor_cost_update / bom_change / admin_recompute / scheduled_refresh. Plus two views: v_assembly_latest_cost (one row per assembly with latest active cost), v_assembly_vendor_dependencies (which assemblies depend on which vendor SKUs — for impact analysis when a vendor changes prices). Loader migration 018 (round 10) parses the 7 program .md files + 141 item .md files into assembly_programs + assemblies + assembly_bom rows. Loader migration 019 (round 10) parses GFS_Master_Pricing.xlsx into vendor_items + vendor_costs. Loader migration 020 computes initial assembly_cost_rollup rows. Worker endpoints (round 10): GET /api/assemblies (filter by program, brand, status), GET /api/assemblies/<id> (full BOM + latest cost + vendor dependencies), GET /api/vendor-costs (filter by vendor, effective range), GET /api/assembly-cost-rollup/refresh?confirm=true (recompute), GET /api/vendor-impact/<vendor_name> (which assemblies hit by this vendor's price changes). Vectorize namespaces extend to include `assemblies` and `vendor_costs` for chat. Per ADR-022: source .md files stay in Dropbox; D1 holds operational state; R2 holds R2 archival of vendor_cost_history + assembly_cost_rollup_history snapshots.",
      "consequences": "Pros: parallel structure to the pricing domain makes the architecture predictable; same admin patterns (review proposed, approve, log audit) apply to both. Chat becomes order-of-magnitude more powerful — 'cost of RS9310W' returns the live FOB dock cost with full vendor dependency tree; 'impact of Sun-Rich price change' surfaces every affected assembly. pricing_customer_prices.cost_basis_per_case gets populated from v_assembly_latest_cost going forward. Vendor-impact analysis becomes a single query (was previously impossible without manual spreadsheet work). Cons: 6 new tables increase D1 schema surface from 62 → 66 (12 → still 12 views; round 10 adds v_assembly_latest_cost + v_assembly_vendor_dependencies, total 14). The .md → D1 loaders are non-trivial — assembly item .md files use YAML frontmatter (frontmatter parsing required, not just markdown tables). GFS_Master_Pricing.xlsx loader needs openpyxl + sheet schema discovery. Vendor name normalization is hard (the .md files use free-text vendor names like 'Sun-Rich', NS uses vendors.companyname like 'Sun-Rich Foods Inc' — fuzzy match required as we did for customer_name_mapping). Risks: (R1) BOM source_doc drift — admin edits the .md file but loader hasn't re-run; source_doc_hash + nightly drift healthcheck mitigates. (R2) Vendor cost staleness — quotes expire (expires field) but rollup computation may use stale values; the assembly_cost_rollup re-fires when any vendor_costs row updates. (R3) yield_pct estimation errors compound across BOM lines — admin review surface required before any rollup is marked active. (R4) Mismatch between v_assembly_latest_cost and pricing_customer_prices.cost_basis_per_case (one is a cost, one is a denormalized snapshot) — nightly sync job copies latest cost into pcp.cost_basis_per_case for the active SY. (R5) Loader for .md files with YAML frontmatter is more brittle than the pricing .md loader; requires a YAML library (pyyaml) + careful schema validation. Open items before round 10 ships: confirm GFS_Master_Pricing.xlsx structure (sheets, columns); decide whether vendor_costs.unit_cost is per-uom or per-case (mixed in source today); decide whether labor_defaults are flat per-case or per-minute × time-per-case; design admin review UI for proposed cost rollups before they mark active; specify the recompute trigger (manual via endpoint vs scheduled vs on every vendor_cost write).",
      "implementation_date": "2026-05-20",
      "implementation_evidence": "commit 81c22dc (Round 10: 146 assemblies, 673 BOMs, 280 vendor items, 643 vendor costs LIVE) + migration 017_assembly_costing_schema.sql + src/index.ts:972-1000 (get_assembly_cost tool, v_assembly_latest_cost view); FOB dock cost roll-up shipped"
    },
    {
      "id": "ADR-024",
      "date": "2026-05-20",
      "title": "Multi-model chat routing (Workers AI / Kimi / Qwen / Claude) and chat-data provenance display requirement",
      "status": "Accepted",
      "context": "Round 9 wired the pricing chat to Llama 3.3 70B via Workers AI. The user surfaced two related requirements: (1) consider Kimi (Moonshot K2) and Qwen (Alibaba Tongyi) as cheaper or faster alternatives, alongside Claude; (2) the chat response must always show users HOW the data was pulled — which source row, which date, which scoring path — so they can validate every figure before acting. ADR-011 set Cloudflare-native as the default with AI Gateway routing to Claude for complex presets (ADR-020). ADR-020 added the cost cap framework (ai_gateway_cost_caps). Today's implementation returns a `retrieved` array + a `provenance` array in the JSON response — both carry namespace + source_id + similarity score + metadata — but this isn't yet rendered as a user-visible 'show your work' panel in the dashboard. The user's framing — 'I always want on data in that chat to be validated and show the user how the datapoint was pulled from' — applies to every chat answer, every quote draft, every healthcheck finding.",
      "decision": "Two locked decisions: (1) **Multi-model routing**. Continue Workers AI Llama 3.3 70B as the default for short retrieval-grounded answers (fast, cheap, no external dependency). Route via AI Gateway when warranted to:   - **Kimi K2 (Moonshot)** for very-long-context summarization (200K context, often cheaper than     Claude per token at high volumes). Useful when the chat needs to digest 50+ retrieved chunks     or whole bid review docs at once.   - **Qwen 2.5 72B (Alibaba)** for cheap bulk classification (e.g., the 700+ unmatched bid_lines     that need a tentative GFS code suggestion). Cheaper per million tokens than Claude or Kimi.   - **Claude Haiku 3.5 (Anthropic)** for complex multi-step reasoning (annual roll planning,     margin scenario analysis). Best reasoning quality on SQL synthesis per the ADR-020 audit.   - **Claude Sonnet Vision** for vision tier-2 on parse_confidence < 0.6 (already locked in     ADR-021). Routing decision goes through AI Gateway. Cost cap rows extend to include `kimi_long_context`, `qwen_bulk_class`, `claude_complex_reason` tiers (in addition to existing chat_complex_routed, vision_tier2, embedding_refresh). Default daily caps: $3 / $1 / $5 respectively. The chat endpoint selects model based on preset_id (or `model_hint` query param when caller knows what they want); default is Workers AI Llama. Failure modes fall back to Llama (always available). (2) **Chat provenance display contract**. Every chat response must include three blocks:   - **answer** — the LLM's text response.   - **provenance** — for every numeric or factual claim, a structured citation:     { citation_marker: '[1]', namespace: 'pricing_customer_prices', source_id: 'pcp:Driscoll:RS9310W:SY2627',     similarity_score: 0.87, as_of_date: '2026-05-20', sql_lineage: 'SELECT case_price FROM     pricing_customer_prices WHERE customer_name=X AND item_code=Y AND school_year=Z',     metadata: { ... } }. The chat HTML surface renders this as a 'How we got this' panel below     each answer, showing exact row source + date stamp + raw SQL trace.   - **confidence_notes** — when provenance is incomplete (e.g., retrieved score < 0.6, or LLM     hedged with 'I don't have that data'), the response includes a hint about what data is     missing and a suggested action ('refresh /api/embed/refresh for namespace X' or 'admin needs     to load missing bid review file Y'). Round 10 work: extend chat response shape; extend admin-dashboard.html chat surface to render provenance panel; add `sql_lineage` field to the retrieval pipeline (each Vectorize chunk records the SQL that produced it). Round 11+ work: provision AI Gateway endpoints to Kimi + Qwen + Claude; extend ai_gateway_cost_caps with new tier rows; add model_hint dispatch in /api/pricing-chat.",
      "consequences": "Pros: chat-as-a-tool becomes trustworthy because every number is traceable to a row + date + SQL. Admin can spot-check any answer before acting. Multi-model routing gives cost-per-task optimization without compromising answer quality (Llama for hot path, Kimi/Qwen for bulk, Claude for nuance). The cost cap framework keeps each tier bounded. Failure mode falls back to Workers AI (no external dependency, always available). The provenance contract becomes a forcing function: if a chunk can't be traced (e.g., scraped from an unsourced doc), it doesn't get embedded. Cons: per-call latency increases when external models are routed (Workers AI ~200-500ms cold; Anthropic ~1-2s; Kimi/Qwen ~1-2s). Cost increases — bulk Qwen still costs more than Llama-on-Workers-AI (Workers AI Llama is essentially free up to neuron quota). The provenance JSON adds ~200-400 bytes per chunk to every response (acceptable for the UX gain). Maintaining 4 model integrations adds operational complexity (4 sets of API keys, 4 cost-cap rows, 4 error classes). Risks: (R1) Kimi/Qwen API stability varies by region — mitigated by always-fallback-to-Llama. (R2) Provenance can be wrong (LLM cites [n] but didn't actually use that retrieved chunk) — mitigated by faithfulness check (regex the answer for facts, verify each fact appears in cited chunk text; flag mismatches). Faithfulness check runs nightly across last 24h of ai_audit_log. (R3) Cost-cap-exceeded errors degrade UX — mitigated by surfacing 'today's budget' in the dashboard so admin sees runway. (R4) Provider key rotation — Workers Secrets handles it but requires manual rotation; document in CLOUDFLARE_SERVICES.md. (R5) Multi-provider routing config drift between dev/prod — config lives in data/ai_gateway_routes.json, deployed alongside Worker code. Open items before round 10/11 ships: API keys for Kimi (Moonshot), Qwen (Alibaba DashScope), and Claude (Anthropic) need to be created and stored as Worker Secrets; AI Gateway endpoints set up in Cloudflare dashboard; faithfulness check job specced + scheduled; admin-dashboard chat panel designed (provenance + confidence_notes + cost ticker).",
      "implementation_date": "2026-05-20",
      "implementation_evidence": "commit 892d285 (Round 9: ADR-024 multi-model & provenance) + commit 18b4b2a (Round 22 part 3: cross-provider council with Haiku) + commit 1e6852b (Round 39: council_v2 DEFAULT) + migration 039_council_v2.sql + migration 046_chat_messages.sql (provenance_json column) + src/index.ts:1887-1939 (council_mode per pillar) + Workers AI + Anthropic routing live"
    },
    {
      "id": "ADR-025",
      "date": "2026-05-20",
      "title": "Specification Sheet Program — the shared product catalog that ties customer quotes to assembly builds",
      "status": "Accepted",
      "context": "The platform now has clean parallel ingestion for customer pricing (ADR-012 / ADR-013) and assembly costing (ADR-023). The third Dropbox folder `Claude_Specification Sheet Program_Original/` (~80 MB) holds the canonical product catalog with `data/master_products.csv` as the single source of truth for item attributes (allergens, nutritionals, claims, pack specs, CN credits), plus `brands/<brand>/` per-brand customization (alfresco-italian, bransons-roadhouse, gfs, harvest-promise, meltmates, power-up-foods, rsf, tijuana-tortilla) and `outputs/<brand>/` with generated spec sheets per item per brand. This catalog is used by BOTH the customer side (quote letters cite spec attributes when bidding to schools) AND the assembly side (each assembly's spec_item provides allergen/nutritional context for the finished good). Today the platform has items via NS mirror (`items` table, 1,265 rows) but those lack the spec-sheet enrichment (allergens, CN credits, etc.). The NS items.custitem_as_* allergen flags are a partial overlap but the spec sheet program has the authoritative version — and the user pointed out that NS items are 'lagging' behind the spec sheet program. This ADR plans the ingestion.",
      "decision": "Add a shared `spec_items` catalog to D1 as the cross-domain item enrichment layer. Three new tables (migration 022 ships the DDL): (1) **spec_items** — one row per (item_code, brand). Stores allergens (12 columns mirroring NS items.custitem_as_*), nutritional facts (calories, fat, sodium, sugar, protein, fiber, calcium, iron), CN credits (mma_credit, grain_credit, etc.), pack specs (units_per_case, serving_size, case_weight), claims (kosher, halal, vegan, organic, non_gmo), shipping group, classification, source_doc (path to master_products.csv row + brand override file), source_doc_hash, last_synced_at. UNIQUE on (item_code, brand). (2) **spec_attributes** — generic key-value extension for attributes not in the column set (certifications, custom flags). Keyed on (spec_item_id, attribute_key). (3) **spec_versions** — effective-dated history of attribute changes (when the manufacturer reformulates, when the lab updates nutritionals). Stores version_number, previous_value, new_value, change_reason, changed_by, changed_at. Plus three views: v_spec_with_pricing (spec × pricing_customer_prices for active prices), v_spec_with_assembly (spec × assemblies for build context), v_spec_drift_vs_ns (spec_items vs items.custitem_as_* — surfaces where NS is lagging). Loader migration 023 (round 12) parses `data/master_products.csv` + `brands/<brand>/brand_config.json` + per-brand `outputs/<brand>/<item>/*.md` into spec_items + spec_attributes rows. Idempotent via INSERT OR REPLACE on (item_code, brand). Worker endpoints (round 12): GET /api/spec-items (filter by brand, claim, allergen-free); GET /api/spec-items/<item_code> (full spec with cross-domain joins to pricing + assembly + bid history); GET /api/spec-drift-vs-ns (which items have NS data older than the spec sheet); GET /api/spec/by-claim?claim=kosher (find items with a specific certification). Vectorize namespace `spec_items` (8th namespace), embedded with text like 'Item CODE (brand): description. Allergens: free of X,Y. CN credit: 2G. Calories per serving: 130. Claims: kosher, halal. Classification: dry grocery.'",
      "consequences": "Pros: chat answers cross-domain questions that span pricing + assembly + spec data — 'what's the calorie count and our margin on RS9310W for NYCDOE?' joins three domains in one answer. Customer quote letters auto-populate with the right CN credits and allergen disclosure. The 'NS is lagging' problem becomes visible (and fixable) via v_spec_drift_vs_ns. Brand-specific spec variants (e.g., NYCDOE non-RS6000B variant) are first-class instead of buried in file paths. Cons: spec_items × brand cross-product can blow up — 146 assembly items × 8 brands = up to 1,168 spec_items rows (manageable). Reconciling spec_items.allergens with items.custitem_as_* becomes a real synchronization concern — we accept spec_items as SoR for allergens going forward and add a healthcheck for divergence. Per-brand brand_config.json may have nested structures we have to handle (JSON column + extraction views). Risks: (R1) master_products.csv format may have inconsistent headers per row — mitigated by loader strict schema check + a fail-soft path that logs unmappable rows to a staging table. (R2) Brand overrides may conflict with master (different allergen flags for the same item across brands) — admin reviews via UI; for now `spec_items.notes` captures the divergence. (R3) Customer-facing claims (kosher, halal) have legal implications — admin signs off before any spec row is marked status='active' for use in quotes. spec_versions audit trail captures who approved. (R4) Spec sheet PDFs in outputs/<brand>/ are generated artifacts — they live in R2, not D1; the spec_items.output_pdf_r2_key column points to the latest generated artifact per (item, brand). (R5) NS items.last_modified vs spec_items.last_synced_at drift can grow if the spec sheet program isn't re-ingested — nightly drift healthcheck + monthly admin review surfaces this. Open items before round 12 ships: confirm master_products.csv column schema; map brand_config.json fields to brand-specific overrides on spec_items; design admin approval UI for status transitions; specify which NS items.custitem_as_* fields are 'NS owns this' vs 'spec_items owns this'.",
      "implementation_date": "2026-05-20",
      "implementation_evidence": "commit 4ade629 (Round 12: 136 spec_items + 3 cross-domain views) + commit b0e8af4 (Round 11: ADR-025 spec sheet plan) + migrations 022_spec_items_schema.sql + 024_fix_spec_drift_view.sql + src/index.ts:1199-1230 (get_spec_sheet tool, spec_sheet curated provenance source)"
    },
    {
      "id": "ADR-026",
      "date": "2026-05-20",
      "title": "Suggestion engine and continuous-learning corpus — the platform gets faster the more the team uses it",
      "status": "Accepted",
      "context": "User directive on 2026-05-20: 'as we do more and more bids and do more and more price quotes it should learn and be smarter and help make my users faster we just do a lot of the same types of things over and over again.' The platform has the write surface today (round 15: bid create/close, spec versioning, vendor cost create, customer contact, program create/update) and the read surface across 3 domains (customer/vendor/shared catalog) plus 8 Vectorize namespaces. What it does NOT have is a memory of past admin decisions — when a similar bid arrived 3 months ago, what price did we set? When this vendor raised, how did we re-quote the affected customers? When this customer asked for terms, what did we offer? Today those decisions live in ai_audit_log (chat queries), platform_writes_log (admin writes), bid status changes, and spec_versions — but none of that is queryable as a suggestion engine. The team does ~5-20 bids/month and the patterns are highly repetitive (school district + item set + program type + margin band). A suggestion engine would: (a) auto-fill new bid drafts based on similar past bids, (b) suggest customer-program templates when adding a new customer, (c) pre-populate vendor-cost ripple impact when a new price arrives, (d) recommend spec changes when allergen/regulation patterns emerge, (e) flag anomalies (this customer's price moved 18% MoM — was that intentional?).",
      "decision": "Build a learning corpus on top of the existing audit infrastructure. Three pieces: (1) **decision_corpus** D1 table (round 16 migration 026) — one row per admin decision worth remembering. Fields: id, decision_type ('bid_price_set', 'quote_terms', 'vendor_cost_accepted', 'spec_change', 'program_offered', 'bid_status_set', 'contact_role_assigned'), situation_json (snapshot of inputs — customer, item, vendor, market context), action_json (what admin chose), outcome_json (what happened — bid won/lost, customer accepted/rejected), confidence_score (admin self-rated, 0-1), tags (free-text labels), created_at, created_by, source ('admin' / 'inferred' / 'imported'). Populated by writeAudit() decorator at every relevant write endpoint. (2) **decision_corpus** Vectorize namespace — embed each row as 'When [situation], we chose [action] and the outcome was [outcome].' Similarity search at suggestion time. (3) **Suggestion endpoints** — /api/suggest/bid-price (input: customer + items + context; output: ranked price suggestions with rationale + closest past matches as provenance), /api/suggest/customer-template (input: new customer + segment; output: typical programs + terms), /api/suggest/vendor-impact (input: new vendor cost; output: ripple-projected customer-quote adjustments). Each suggestion includes provenance (which past decisions informed it). Admin accepts/rejects via ?confirm or ?reject, which writes back to decision_corpus with the outcome label — closing the learning loop. Use ADR-024's council mode to validate high-stakes suggestions (bid prices on accounts >$50K/yr). Use ADR-021's vision tier-2 (Claude) for ambiguous pattern detection. Vectorize index `gfs-decision-memory` (new, separate from `gfs-pricing-corpus`) so chat retrieval and suggestion retrieval don't interfere. Faithfulness: every suggestion shows its source decisions as citations (sql_lineage + as_of_date per ADR-024 contract). User can reject + add note → trains the next suggestion. Cloudflare services involved: D1 (decision_corpus), Vectorize (separate index), Workers AI (embedding + reranker + suggestion synthesis), AI Gateway → Claude for nuance, Workflows for multi-step suggestion orchestration (e.g. bid draft = fetch bid + match past similar + suggest prices + suggest programs + assemble draft response).",
      "consequences": "Pros: the platform genuinely gets faster the more the team uses it. New bid in an hour instead of a half-day. Spec changes ripple automatically with admin-confirmation. Customer onboarding templates emerge from history rather than requiring rebuild. Vendor-cost ripples flag impact before admin notices. The 'AI proposes, admin approves' contract from the Architectural Identity is honored — suggestions are never auto-applied, always require ?confirm=true. Cons: garbage in, garbage out. If admin makes inconsistent or wrong decisions, the engine learns those patterns and confidently suggests them. Mitigated by (a) faithfulness contract (every suggestion shows its sources), (b) admin can reject + correct (closes learning loop), (c) council mode validates high-stakes calls before applying. Storage grows linearly with decisions — ~5K decisions/year is trivial for D1+Vectorize. Risks: (R1) suggestion bias from a small initial corpus — for the first ~3 months, suggestions are low-confidence; supplement with rule-based templates from operating model docs. (R2) Stale decisions cited as relevant — add as_of_date filtering, decay relevance for decisions older than 1 year. (R3) Vendor or customer changes that invalidate past decisions silently — drift healthcheck flags it. (R4) Privacy of customer-specific suggestions if Cloudflare ever has a multi-tenant Worker incident — Vectorize index is per-account scoped, low risk today. (R5) Suggestions can be wrong in non-obvious ways (e.g. extrapolating last year's margin to a brand new account type). Mitigated by displaying the closest source decisions inline + admin spot-checking. Open items before suggestion engine ships: migration 026 (decision_corpus DDL), populate from ai_audit_log + platform_writes_log + bid status changes + spec_versions, Vectorize index provisioning, three suggestion endpoints, dashboard surface for accept/reject.",
      "implementation_date": "2026-05-20",
      "implementation_evidence": "commit 59bb338 (Round 15: ADR-026 learning engine) + commit bc1ca2c (R82.1 autoresearch) + migrations 026_karpathy_learning_tables.sql + 029_training_corpus.sql + 053_decision_corpus_few_shot_log.sql + src/index.ts:2185-6420 (suggestion_corrections → decision_corpus promote cron, /api/learning/promote endpoint, weekly batch)"
    },
    {
      "id": "ADR-027",
      "date": "2026-05-20",
      "title": "Enterprise services posture — adopt the right Cloudflare services and external models, skip cheap shortcuts",
      "status": "Accepted",
      "context": "User directive on 2026-05-20: 'I have an unlimited cloudflare budget to make this amazing. Skip and don't cheap or be cheap on anything this is enterprise production grade system we are building.' Prior cost-conscious decisions documented in CLOUDFLARE_SERVICES.md (Workers Paid $5/mo, recommended Access $3/mo, skipped Zone Pro $20/mo and Business $200/mo). This ADR supersedes the conservative cost-control posture from earlier rounds and adopts an enterprise-grade service mix where the marginal value justifies the spend. The platform is the company's single operating system; downtime, data loss, or weak validation are existential risks that rounding-error spend savings can't justify.",
      "decision": "Adopt the following Cloudflare services and operational posture: (1) **Cloudflare Access (Zero Trust Standard) — enable.** $3/user/mo per ADR-014 + audit recommendation. SSO with Google Workspace; identity-aware policies on Pages + Worker routes; audit log of every human access. (2) **AI Gateway with multi-provider routing — enable.** Anthropic + Moonshot (Kimi) + Alibaba (Qwen) + OpenAI as fallback. AI Gateway handles prompt caching (Anthropic ephemeral), retries, cost tracking, observability. Per ADR-020/024 plan. Add Worker Secrets ANTHROPIC_API_KEY, MOONSHOT_API_KEY, DASHSCOPE_API_KEY. (3) **Cloudflare Workflows — enable.** Durable multi-step orchestration. Use cases: annual price roll cycle (Pillar 4 — 11 steps), suggestion engine pipelines (fetch+match+suggest), ADR-019 NS push consumer (idempotency + retry + mirror-back). Workflows replaces what would otherwise be ad-hoc cron+queue combinations. (4) **Durable Objects — enable.** Per-customer mutex for ADR-019 push (replaces racy KV mutex), per-session chat state (Agents SDK), atomic cost cap (audit Chat-Top-2). (5) **Browser Rendering — enable.** Quote PDFs (POST /api/quotes/generate, round 16), spec sheet PDFs, invoice/AR aging exports. Renders HTML templates → PDF → R2. (6) **Email Workers — enable.** bids@ + pricerequest@ intake per ADR-015/017. JSON-mode vision per ADR-021. Tier-2 escalation to Claude Sonnet Vision when parse_confidence<0.6. (7) **Logpush — enable.** Push Worker request logs to R2 monthly for compliance + forensics. (8) **Analytics Engine — enable.** Streaming metrics for chat usage, suggestion accept-rate, endpoint latency, cost per question. Beat-frequency observability beyond per-call ai_audit_log. (9) **Cloudflare Pages zone plan: stay Free.** Zone Pro/Business doesn't add value here; Access + Workers Paid + the above stack cover the requirements. (10) **NetSuite SuiteAPI + Chartstone — keep current.** ADR-019 push contract uses RESTlet 2948 via Worker scheduled() (not Chartstone) for write side; Chartstone remains for admin SuiteQL exploration. **External models** (provisioned this round, used per ADR-024 routing): - Anthropic Claude (Haiku 3.5 for complex reasoning + Sonnet for vision) - Moonshot Kimi K2 for long-context summarization - Alibaba Qwen 2.5 72B for bulk classification - Workers AI Llama 3.3 70B as the default + always-fallback Projected monthly spend (post-adoption): Workers Paid $5 + Access $3/user + AI Gateway routing ~$50-150/mo at expected volume + Workflows ~$5/mo + Browser Rendering ~$3/mo + Logpush included = ~$70-200/mo. At enterprise-grade reliability, this is rounding error.",
      "consequences": "Pros: every architectural commitment from ADRs 011-026 finally has its supporting service. The platform shifts from 'mostly free tier with stubs' to 'enterprise production grade with real wiring everywhere.' Single-admin team has the same access controls, observability, and reliability as a 50-person engineering org. Chat quality leaps when multi-provider routing kicks in. Suggestion engine becomes feasible with Workflows orchestration. Cons: managing 4 external model API keys + their rotation. Cost ceiling is no longer the natural rate-limiter — cost caps (ai_gateway_cost_caps) become more important. Operational complexity rises (more services = more rotations, more dashboards). Risks: (R1) API key leakage of any provider key = unbounded spend on that provider — mitigated by Worker Secrets (not env vars), monthly rotation policy, AI Gateway's own throttling. (R2) Vendor lock-in to Cloudflare across multiple services — accepted (ADR-011 Cloudflare-native posture); risk amortized over decreasing per-service migration cost. (R3) Cost surprise from any single provider — AI Gateway daily cost cap per tier (existing ai_gateway_cost_caps framework extends to each new tier). (R4) Email Worker + Browser Rendering + Workflows are all newer Cloudflare products — small risk of API changes; mitigated by versioning binding configs in wrangler.jsonc with compatibility_date. (R5) Multi-provider routing config drift between dev/prod — config lives in data/ai_gateway_routes.json + Workflows definitions in wrangler.jsonc, deployed atomically with Worker code. Open items before this ADR is fully landed: provision API keys for Anthropic/Moonshot/Alibaba; enable AI Gateway in Cloudflare dashboard; provision first Workflow (NS push consumer); enable Access on Pages + Worker; enable Browser Rendering; enable Logpush; enable Analytics Engine. Each comes with its own configuration commit; all land round 16.",
      "implementation_date": "2026-05-20",
      "implementation_evidence": "commit 59bb338 (Round 15: ADR-027 enterprise posture) + commit 2186549 (Rounds 32-35: cron + DOs + Workflows) + src/index.ts:20-43 (R2Bucket, VectorizeIndex, Workflows binding, NS_PUSH_QUEUE, DurableObject namespaces); CF-only stack with no AWS/GCP per CLAUDE.md invariant #5"
    },
    {
      "id": "ADR-028",
      "date": "2026-05-20",
      "title": "Dual-intake + HITL review queue + chat-as-omni-interface — with Karpathy-inspired learning loop underneath",
      "status": "Accepted",
      "context": "User question 2026-05-20: 'should the two intake engines for updates be email to the portals and we have some workflow and admin dashboard to check step all the updates that human in the loop step. And then also the chat interface like a chatgpt thats linked to all of this? we have all the cloudflare tools available to us to ensure these pipelines, how this all works, everything is best.' Plus earlier directive: 'as we do more and more bids it should learn and be smarter and help my users faster.' This ADR consolidates the user's intended end-state into one architecture and maps every layer to a specific Cloudflare service. The 10 Karpathy patterns (eval set as spec, few-shot retrieval, closed-loop diff capture, two-tier model distillation, process supervision, tool-use over generation, anomaly detection on repeated patterns, confidence-gated autonomy, curriculum from difficulty tags, chat-as-memory) are adopted as the learning substrate. ADR-026 introduced the suggestion engine; ADR-027 the enterprise services posture. This ADR specifies how they fit together end-to-end and the wiring order.",
      "decision": "Four-layer architecture, each layer mapped to specific Cloudflare services. **LAYER 1 — INTAKE (data flowing IN)**. Three engines: (a) Email Worker on bids@ai-globalfoodsolutions.co + pricerequest@ai-globalfoodsolutions.co (Cloudflare Email Routing + Email Workers, ADR-015/017/021). PDF/XLSX attachments to R2, vision parse via Workers AI llava (tier 1) or AI Gateway → Claude Sonnet Vision (tier 2 when parse_confidence<0.6). Writes to inbound_email_log + bids_pending_review. (b) Direct admin API writes (round 15 endpoints): /api/bids/create, /api/spec-items/:code/version, /api/vendor-costs/create, /api/customers/:id/contact, /api/programs/create, etc. Every write logs to platform_writes_log. (c) Future: vendor portal email parser (vendor sends new price sheet → auto-extract → propose vendor_costs row → admin reviews). Same Email Worker pipeline with different routing rule. **LAYER 2 — HITL REVIEW QUEUE (human-in-the-loop)**. Cloudflare Workflows orchestrate every inbound + every system-detected change as a durable, retryable, step-supervised review item. New D1 table review_queue (round 16 migration 026): item_id, source (email_bid / api_write / drift_detection / suggestion / anomaly), context_json, suggested_action, status (pending / approved / rejected / escalated), confidence_score, ambiguities_json, assigned_to, created_at, decided_at, decided_by, decision_notes. Admin dashboard adds /review tab showing the queue with color-coded confidence per Karpathy Pattern 8: green ≥0.85 (one-click accept), yellow 0.60-0.85 (requires \"I read this\" click which itself is a signal), red <0.60 (chat refuses to suggest, escalates). Each review records step_grades (Pattern 5) so we know per-step failure modes instead of just outcome wins. Workflows manages retry + state. **LAYER 3 — CHAT-AS-OMNI-INTERFACE (the front door)**. ChatGPT-style HTML at /chat.html (new Pages surface, round 16). Backed by /api/pricing-chat (already built — intent routing + faithfulness + council mode) PLUS new /api/chat/session for durable conversation state via Durable Objects (Karpathy Pattern 10 — chat as memory). Tool-use enforced (Pattern 6): chat NEVER generates a numeric figure — it calls /api/customers/:id, /api/vendor-costs, /api/pricing/compare, etc. The Anthropic tool_use protocol routes the LLM to the right endpoint with typed args. Provenance contract from ADR-024 stays. New: chat surfaces accept/reject buttons for every suggestion (closes Pattern 3 correction-capture loop). Session memory across days — when user asks 'what did we decide about Driscoll last week' it actually knows. **LAYER 4 — LEARNING SUBSTRATE (the engine that gets smarter)**. Sits under all three above. Components: (i) pricing_evals + pricing_eval_runs (Pattern 1) — eval-driven development; every Worker deploy runs the suite and refuses to merge if easy/medium pass-rate regresses. (ii) decision_corpus (Vectorize namespace gfs-decision-memory) — every approved review item + every confirmed write embeds into this corpus, retrievable as few-shot examples for similar future decisions (Pattern 2). (iii) suggestion_corrections (Pattern 3) — diff between suggested vs accepted. Nightly cron promotes high-signal diffs to pricing_evals. (iv) two-tier routing (Pattern 4) — Workers AI Llama serves; AI Gateway → Claude Haiku/Sonnet called only when confidence<threshold OR when needed for distillation labels. model_disagreements table captures every Sonnet ≠ Llama case for weekly human grading. (v) step_grades (Pattern 5) per Workflow step — diagnose where reasoning fails. (vi) repeated_failures (Pattern 7) — cluster suggestion_corrections weekly; clusters ≥3 in 30 days become testable patterns (Promote to eval / Add hard rule). (vii) confidence calibration (Pattern 8) — weekly bucket prediction confidence vs actual pass-rate; adjust thresholds. (viii) difficulty curriculum (Pattern 9) — pricing_evals.difficulty tag drives test ordering. **Cloudflare service-to-layer mapping** (per ADR-027): - Email Routing + Email Workers → Layer 1 (intake) - Workflows → Layer 2 (HITL durable orchestration) + Layer 4 (eval pipeline orchestration) - Pages (already used) + new /chat.html → Layer 3 (chat UI) + admin dashboard /review tab - Durable Objects → Layer 3 (chat session state) + atomic cost cap (ADR-027) + per-customer   push mutex (ADR-019) - D1 → all four layers (review_queue, pricing_evals, decision_corpus rows, etc.) - Vectorize → Layer 3 retrieval (existing gfs-pricing-corpus) + Layer 4 decision memory   (new gfs-decision-memory namespace) - Workers AI → embedding + Llama default reasoning - AI Gateway → multi-provider routing (Anthropic + Moonshot + Alibaba) for council mode +   distillation labels - R2 → attachment storage + quote PDFs (Browser Rendering output) + monthly archival - Browser Rendering → quote PDF generation + spec sheet PDFs - Logpush → compliance retention of Worker logs - Analytics Engine → streaming metrics for chat usage + suggestion accept-rate - Cloudflare Access → SSO/Zero Trust on the dashboard + chat surfaces Wiring order (Karpathy-discipline: minimum viable, then compound): Week 1: Pattern 1 (eval table + 50 seed cases) + Pattern 6 (tool-use enforced in chat). Week 2: Pattern 3 (correction capture) + Pattern 2 (few-shot retrieval at chat time). Week 3: Pattern 8 (confidence gating in review queue) + Pattern 10 (Durable Objects session). Week 4: Pattern 4 (two-tier routing via AI Gateway) + Pattern 5 (Workflow step grades). Week 5+: Pattern 7 (anomaly clustering) + Pattern 9 (curriculum). Patterns 7 and 9 are leverage — they only pay off after 4+ weeks of corrections + eval runs accumulate. Building them earlier is over-engineering.",
      "consequences": "Pros: the platform is now ONE thing — intake → HITL review → admin acts → learning captures → future suggestions get better. ChatGPT-style chat is the front door for both querying ('what's Driscoll's margin on RS9310W') and acting ('approve the pending bid for Wappingers'). The user operates the entire company from one interface. Every action improves the next. Every Cloudflare service has a load-bearing job — no orphan products. Cons: this is the most complex ADR by far. Operationally it spans 11+ Cloudflare services + 4 external model providers + 6+ new D1 tables + 2 new Vectorize namespaces + new Pages surfaces. Failure modes multiply. Mitigated by the wiring order — minimum-viable foundation first, leverage patterns only after data accumulates. Risks: (R1) over-engineering at any phase blocks the next — discipline against building Pattern 7/9 in week 1. (R2) Workflows is a newer Cloudflare product; API changes possible — mitigated by versioning binding configs. (R3) Multi-provider AI cost surprise — daily cost caps per tier (existing ai_gateway_cost_caps framework). (R4) The HITL queue becomes the bottleneck if review volume spikes — mitigated by confidence-gated autonomy: high-confidence items can auto-accept with after-the-fact audit instead of pre-approval. (R5) chat-as-omni creates a single point of UX failure — mitigated by keeping the 65+ direct API endpoints as operational alternatives. (R6) Eval set quality drift — mitigated by Pattern 9 curriculum rotation + nightly diff-capture review. (R7) Worker bundle size grows with all the routing logic — mitigated by Workflows offloading orchestration, Durable Objects offloading state. (R8) Each new external provider key is a leak surface — mitigated by Worker Secrets + monthly rotation + AI Gateway's own throttling. Open items before Layer 1+2 ships: provision Workflows, Durable Objects, Email Workers, Browser Rendering, Logpush, Analytics Engine in Cloudflare dashboard; API keys for Anthropic + Moonshot + DashScope as Worker Secrets; migration 026 for review_queue + pricing_evals + suggestion_corrections + step_grades + repeated_failures + chat_sessions tables; chat.html on Pages; /review tab on admin-dashboard.html; first 50 graded historical bids seed pricing_evals.",
      "implementation_date": "2026-05-20",
      "implementation_evidence": "commit 8b4b4f3 (ADR-028: dual-intake + HITL + chat-omni + Karpathy loop) + commit 1e6852b (council_v2 email intake pattern) + migrations 026_karpathy_learning_tables.sql + 036_intake_lifecycle.sql + src/index.ts:1083-6592 (review_queue with source=suggestion, /api/bulk-approve, dual mailbox bids@ + pricerequest@ via src/email.ts)"
    },
    {
      "id": "ADR-029",
      "date": "2026-05-21",
      "title": "Replace 10 generic chat roles with 11 job-pillar roles + admin (R85)",
      "status": "accepted",
      "context": "R52 deployed 10 generic chat role names (pricing/finance/warehouse/etc.) but they didn't map to how the team actually thinks about ownership. Mike's real org structure has 11 job-function pillars (Price/Nutrition/Finance/Relationship/Order Mgmt/Inventory/Production/Logistics/Reports/Bid/USDA Commodity Manager). The CDX-2 audit (2026-05-21) also flagged that historical chat_sessions/chat_messages rows needed a sweep migration (finding MC4 + SE10) when the role IDs changed. Because the R85 eval set is the contract for chat behavior (METHODOLOGY pattern: eval set IS the spec), reshaping the role allowlists requires an ADR per the round invariants.",
      "decision": "Adopt Mike's 11-pillar map (price_admin, nutrition_admin, finance_admin, relationship_admin, order_mgmt_admin, inventory_admin, production_admin, logistics_admin, reports_admin, bid_admin, usda_commodity_manager) + 'all' admin catch-all. Each pillar gets a focused tool allowlist (intersection of the active CHAT_TOOLS_FOR_PROMPT catalog), a permitted intent set, namespace caps, and a council_mode (council_v2 for full-validation pillars like price_admin + bid_admin, single for the lean ones, auto for reports_admin). Backwards compat is preserved via LEGACY_ROLE_REDIRECTS (server) + LEGACY_REDIRECTS (chat.html) — legacy R52 IDs are transparently translated before lookup. Migration 052 adds role_id columns to chat_sessions + chat_messages so future sessions persist the pillar, plus indexes for analytics. persistConversationTurn() and the chat_sessions INSERT both write role_id going forward. Each pillar gets at least one eval case in eval/golden-set/role_pillars.yaml asserting role_filter_applied + intent membership + council_mode.",
      "consequences": "Pros: pillar names match the team's mental model so onboarding is friction-free; per-pillar tool allowlists make cost-tier selection clearer (single-mode pillars cost ~30x less than council); eval coverage now asserts role-restriction works (closes CDX-2 SE10 + MC4). Cons: requires localStorage migration on first chat.html load (handled transparently); one-shot D1 migration on historical chat_sessions/chat_messages (already applied, 0 rows had legacy role_id because R52 never persisted it); per-pillar tool allowlists need maintenance as new tools land — add to CHAT_ROLE_FILTERS in src/index.ts when shipping a new tool."
    },
    {
      "id": "ADR-030",
      "date": "2026-05-22",
      "title": "Tighten R85 role-pillar eval cases mid-round (R85.6 → R85.6.1) — retrospective ADR per R91 MM4",
      "status": "accepted",
      "context": "R85.6 (commit 09df253) shipped 11 role-pillar eval cases. R85.6.1 (commit a1a029d) tightened 4 of them mid-round so they hit the rule-based classifier instead of the slower LLM classifier path. The mid-round eval edit is exactly the Goodhart-law trap METHODOLOGY's pattern 'Eval set IS the spec' (round invariant #6) was meant to prevent. CDX-4 (R90 methodology audit, 2026-05-22) flagged the missing ADR for this spec change as finding MM4. This ADR backfills the rationale so the R85.6 → R85.6.1 archaeology stays honest.",
      "decision": "Document, do not revert. The 4 cases that R85.6.1 modified used prompts that the existing classifyIntent() rule set did NOT route to the pillar's allowed intent — they fell back to LLM classification which non-deterministically produced different intents per run. Tightening the prompts to phrases the rule-based classifier deterministically routes (e.g., 'what does X pay for Y' → customer_price_lookup) makes the test stable and still validates the role-restriction contract the spec was checking. The alternative (keep the original prompts, expand classifyIntent() rules to cover them) was a larger redesign that would have inflated R85's scope beyond the 11-pillar reshape. R85.6.1 preserved the test INTENT (role restriction works) while changing the test PROMPT (use deterministic phrasing). Also widen council_mode assertion to allow null because the intent-rejection response path correctly returns null council_mode.",
      "consequences": "Pros: R85 eval cases are stable across runs and assert the role-restriction contract correctly; classifyIntent() expansion stays in scope for a future round (R91+ intent rule expansion when warranted by real chat traffic, not eval theatre). Cons: the precedent of editing eval cases mid-round to make them pass is a slippery slope — future rounds MUST add an ADR for every eval/golden-set/*.yaml change per METHODOLOGY pattern 'eval set as versioned spec'. R91 reaffirms this discipline by writing THIS ADR. Mitigation: ALL future eval set changes require an ADR row at the time of the change, not retrospectively."
    },
    {
      "id": "ADR-031",
      "date": "2026-05-24",
      "title": "HITL discipline — propose_* tools are the ONLY chat path to business-state writes",
      "status": "Accepted",
      "context": "Session R296-R360 introduced 12 new HITL write tools (propose_quote, propose_price_change, propose_bid_status, propose_program_change, propose_spec_update, propose_collection_action, propose_vendor_failover, propose_create_customer, propose_create_vendor, propose_create_item, propose_ns_field_update, declare_rule). Codex audit caught 2 cases where the discipline drifted: declare_rule was writing directly to declared_rules (bypassed HITL), and propose_ns_field_update accepted arbitrary entity_type+field. Without a written contract, the next contributor will silently break the invariant.",
      "decision": "Every chat tool that mutates business state MUST follow the propose_* contract:\n\n1. Name MUST start with `propose_` (or `declare_`/`simulate_` for adjacent semantics).\n2. SQL writes go to ONE table only: `proposed_actions` (status='pending').\n3. Validate inputs against an explicit allowlist before INSERT — no arbitrary entity_type, no unbounded text fields, no shell-out-style field strings.\n4. Return shape: `{tool, ok, ...echoed_inputs, proposed_action_id, status:'pending', error?}`. The action_id MUST surface to the LLM so it can narrate it.\n5. Snapshot current state via SELECT before proposing — Mike sees before/after diff in /api/mike/inbox.html.\n6. CANONICAL_INTENT_PATTERNS entry forces the tool when the user's phrasing matches; the screenChatInput mutation refusal is auto-bypassed for tools whose name starts with propose_/declare_/simulate_.\n7. SYSTEM_FORCED_TOOLS Set membership required so per-role allowlists don't block forced execution.\n8. Approval flow (R357): /api/proposed-actions/:id/decide requires X-Edit-Token. Approval stays 'approved'; drainer flips to 'applied' only after confirmed NS write.\n9. NEVER write directly to: customer_pricing, pricing_master, bids, customer_programs, spec_items, declared_rules, ns_pending_pushes. Always route through proposed_actions first.\n\nThe ONLY exceptions to HITL: sync/mirror paths (NS → D1, read-only direction), recomputed aggregates (pricing_master cache refresh), telemetry writes (training_loop_runs, chat_decision_audit), and ns_pending_pushes drainer (admin-token gated).",
      "consequences": "Pros: invariant is enforceable (search for `INSERT INTO proposed_actions` to find every HITL surface; search for `INSERT INTO declared_rules` or `INSERT INTO pricing_master` to find suspect ones). Discipline survives Mike being away for weeks. Compliance/audit story has a clean answer.\nCons: every new chat write tool is now ~50 LOC instead of ~10 (snapshot + allowlist + insert + return + canonical + forced). Accepted trade — Mike's $170M business runs on this; the bytes are cheap."
    },
    {
      "id": "ADR-032",
      "date": "2026-05-25",
      "title": "Workflow contract runner v1 (Phase 51) — declarative end-to-end execution discipline",
      "status": "Accepted",
      "context": "Phase 51 introduced workflow_definitions (v2 schema: trigger_spec, inputs_required, context_to_load, preconditions, fan_out_targets, post_actions, verify_checks, retry_policy). Every business workflow Mike runs (bid_price_update, customer_quote, vendor_cost_update, new_assembly_item, ar_aging_action_plan, ...) used to be tribal knowledge — a script here, a chat prompt there, Mike-in-the-loop everywhere. 20 contracts shipped in v2 across migrations 117-120. Without a runner, those contracts are just JSON documents; with a runner they become the AI-proposes/system-executes/Mike-approves substrate.",
      "decision": "executeWorkflowContract(env, workflowType, inputs, opts) is the single entrypoint. Stages: loadContract → loadContext (positional ? binds, q.binds list maps input keys) → checkPreconditions (evaluateRule grammar: 'X present', 'X is null', 'X >= N'; unevaluable+severity=block → blocked) → stageHitlProposal (if contract.risk_level >= 3) → executeFanOut (per-target, kind-dispatched; only kv_invalidate + stage_proposed_action are real, rest are 'stub' status not 'ok') → scheduleVerifyChecks (writes workflow_verify_results rows for post-window cron) → executePostActions (writes workflow_run_log + reflexion_log).\n\nKey invariants:\n- Unimplemented fan-out kinds emit status='stub' and do NOT increment executed counter. A workflow with an unimplemented ns_push step cannot return ok:true with zero NS writes.\n- stage_proposed_action throws on INSERT failure (no silent .catch). Risk-4 workflow cannot return pending_hitl with action_id=undefined.\n- workflow_run_log (migration 121) is the per-execution audit — distinct from workflow_runs (KV-state, legacy).\n- reflexion_log (migration 122) writes durable observations + status (approved_for_reuse / rejected / expired) so AI memory doesn't poison future answers.\n\nNot in v1: the 9 stub kinds (d1_write, ns_push, http_call, chat_tool, hitl_email_draft, flag, workflow_class_invoke, loop_over_*, dispatch_workflow). Each gets wired as the corresponding business workflow lands real fan-out logic.",
      "consequences": "Pros: tacit operator knowledge moves into a queryable substrate (every workflow has trigger, inputs, preconditions, fan-out plan, verify checks declared). HITL gate is one explicit place. /api/workflow/runs/:id is the inspection surface; ?preview=true returns the plan without execution. Workflow runs become searchable evidence for audits.\nCons: 20 v2 contracts shipped with mostly stub fan-out kinds — the runner is the spine but the business logic per kind has to be wired one workflow at a time. Accepted; the discipline of declaring the contract before writing the code is the win."
    },
    {
      "id": "ADR-033",
      "date": "2026-05-25",
      "title": "Event ledger / CDC substrate (Phase 52) — the spine that lets every consumer subscribe to the system",
      "status": "Accepted",
      "context": "Before the ledger, every meaningful state change was scattered: workflow_runs, chat_messages, inbound_email_log, proposed_actions_applied_mirror, sync_status. Anyone who wanted to know 'what just happened in the system' read 5 tables. Worse, the workflow runner had no way to subscribe to events — every workflow trigger had to be hard-wired. Per Mike's strategic doc (project_high_leverage_moves) this is move #2 of 8; pairs with the runner (move #1) to make 'AI proposes, system executes, Mike approves' real.",
      "decision": "Single append-only events table (migration 123) + event_subscriptions + event_drain_state. Schema:\n- events: event_id (ULID PK), event_type (entity.verb past-tense), entity_type, entity_id, payload_json, caused_by, workflow_run_id, source_system, occurred_at, sequence_no, idempotency_key (migration 127, partial UNIQUE).\n- event_subscriptions: event_type_glob (‘price.*’) → workflow_type with optional input_mapper (jsonpath-like recipe).\n- event_drain_state: per-drainer cursor (last_event_id).\n\nProducers wired in R554 Phase 52b: sync.tier_completed (success + error paths), hitl.approved, hitl.rejected, price.changed, email.parsed.\n\nDrain semantics (R560 hardening):\n- KV concurrency lock per drainer (300s TTL).\n- Cursor advances only past successfully processed events.\n- On dispatch failure: record workflow.dispatch_failed event + halt at last successful + retry next pass.\n- Glob matchers compile once per subscription set.\n- recordEvent uses INSERT OR IGNORE on idempotency_key for retry safety.\n\nGET /api/events/recent is the unified timeline read (auth-gated). POST /api/events/{record,drain} require X-Edit-Token — forging hitl.approved would trigger workflows.",
      "consequences": "Pros: workflow runner subscribes to events instead of being manually invoked. Reflexion log filters by entity via join. 'What happened to Driscoll between Jan and March' = one query. Replay is possible. Anomaly detection gets a clean substrate. Future ML labels live in event form.\nCons: every meaningful state change now writes 2 rows (the canonical table + the event ledger). Storage cost trivial; consistency between the two is producer-side (we write the canonical row first, then the event — if the event INSERT fails the event is missing but the canonical state is right, which is the safe failure mode)."
    },
    {
      "id": "ADR-034",
      "date": "2026-05-25",
      "title": "Canonical tool registry (Phase 54) — AI queries by intent, not by name memorization",
      "status": "Accepted",
      "context": "212 chat tools live in src/chat_tools/impls.ts. The CHAT_TOOLS_FOR_PROMPT catalog (50 tools today) is the only registry the AI sees. Adding a tool means editing the prompt; routing a query means the AI has to remember the right tool name from a flat list. Per Mike's strategic doc move #4: registry turns this into structured discovery + permissions become a set of tool_ids per role + telemetry joins to tool_id.",
      "decision": "Migration 125 introduces tool_registry, tool_role_palettes, and tool_invocations. populate-tool-registry.mjs scrapes CHAT_TOOLS_FOR_PROMPT and UPSERTs rows (50 seeded today). search_tools chat tool lets the AI find tools by intent (substring on description + tool_id + tags; filters category and side_effects). tool_role_palettes seeded R561: search_netsuite_knowledge + search_tools open to all 10 roles; get_customer_health_score open to admin/ar/sales/exec/finance/order_mgmt_admin; list_at_risk_customers admin/exec/ar/finance only (the ranked list itself is the sensitive surface).\n\nside_effects classification: 'none' (read), 'reads_ns', 'writes_ns', 'writes_d1', 'stages_proposed_action', 'writes_kv', 'sends_email', 'reindex', 'cron_trigger'.",
      "consequences": "Pros: AI capability grows without prompt sprawl. Tool routing becomes data-driven via tool_invocations joined to tool_id. Permissions are first-class (palette = set of tool_ids per role). New tools register via UPSERT — idempotent population. Future eval harness can ask 'did the AI pick the right tool?' instead of 'is the answer right'.\nCons: 50 of ~212 tools registered today — the rest are in impls.ts but not in the catalog. populate script must run every time a new tool catalog entry lands. Accepted gap; next pass seeds the rest by scanning impls.ts directly."
    },
    {
      "id": "ADR-035",
      "date": "2026-05-25",
      "title": "NS knowledge corpus (Phase 57) — single search_netsuite_knowledge tool over manifest + guide + ADRs + wiki",
      "status": "Accepted",
      "context": "data/system-manifest.json holds 3,486 structured NetSuite items (records, fields, lists, saved searches, reports, roles, scripts, suitelets). docs/SYSTEM_WIKI.md, data/decisions.json (ADRs), and llm_wiki_log carry unstructured knowledge. Before R555 the AI could only see the manifest as JSON; ADRs and prose were invisible to chat. Per Mike's strategic doc move #7, unify into one queryable corpus so chat answers from authoritative sources instead of best-guess.",
      "decision": "Migration 124 introduces knowledge_chunks: chunk_id (PK, prefix-namespaced like 'adr:ADR-031'), source_type ('manifest' | 'guide' | 'adr' | 'wiki' | 'saved_search'), source_id, title, body, metadata_json, content_hash (sha-256 for change detection), vector_id.\n\nbuild-knowledge-corpus.mjs walks each source, chunks appropriately (manifest items — 1 row per item; guide — split by ## / ### sections; ADRs — 1 row per ADR; wiki — 1 row per entry), hashes, posts to POST /api/knowledge/ingest. The endpoint embeds via @cf/baai/bge-base-en-v1.5 and upserts into VECTORIZE namespace 'ns_knowledge'.\n\nsearch_netsuite_knowledge chat tool: embed query → query Vectorize topK → join back to knowledge_chunks for body/title/metadata → score threshold 0.30 → cap at limit. Optional source_types filter.\n\n3,360 chunks seeded R555. wiki = 0 (no standalone llm_wiki_*.{md,json}); will auto-pick up when files exist.",
      "consequences": "Pros: AI answers 'how is X configured in NS' or 'which ADR decided Y' from sources instead of inventing. Onboarding context expands without prompt bloat. Every new ADR / wiki entry / saved search joins the corpus on next rebuild. Pairs with the broader doc-RAG buildout.\nCons: corpus is admin-triggered (no cron yet); embedding costs are real (one-time + delta on each rebuild). The Vectorize free tier covers this comfortably at 3,360 chunks but watch the bill if it grows past ~10K."
    },
    {
      "id": "ADR-036",
      "date": "2026-05-25",
      "title": "Customer health prediction (Phase 53) — leading indicators + composite score + nightly rotation",
      "status": "Accepted",
      "context": "AR aging is a lagging indicator — by the time AR moves, the customer has already disengaged. Driscoll is 36.4% of revenue; a single early-warning on a top-5 customer pays for this build 100x. Per Mike's strategic doc move #3.",
      "decision": "Migration 126: customer_health_signals (one row per signal computation), customer_health_scores (composite, current + prior + trend), customer_health_score_history (append-only for trend analysis).\n\nSignal weights (R561, this ADR documents them):\n- order_velocity_decline (25): last30 orders vs trailing-90 baseline. The single strongest leading indicator — a customer who orders less this month than the prior 3-month average is disengaging.\n- order_recency_gap (20): days since last SO vs median interval of last 12 orders. Captures rhythm break independent of volume.\n- dispute_frequency (15): credit memos in last 30 days. 3+ in a month = full signal.\n- reflexion_friction (15): reflexion_log entries tagged 'friction' or status='needs_review' for this customer in last 60 days. The AI-memory channel.\n- ar_aging_lagging (10): bucket_61_90 + bucket_90_plus from v_customer_ar_aging as fraction of total open. Included for completeness but weighted half of leading indicators since it's the last thing to move.\n- order_spread_narrowing (10): distinct SKU count last 60d vs trailing baseline. Captures de-rangering.\n- email_response_latency (5): placeholder weight, not yet computed — added so the schema is stable.\n\nWeights sum to 100; composite = (Σ contribution_i / 100) * 100 = 0-100 score. Tier mapping: green <25, yellow 25-50, orange 50-75, red 75+.\n\nNightly cron at 45 5 * * * runs recomputeAllCustomerHealth(limit=500). Candidates ordered by score staleness ASC so the 500-row window rotates instead of repeating. Concurrency 10 via Promise.all batches (R561). Per-customer try/catch so one bad row doesn't abort the pass. POST /api/admin/compute-customer-health-now is the ad-hoc full pass.\n\nRead surface: get_customer_health_score(customer_name|customer_id) + list_at_risk_customers(min_tier?, limit?). /customer-health.html is the admin UI.",
      "consequences": "Pros: Mike sees a Driscoll-style decline 30-60 days before AR. Pairs with the workflow runner — a 'concerned customer' workflow can subscribe to score-threshold-crossing events when those are wired. Composite is interpretable (top 3 signals surfaced in get_customer_health_score response). Score history enables trend visualization.\nCons: weights are hand-tuned, not learned. No backtest yet — next pass should reconstruct historical scores against known churn cases (Cal-Maine, Sysco etc) and rebalance. email_response_latency weight=5 is placeholder — either implement or drop in next pass."
    },
    {
      "id": "ADR-037",
      "date": "2026-05-26",
      "title": "Bid Center is the 4th pillar — D1 mirror of Customer-Pricing/ + 11 v2 workflow contracts",
      "status": "Accepted",
      "context": "The platform has three documented lifecycle pillars: Sales Order (ADR-030 / migration 130), Purchase Order (migration 132), and Work Order / Assembly (migration 133). Bidding/quoting is the missing fourth. Today it lives entirely as JSON-on-disk under ~/Desktop/GFS-NetSuite-Cloudflare/source-documents/Customer-Pricing/ (STATUS.json, commercial_items.json, 69 customer.json files, External_Bids/bids_log.json, 23 Bid_Reviewer/*.md, HPS_MAP programs, 32 price-history snapshots). Eleven SKILL.md prompts (CREATE_NEW_CUSTOMER, ADD_UPDATE_CUSTOMER_PRICING, etc.) drive the day-to-day. The folder is great for human workflow but invisible to the Worker, the chat tools, the customer-health scorer, and the admin dashboard. Folding it into SO / PO / WO would break the semantics — a 'bid' is not a transaction; it's a *commitment to a price at a future date*. SO/PO/WO are transactional; Bid Center is contractual. Distinct lifecycle, distinct timing (annual July 1 rollover anchors the calendar), distinct HITL profile (one approve-batch decision touches 69 customers at once vs. one SO at a time).",
      "decision": "Bid Center is a 4th first-class pillar with its own data layer (migration 136 — 8 D1 tables: bid_customers, bid_items, bid_regional_prices, bid_customer_prices, bid_external_pipeline, bid_reviews, bid_programs, bid_price_snapshots), its own 11 v2 workflow contracts (one per SKILL.md prompt, all prefixed bid_center_*), and its own sync surface (scripts/sync-bid-center-to-d1.js). Customer-Pricing/ remains the human authoring surface; D1 is the machine-readable mirror. Sync runs daily (incremental) plus on-demand --full / --customer. Customers are NEVER deleted, only archived (archived=1) per the existing JSON convention. Threading key is customer + SY-version (e.g. 'Driscoll Foods' + 'SY2627-v5.40') — the equivalent of a customer PO# for bid work; it ties every price override, snapshot, and version_log entry to a specific revision. NetSuite boundary: READ items + customers + transactions (already mirrored), WRITE only via proposed_actions — specifically the ns_named_price_list_write action which the prepare_next_school_year batch stages 69 times under one batch_id. The bids@ai-globalfoodsolutions.co mailbox is the email-intake surface for log_external_bid (parses incoming portal emails into bid_external_pipeline rows; attachments go to R2). HITL invariant (ADR-031 extension): every NS write-back from this pillar passes through proposed_actions; the July 1 batch is the canonical case — Mike approves the whole batch or individual rows in /admin-dashboard.html before any NS write fires. The 11 contracts (risk in parens): new_customer_onboarding (2), update_customer_pricing (2), add_or_update_item (3), fill_regional_prices (2), rebuild_regional_sheets (1), compare_customer_quotes (1), log_external_bid (2), bid_pipeline_review (1), archive_customer (3), system_health_check (1), prepare_next_school_year (4 — the big batch). Sync footprint at first cutover: 141 bid_items, 556 bid_regional_prices, 2,240 bid_customer_prices, 70 bid_customers, 44 bid_external_pipeline, 23 bid_reviews, 2 bid_programs, 32 bid_price_snapshots = 3,108 total rows.",
      "consequences": "Pros: Bid Center becomes queryable by the Worker / chat / customer-health scorer (a customer with declining bid volume in bid_external_pipeline is a leading signal that pairs with ADR-036). The 11 workflow contracts are now first-class entries in workflow_definitions — the runner can execute them like any SO/PO/WO workflow once wired. Vectorize ingestion (per ADR-035) can pull bid_reviews into the knowledge corpus. July 1 rollover stops being a 69-customer hand-touch operation and becomes one batch-approve in /admin-dashboard.html. Programs (HPS MAP, future GPO contracts) get a structured home for allowance_per_case math. Cons: The Customer-Pricing/ folder remains the *authoring* source — dual maintenance until phase 2 promotes the D1 mirror to read-write (the Worker would then write back to the folder on Mike approval). Sync requires the laptop running (same dependency as ADR-002); GAP — future Worker-side scheduled() handler should pull from Dropbox API instead of local FS. FK constraints are documented but not enforced by D1; the sync script does the validation. ns_customer_id and ns_item_id on bid_customers / bid_items are nullable until the matching pass is run — next pass should match by entityid/itemid against the existing NS mirror.",
      "supersedes": null,
      "related": [
        "ADR-002",
        "ADR-012",
        "ADR-030",
        "ADR-031",
        "ADR-035",
        "ADR-036"
      ],
      "implementation_evidence": "migration 136_bid_center_pillar.sql (8 tables + 11 INSERT OR REPLACE contracts + indexes) + scripts/sync-bid-center-to-d1.js (dry-run validated: 3,108 rows ready) + docs/BID_CENTER_ARCHITECTURE.md (200-line engineering reference)"
    },
    {
      "id": "ADR-038",
      "date": "2026-05-27",
      "title": "Data Tagger is the 5th pillar — per-customer learned NS-field extraction templates with reflexion loop",
      "status": "Accepted",
      "context": "The platform now has four operational pillars: Sales Order (ADR-030 / migration 130), Purchase Order (132), Work Order/Assembly (133), and Bid Center (ADR-037 / migration 136). What is still missing is the layer that turns inbound documents (PO PDFs from customers, vendor invoices, COAs, spec sheets, RFP responses) into write-ready NetSuite records. Today this is handled ad-hoc: an operator opens a PDF, eyeballs the fields, types into NS. The earlier 'document_understanding' approach used a single generic LLM-extraction prompt per doc_type, which produced ~70% accuracy that never improved — there was no place to record 'for Driscoll specifically, the PO # always follows the literal label \"P.O. #\" on page 1'. Folding this into Bid Center conflates contractual data (price commitments) with operational data (turn a PDF into an SO). Folding it into SO/PO/WO duplicates the extraction logic per pillar. The right shape is one extraction engine with one NS field taxonomy, parameterized by per-sender templates that improve via operator corrections.",
      "decision": "Data Tagger is a 5th first-class pillar with its own data layer (migration 142 — 5 D1 tables: data_tagger_templates, data_tagger_tags, data_tagger_extractions, data_tagger_corrections, ns_field_taxonomy). Each template is keyed by (customer_id, doc_type, ns_record_type, version) and contains one row per NS field, tagged with one of nine extraction strategies: regex_after_label, regex_before_label, fixed_region (PDF coordinates), table_with_headers, multi_line_span, whole_section, formula, llm_with_schema (fallback for unknown senders or fuzzy fields), and literal_constant (values known a priori, e.g. the entity ID once the sender is matched). The ns_field_taxonomy table is the single source of truth for what fields exist per NS record type (SalesOrd, PurchOrd, CustInvc, VendBill, WorkOrd, AsmBuild) — it drives the tagger UI's field picker and validates that ns_field_path values resolve to real, documented fields. The seed in migration 142 covers ~70 high-use fields across the six record types and ships one canonical example template (Driscoll Foods · PO PDF → SalesOrd, 8 tags). Per-customer learned templates beat generic LLM extraction on three axes: (1) accuracy — once a regex is dialed in, it is 100% on that sender's format, vs. LLM variance; (2) cost — regex is ~free, LLM is ~$0.001/doc and adds latency; (3) auditability — every extraction has a deterministic strategy_params_json the operator can inspect and tweak. LLM stays in the toolbox for fuzzy fields (llm_with_schema strategy) and for one-off senders that don't justify a template. The HITL invariant (ADR-031) is preserved end-to-end: every extraction lands in data_tagger_extractions(status='pending'), the operator approves/corrects/rejects in the tagger review UI, and only approved extractions enter proposed_actions and from there the NS write-back path. The reflexion loop is the long-term play: when an operator corrects a field, a row lands in data_tagger_corrections with will_update_template=1; a nightly job (BB-4, not yet built) replays corrections and updates the template's strategy_params_json so the next extraction of that sender's doc is right out of the gate. This converts every human review into permanent template improvement — the same Karpathy-inspired loop from ADR-028 (dual-intake / chat-as-omni-interface) but applied to extraction.",
      "consequences": "POSITIVE: (1) PO-to-SO is now a structured, measurable pipeline with a hit_count / success_count / failure_count per template — we can dashboard template health and see which senders need re-training. (2) New customers onboard by cloning a generic template + correcting once or twice, rather than writing extraction code. (3) The ns_field_taxonomy table doubles as documentation for what NS fields chat tools and workflow contracts can target — single source of truth. (4) Operators can SEE the strategy that produced each value (the strategy_params_json travels with the extraction) which makes audits and bug reports specific. NEGATIVE: (1) Templates are per-sender — Driscoll changing their PO form invalidates the template and requires re-training (offset by the correction loop). (2) The 9-strategy enum is opinionated; novel doc shapes may need a 10th strategy or an llm_with_schema fallback. (3) Initial seed of ns_field_taxonomy contains GUESS flags for ~6 fields (default subsidiary, default location, custom column IDs, vendor invoice field ID, custbody_waste, custom forms) that need confirmation against the live NS instance before templates depending on them go active. (4) The migration ships only ONE example template (Driscoll); BB-2 (tagger UI) and BB-3 (chat tools) must be built before this pillar is usable in production. SUPERSEDES: the generic-LLM 'document_understanding' approach, which is now a fallback strategy (llm_with_schema), not the primary path. EXTENDS: ADR-031 (HITL invariant) — every extraction-to-NS-write path passes through proposed_actions; ADR-028 (Karpathy learning loop) — corrections feed template updates the same way chat conversations feed prompt tuning.",
      "implementation_date": "2026-05-27",
      "implementation_evidence": "migrations/schema/142_data_tagger_pillar.sql (5 tables, 17 indexes, ~70 ns_field_taxonomy seed rows, 1 example template + 8 tags for Driscoll PO → SalesOrd); scripts/seed-ns-field-taxonomy.js (idempotent seed with --full rebuild + Driscoll customer_id re-point); docs/DATA_TAGGER_ARCHITECTURE.md (~200-line pillar reference covering 9 strategies, reflexion loop, UI sketch, first-use-case runbook). BB-2 (tagger UI) and BB-3 (chat tools) tracked separately."
    },
    {
      "id": "ADR-039",
      "date": "2026-05-27",
      "title": "Folder-only operator edits — Customer-Pricing folder is the canonical edit surface",
      "status": "Accepted",
      "context": "Mike's operator workflow has matured around the legacy Customer-Pricing folder (Desktop/GFS-NetSuite-Cloudflare/source-documents/Customer-Pricing/): drop folder in Claude.ai, pick a skill from the menu, the skill runs the existing Prompts/*.md workflow, post-session-sync.js pushes the resulting diff to platform D1 via /api/tools/invoke. The platform meanwhile grew a parallel set of operator entry points — chat at chat.ai-globalfoodsolutions.co, Quick-Action buttons on gfs-pricing.pages.dev, direct /api/tools/invoke curls, dashboard widgets. Two surfaces for the same intent (operator edit) is a leak: the folder produces auditable, idempotency-keyed, snapshot-backed diffs; the chat/dashboard paths bypass STATUS.json + Price_History/ + the per-skill checklist and produce edits that don't round-trip back into the folder, so the folder drifts from the platform. Mike's call: 'the only way for me to make edits is by dropping that folder into a claude chat... this gives me a backside admin setting.' The folder becomes the SINGLE operator edit surface; the platform is read-only for operator workflows. System-initiated writes (incoming bid email → bids@, nightly cron, daily NS sync, workflow runner) are platform automation — not operator intent — and continue unchanged. HITL approvals (operator approving a previously-staged proposed_actions row) are NOT 'operator-initiated writes' in this model — they approve an action a system handler already staged — so the HITL queue continues to work unchanged.",
      "decision": "All operator-initiated chat-tool WRITES require the request to carry header 'X-Sync-Source: customer-pricing-folder'. The header is set by Customer-Pricing/scripts/post-session-sync.js on every POST. The check lives in src/lib/edit_mode_guard.ts (guardOperatorWrite) and is invoked from src/chat_tools/impls.ts right after the role gate, gated on a centralized OPERATOR_WRITE_TOOLS set (26 tools: update_customer_pricing, update_customer_price_proposed, update_bid_line, log_external_bid, prepare_next_sy_commit, send_customer_quote_email, send_quote_to_customer, send_bid_response_email, notify_bid_decision, propose_bid_response, create_so_from_awarded_bid, create_new_customer_quote_commit, bulk_add_item_to_customers, bulk_update_prices_for_item, bulk_version_bump, save_data_tagger_template, apply_extraction_to_so_draft, propose_email_to_customer, propose_customer_program, propose_bulk_cost_basis, propose_cleanup_pricing_orphans, propose_bulk_approve, backfill_vendor_items_netsuite_id, recompute_assembly_cost_rollup, backfill_quote_lines_from_quotes, suggest_cost_basis_from_similar). System-initiated writes carry header 'X-System-Caller' with value in {cron, email-handler, ns-sync, workflow-runner} and bypass the guard. Read tools are unaffected. Feature-flagged via Worker env EDIT_MODE in {folder_only (default), open (legacy unrestricted, emergency rollback only)}. The Customer-Pricing dashboard (GFS_Pricing_Dashboard.html + cloudflare-deploy mirror) surfaces a dismissible 'Read-only mode' banner when /api/healthcheck reports edit_mode=folder_only and intercepts Quick-Action buttons that route to write tools, replacing them with a modal that walks Mike through the folder-drop workflow. The CLAUDE.md / CLAUDE_CONTEXT.md files in the folder are updated to make the invariant the FIRST critical-rules block a session reads.",
      "consequences": "POSITIVE: (1) Single canonical edit surface — audit thread is clean, every write has folder-side provenance (STATUS.json + Price_History/ snapshot) AND platform-side provenance (X-Sync-Source header, Idempotency-Key, proposed_actions row). (2) UX matches Mike's mental model — 'I edit in the folder; the platform reflects'. (3) Stops a class of bug where chat-initiated writes drift the folder from D1. (4) System automation paths (email + cron + NS sync + workflow runner) explicitly demarcated by the X-System-Caller header — discoverable in logs. NEGATIVE: (1) ~10s latency between a folder edit and the platform reflecting it (sync script must run + idempotency-keyed POST round-trip). Acceptable given the operator pace. (2) Emergency edits without the folder require an EDIT_MODE=open Worker-secret flip — adds friction by design. (3) New write tools added later need to be appended to OPERATOR_WRITE_TOOLS or they silently bypass the gate — caught by adding the [writes] marker to prompt.ts + the audit query. RELATED: ADR-031 (HITL invariant — preserved; approvals continue to bypass since they are not operator-initiated writes), ADR-037 (Bid Center — its write tools are all in the guarded set), ADR-038 (Data Tagger — save_data_tagger_template + apply_extraction_to_so_draft are in the guarded set). EXTENDS: ADR-028 (Karpathy dual-intake / chat-as-omni-interface) — chat remains the omni-interface for READS and for narrating the folder-driven write workflow; only the act of initiating a write is now narrowed to the folder.",
      "implementation_date": "2026-05-27",
      "implementation_evidence": "src/lib/edit_mode_guard.ts (NEW — 17-test inline harness, all passing); src/lib/index.ts (barrel export); src/chat_tools/impls.ts (OPERATOR_WRITE_TOOLS set + guardOperatorWrite call after role gate); src/index.ts (Env.EDIT_MODE typed, CORS Allow-Headers extended to admit X-Sync-Source + X-System-Caller + Idempotency-Key); Customer-Pricing/scripts/post-session-sync.js (X-Sync-Source: customer-pricing-folder on every POST); Customer-Pricing/CLAUDE.md + CLAUDE_CONTEXT.md (CRITICAL INVARIANT block); Customer-Pricing/GFS_Pricing_Dashboard.html + cloudflare-deploy/public/index.html (dismissible banner + Quick-Action interceptor modal). tsc --noEmit clean."
    }
  ]
}