A voice-first produce ordering platform for Romania where a buyer speaks what they need and the system sends real SMS messages to matched farmers — grounded by a catalog-constrained AI pipeline that cannot hallucinate a product.
Last updated 2026-09-25.
Built with TypeScript, Next.js 16, React 19, Gemini API, Prisma 7, PostgreSQL, Zod, Twilio, WhatsApp Cloud API, Tailwind CSS.
ECOU is a voice-first produce ordering platform for Romania. A buyer speaks what they need into a phone — "am nevoie de mere lângă Timișoara" — and the system transcribes it, extracts structured purchase intent, matches them to nearby farmers, and sends real SMS messages to those farmers' phones. The whole path from voice to farmer notification takes under four seconds.
What makes it a different kind of project is the AI pipeline at the centre. A single Gemini call takes raw audio, transcribes the speech, and extracts structured data — product, location, quantity, variety — all in one request. There is no separate speech-to-text step. That halves the latency and removes a whole class of bug where the transcript looks fine but a second model reads it differently than you do.
The model does not get to invent anything. It receives the real product catalog — 61 products with their Romanian and English names, aliases, and IDs — and must pick from that list or return null. A null produces a follow-up question, which is recoverable. A wrong ID texts the wrong farmers, which is not. The same constraint applies to cities and counties: 42 of each, with IDs. The model matches against sound as well as spelling, because Romanian place names are routinely mis-heard by speech-to-text, but when two entries are equally plausible it returns null and lets the conversation settle it.
Even after that catalog grounding, every model output is verified against the database before anything happens. A hallucinated product ID becomes null, not a crash. Grounding is a prompt technique, not a guarantee. The sanitize layer is the guarantee.
It was built as a freelance engagement for a client in the Romanian agri-tech space. The client had a detailed Software Requirements Specification, a PostgreSQL preference, and a demo date. I had two weeks and one decision I was willing to argue about: the SRS wanted dispatch to be automatic, straight from transcription. I argued that machine transcription must never send unreviewed. A confirmation screen exists because of that pushback, and it is the single design decision I am most confident was correct.
The platform runs bilingual. A Romanian buyer who speaks Romanian gets follow-up questions in Romanian and a confirmation screen in Romanian. An English-speaking buyer gets English. The farmer's SMS is always in Romanian — the recipient is a Romanian farm regardless of which language the request was spoken in. That language split is deliberate and enforced in code, not left to the model.
The SMS arrives, the farmer reads it on a lock screen, and they call the buyer's number. That is the whole product. Everything in this case study is about making that path reliable, fast, and safe — where safe means the model cannot hallucinate a farmer into receiving a message about something nobody asked for.
Romania has tens of thousands of small produce farms. A buyer — a restaurant, a retailer, a family wanting bulk fruit — has no reliable way to find which farmer carries what, where, at what price.
The current system, to the extent one exists, is word of mouth and local weekly markets. If a buyer in Timișoara wants 200 kilograms of apples and their usual supplier is out of season, their options are to drive to the next market, ask around, or post in a WhatsApp group and wait. None of those options tell them about a farm in the next county over that has exactly what they need and would be glad for the business.
From the producer's side it is worse. A small farm has no web presence, no listing, no way to signal availability beyond the people who already know about them. Their market is geographically limited to whoever shows up on market day. Growth means a bigger stall, not a wider reach.
BUYER PRODUCER Needs 200 kg of apples Has 400 kg of apples near Timișoara in Arad county (next door) Doesn't know this farm exists <--> Doesn't know this buyer exists Asks friends, drives to market Waits at market, hopes someone comes
The gap is not information that does not exist. The data is real: a farmer in Arad has apples at a price, and a buyer in Timișoara wants apples at a quantity. The gap is that nobody connected them, because there is no system to connect them.
The client's SRS specified a voice interface, and the reasoning was sound. The target users include people in rural Romania who are more comfortable speaking their needs aloud in Romanian than navigating a search interface. A 60-year-old buyer who has never used a product filter can say "vreau zece kilograme de roșii lângă Cluj" and mean something precise. The system's job is to understand that precision and act on it.
Voice also captures nuance that forms lose. A spoken request might include a variety — "mere Golden" — a delivery note, or a name. A dropdown for 61 products does not invite any of that. The model extracts everything it hears, and the parts that do not map to structured fields go into a notes column that travels with the SMS.
Voice also introduces the worst kind of failure: silent wrong answers. Speech-to-text can mishear "Bacău" as "Bihor" and the system would confidently text the wrong county's farmers. Digits are worse — a misheard phone number means the SMS sends, the farmer calls, nobody answers, and everyone's time is wasted with no error message anywhere in the chain.
That is why the entire AI pipeline is designed around the assumption that the model will sometimes be wrong, and the job of the surrounding code is to make "wrong" recoverable rather than catastrophic. The grounding, the sanitize layer, the human confirmation screen, and the decision to never extract phone numbers from voice — all of those are consequences of taking this risk seriously rather than hoping the model is good enough.
The hardest part of this project was not making the AI work. It was making the AI's mistakes harmless.
The buyer speaks. The system understands. The farmer's phone buzzes. Everything in between is verification.
Record audio
→ Gemini: transcribe + extract structured intent (1 API call)
→ Validate: is anything missing?
→ Yes: ask one follow-up question, reopen mic (max 2 rounds)
→ No: show confirmation screen
→ Buyer reviews, corrects, types phone number
→ Match producers by county adjacency graph
→ CAPTCHA gate
→ Dispatch SMS to matched farmers
That flow looks linear, but the interesting decisions are at the branches. Here is what each stage actually does, and why.
The browser captures 16 kHz mono WAV — the one format every browser can produce identically. The recording caps at a configurable maximum. The audio is base64-encoded and sent as multipart form data. No audio leaves the browser until the buyer stops speaking.
Gemini receives the raw audio and the system instruction containing the full product catalog, all cities, all counties. It returns a structured JSON object: transcript, product ID, city ID, county ID, quantity, unit, variety, buyer name, notes, language, and a confidence score. There is no separate transcription step. The model hears the speech and extracts the intent in the same inference pass, so a Romanian accent on a city name is resolved by the same model that heard it, not by a second model reading a possibly-garbled transcript.
If the product or location is missing, the system asks exactly one follow-up question — spoken aloud in the buyer's own language. "I heard you ask for something near Timișoara, but I could not tell which product. What are you looking for?" The mic reopens, the buyer answers, and the new extraction is merged with the prior fields. At most two rounds. Past that, the form appears and the buyer finishes by hand. One question at a time, because asking three at once reliably gets one answered and the buyer talking over the TTS.
The SRS wanted dispatch to happen automatically after extraction. I pushed back. Machine transcription must never send unreviewed, because the failure mode is not a bad user experience — it is a real SMS to a real farmer about something nobody asked for. The confirmation screen shows every extracted field with dropdowns for product, city, and county, so the buyer can correct what the model got wrong. The phone number is typed here, never extracted from voice, because spoken digits are the single worst thing speech-to-text does.
The system searches for producers who carry the requested product in the buyer's county. If nobody is found, it widens to adjacent counties using a pre-computed adjacency graph — the same "nearby, then wider" behaviour a GPS radius search would give, modelled without coordinates. Widening is all-or-nothing: if the buyer's own county has anyone, neighbours are not mixed in. Matches are sorted by city proximity first, then by price.
Every field the client sends is re-validated server-side. The match is re-run from the database, not accepted from the browser. Rate limits are checked. The CAPTCHA is verified. Only then does the system compose an SMS in Romanian, strip diacritics to keep it in GSM-7 encoding (one segment instead of three), and send it through WhatsApp Cloud API or Twilio to every matched producer. The SMS says what the buyer wants, where, how much, and the number to call. That is all. The farmer picks up their phone and calls.
The confirmation screen is the most important screen in the application, and it is the one the original spec did not have. A buyer who speaks "mere" and gets matched to "Morcovi" (carrots) because the model misheard can fix it in a dropdown. Without that screen, five farmers get an SMS about carrots and nobody gets one about apples. The cost of being wrong is other people's time, and that is the cost the confirmation screen exists to prevent.
The end-to-end pipeline was tested with a real phone. On 2026-08-08, the system transcribed Romanian speech, extracted structured intent, matched producers, and dispatched a real WhatsApp message that arrived on a +91 handset with the correct product, location, quantity, and callback number. Provider was meta-whatsapp, logged in SmsLog.
Sole engineer. The client provided a detailed Software Requirements Specification. I designed the architecture, wrote every line of code, and made one deliberate deviation from the spec that I consider the most important decision in the project.
| Area | What I built |
|---|---|
| AI pipeline | The Gemini integration, the system prompt with catalog grounding, the extraction schema, the sanitize verification layer, the multi-turn follow-up system, and the confidence scoring. |
| Voice interface | Browser audio capture, 16 kHz WAV encoding, the record → ask → confirm state machine, TTS for follow-up questions, and language detection. |
| Backend | Five API routes (voice, request, preview, challenge, dispatch), the request state machine, producer matching, and the SMS dispatch pipeline. |
| Database | A Prisma 7 schema on PostgreSQL with geography (counties, cities, adjacency), product catalog, producer listings, buyer requests, and SMS logs. |
| SMS/WhatsApp | A provider abstraction supporting mock, Twilio, and WhatsApp Cloud API (Meta). GSM-7 encoding, message composition, segment counting, and the WhatsApp conversation window handling. |
| Security | HMAC-signed arithmetic CAPTCHA, per-IP rate limiting, per-session dispatch caps, daily global SMS ceiling, and admin authentication with password hashing. |
| i18n | Full English and Romanian localisation with plural rules, locale detection, and the deliberate split between the buyer's interface language and the farmer's SMS language. |
| Testing | Ten test files covering phone normalisation, unit conversion, slug generation, validation logic, i18n plurals, SMS message composition, challenge tokens, and provider integration. |
| Admin panel | Producer CRUD, product CRUD, listings management, and a server-action-guarded admin interface. |
| Public directory | A server-rendered product and producer catalogue with zero client JavaScript, showing coverage, price ranges, and seasonal availability. |
The SRS specified automatic dispatch — the system would transcribe the buyer's voice, match producers, and send SMS messages without the buyer reviewing anything. The spec was explicit about this being the intended flow.
I pushed back. Machine transcription must never send unreviewed when the downstream effect is irreversible and lands on real people. A misheard product name means five farmers get an SMS about something nobody asked for. A wrong county means farmers in Brașov get a message meant for buyers in Bihor. There is no unsend.
The argument was not that the model would be bad. The argument was that even a good model is sometimes wrong, and the cost of being wrong is not a retry — it is someone else's time and attention. The confirmation screen costs the buyer three seconds of review. The absence of it costs farmers false leads and costs the platform credibility.
The client agreed. The confirmation screen went in, and it is the single decision I would defend the longest. Every other design choice in this project can be revised. This one is load-bearing.
The SRS also asked for PostGIS radius search. I replaced it with the county adjacency graph because no data source gives producer coordinates at POC stage, and the adjacency approach gives the same nearby-widening behaviour with no geocoding. That deviation was pragmatic. The dispatch deviation was principled. Both were documented.
This is where the engineering judgment lives. Every decision in this tab exists because a language model is involved in an action that reaches real people, and the system has to be right even when the model is not.
A conventional voice pipeline runs speech-to-text first, then passes the transcript to a second model for extraction. ECOU skips the middle step. Gemini receives the raw audio as an inline data part alongside a system instruction, and returns structured JSON in a single inference pass.
This is not just a latency optimisation, though it halves the time the buyer waits. It removes a class of bug that two-model pipelines have: the transcript looks correct to a human reader but the extraction model interprets it differently. A Romanian accent on "Timișoara" might transcribe as "Timishwara" — fine for a human, ambiguous for a model that was trained on the standard spelling. When the same model hears the audio and extracts the intent, it resolves the accent at the source instead of inheriting a transcription artefact.
| Two-model pipeline | ECOU's single-call approach |
|---|---|
| Audio → STT model → transcript text | Audio → Gemini → structured JSON |
| Transcript → extraction model → structured data | (same call) |
| ~3-4 seconds total | ~1.7 seconds |
| Transcript errors propagate to extraction | Model resolves ambiguity from the audio signal directly |
| Two API calls, two latency windows, two failure modes | One call, one failure mode |
The central idea: the model never invents a product or a place. The system prompt contains the entire product catalog (61 products with English names, Romanian names, aliases, categories, and IDs), all 42 cities (with their county), and all 42 counties. The model must return an ID from these lists, or null.
This is grounding in the formal sense — constraining the model's output space to a known set of valid values. It is the same principle as retrieval-augmented generation (where the model must answer from retrieved documents), applied to a structured catalog instead of free text. The difference is that a RAG system hopes the model stays faithful to its sources. ECOU enforces it.
SYSTEM PROMPT (simplified) "Products, cities and counties come from the catalogs below. Return the id. If nothing clearly matches, return null. Do not pick the nearest thing. A null produces a follow-up question, which is recoverable. A wrong id texts the wrong farmers, which is not." PRODUCTS (id, English / Romanian, category) 1 Apples / Mere FRUIT 2 Pears / Pere FRUIT ... 61 Walnuts / Nuci OTHER CITIES (id, name, county) 1 Timișoara Timiș county ... COUNTIES (id, name) 1 Timiș ...
Both language names are given for every product because the buyer may speak either. "I need apples", "am nevoie de mere", and "vreau niște mere Golden" all have to land on the same ID. Listing the Romanian label explicitly rather than burying it in an alias list means a product with thin aliases still resolves in Romanian.
This is why there is no fuzzy-match layer, no synonym table, and no string similarity function anywhere in the codebase. "Do you mean apples or pears?" is a question the model answers with better context than any edit-distance function, and when it genuinely cannot tell, it says null and the follow-up question settles it.
Grounding is a prompt technique, not a contract. A model that was told to pick from 61 product IDs can still return 999 if it is confused enough. The sanitize layer is the contract.
After the model returns structured JSON, every ID is checked against the in-memory catalog. A product ID that does not exist in the productById map becomes null. A city ID that does not exist becomes null. A county that contradicts the city is overridden — a city is the more specific signal, so it wins. A language code that is not "ro" or "en" falls back to the interface language.
The result: after sanitize(), a non-null productId is certainly real. The rest of the codebase can treat it as a database foreign key without a second thought. This is groundedness in the measurable sense — every model output is validated against a source of truth, and ungrounded outputs are nullified rather than propagated.
MODEL RETURNS SANITIZE DOES RESULT productId: 5 5 exists in catalog productId: 5 ✓ productId: 999 999 not in catalog productId: null cityId: 12 city 12 is in county 7 cityId: 12, countyId: 7 countyId: 3 (conflict) city wins countyId: 7 (overridden) language: "romanian" slice to "ro" language: "ro" language: "xyz" unrecognised language: (UI locale)
The model's output is constrained twice. First, Gemini's responseSchema parameter defines the expected JSON shape with typed properties — integers for IDs, nullable strings for text, a float for confidence. This is a strong hint to the model that shapes its generation, but it is not a validator on the wire.
Second, a Zod schema validates the actual response. If the model returns malformed JSON, Zod catches it. If it returns a negative quantity, Zod catches it. If it omits the transcript, Zod provides a default. The two schemas mirror each other deliberately — responseSchema guides generation, Zod enforces correctness.
Temperature is set to 0. Extraction is a reading task, not a creative one. The same audio re-submitted must produce the same farmer match, because the farmer who gets the SMS did not change. A model that lands on a different product ID on a re-run has introduced randomness into who receives a real message. Temperature 0 eliminates that.
The model is explicitly told: never do arithmetic and never convert units. If the buyer says "two crates", the model returns quantity 2 and unit "crate". If they say "half a ton", it returns quantity 0.5 and unit "t". The conversion to kilograms happens in units.ts, deterministically, with a tested conversion table.
This is a deliberate trust boundary. Unit conversion is multiplication, and multiplication can be verified with a test. A model that converts "două lăzi și jumătate" (two and a half crates) to kilograms is doing arithmetic it was not built for, in a language where numerical agreement is complex, and producing a number nobody can verify without re-reading the prompt. The code does it in one line, and the test proves it.
Spoken digits are the single worst thing speech-to-text does, and the failure mode is silent. The model hears "zero seven four three" and transcribes it as 0743. If one digit is wrong, the SMS sends to the right farmer, the farmer calls the number in the message, and nobody answers. The buyer never knows, the farmer wasted a call, and no error appears anywhere in the system.
So the phone number is typed on the confirmation screen, never extracted from voice. The prompt still accepts a buyerPhone field if the buyer speaks one unprompted, but validation.ts never asks for it by voice. The VOICE_ASKABLE list is product, location, and quantity. Phone is excluded by design.
The prompt handles Romanian and English equally. The system instruction tells the model which interface language the buyer started from ("the interface is in Romanian, so Romanian is more likely — but do not assume it") and requires it to detect the actual spoken language. The catalog lists both names for every product, so "I need apples" and "am nevoie de mere" both resolve to the same ID.
The detected language flows forward into the conversation. A Romanian speaker on the English page gets Romanian follow-up questions. The switch happens on the server — the client only knows which language button was pressed, not which language came out of the microphone.
When the first extraction is incomplete — no product, no location — the system asks a follow-up question. The next audio submission carries a requestId, and the server loads the established fields before calling the model again. The system prompt includes a priorBlock that tells the model which fields are already known, so it can fill gaps rather than start over.
New extraction results are merged with prior fields using a last-write-wins strategy per field. A field the new extraction filled overwrites the prior. A field it left null keeps the prior value. The transcript is appended rather than replaced, so the full conversation history is preserved.
At most two follow-up rounds. Past that the buyer feels interrogated, so the form appears and they finish by hand. The cap is enforced in code, not left to the model's judgment about when to stop asking.
The model's confidence score (0–1) is extracted but never used as a hard filter. It is shown in the admin panel for debugging and could inform future UX decisions, but no farmer misses an SMS because a model was 0.6 confident instead of 0.7. That threshold would be a false precision on a number the model itself cannot calibrate.
The AI pipeline gets its own tab because it is the hard problem. This tab covers everything else that had to be right for the voice-to-SMS path to work reliably.
The SRS asked for PostGIS radius search. No data source gives producer GPS coordinates at POC stage, so distance is modelled as the county adjacency graph instead. Romania's 42 counties are stored with explicit adjacency edges (a join table, two directed rows per undirected edge), and matching searches the buyer's county first, then widens to its neighbours.
Widening is all-or-nothing by design. If the buyer's own county has any producers for that product, neighbours are not mixed in. A buyer in Timiș would rather see three local farms than three local plus eleven from two counties away. Within the home county, someone in the buyer's actual city sorts first, then price breaks remaining ties.
findProducers(productId, countyId)
│
├── Search buyer's county
│ └── Found? → return (sorted: same-city first, then price)
│
└── Empty? → Search adjacent counties
└── Found? → return (widened: true)
└── Empty? → NO_MATCH
The adjacency graph is the one piece designed to be swapped. When producers have real lat/lon, the graph becomes a radius query. The matching API does not change — only the function that returns the candidate county IDs.
A buyer request moves through a defined lifecycle. The status is always computed from the fields, never set by the client.
VOICE_RECEIVED → STRUCTURED → NEEDS_INFO → VALIDATED → CONFIRMED → DISPATCHED
↘ NO_MATCH
↘ FAILED
Both the raw and resolved forms of every field are persisted. When a demo goes wrong, the first question is always "what did it actually hear?" The rawExtraction JSON plus transcript answer it without a re-run.
A standard GSM-7 message is 160 characters per segment. Any character outside the GSM-7 set — including Romanian diacritics like ă, ș, ț, î, â — forces the message into UCS-2 encoding, which bills at 70 characters per segment. "lângă Craiova" costs three segments. "langa Craiova" costs one.
The SMS body is stripped to plain ASCII via Unicode NFD decomposition before sending. Every message targets a single GSM-7 segment, because the SMS bill is the one operational cost that scales linearly with usage and is the client's actual concern.
A request that has already been dispatched returns 409 Conflict. This guards against double-submit — the buyer clicking the send button twice, a browser refresh, or a network retry. A dispatched request is dispatched exactly once. The check is a database status lookup, not a client-side flag that could be bypassed.
Three independent limits protect the SMS bill, layered because each catches a different abuse pattern.
| Layer | Scope | Default | What it catches |
|---|---|---|---|
| Per-IP hourly | IP address | 3 dispatches/hour | Scripted abuse from one origin |
| Per-session | Browser session | 5 dispatches/session | One person trying too many requests |
| Daily global | Entire platform | 200 SMS/day | The hard ceiling that bounds the bill, regardless of source |
A failed CAPTCHA or an invalid phone number does not consume a rate limit slot. Only a request that passes all validation and reaches the dispatch step counts against the cap. This is deliberate — a mistyped phone number should not burn one of the buyer's three hourly attempts.
The confirmation screen presents an arithmetic challenge — a sum of two small numbers. The server mints the challenge with an HMAC signature over the operands and a nonce. The answer is never in the token. The client submits the token and the answer, and the server recomputes the expected result from the signed operands.
This is a speed bump, not an identity check. An attacker with an LLM solves it trivially. Its purpose is to raise the cost of scripting /api/dispatch from zero to nonzero. The daily global cap is what actually bounds the damage. Production wants a real provider.
Three SMS providers are implemented behind a common interface: mock (for development), Twilio, and WhatsApp Cloud API (Meta direct). The provider is selected by environment variable.
WhatsApp Cloud API — Meta's direct route — is the only provider proven end to end. It is free, requires no business verification, and successfully delivered the app's real SMS body to a +91 handset during testing. Its constraints are a recipient allow-list of five numbers and a 24-hour customer service window after the recipient initiates contact.
Twilio's trial tier cannot send this app's message on either channel. SMS rejects anything that is not a canned Twilio template, and WhatsApp returns "ContentSid Required" even within an open conversation window. Both are business rules, not credential issues, and only a funded account moves them.
Never treat "accepted" as "delivered" on any channel. An international long code SMS through India's carrier network is routinely filtered silently — the provider reports acceptance and the message never arrives. Only a handset settles it.
The i18n system enforces a deliberate split. The buyer's interface follows their language preference — Romanian or English. The follow-up questions use the language the buyer actually spoke, detected by the model. The farmer's SMS is always in Romanian, because the recipient is a Romanian farm regardless of which language the request was made in.
This three-way split is enforced in code, not left to implicit agreement. The SMS composition function takes a locale parameter, and smsLocale() reads SMS_LOCALE from the environment with a Romanian default. Setting it to English is documented as a demo affordance — it lets an English-speaking audience read the message on screen during a presentation.
Romanian product and place names carry diacritics that most keyboards do not produce and most users do not type. slug.ts folds diacritics via Unicode NFD decomposition so that a text search for "capsuni" finds "Căpșuni" (strawberries) and "timis" finds "Timiș". The slug is stored on every product, city, and county row, and the directory uses it for URL paths and search.
ECOU is a proof of concept, delivered for a client demo. This tab is an honest accounting of what a POC ships, what it does not, and what production would need.
A farmer receiving messages about their produce is GDPR processing. The POC has no consent flow and no opt-out mechanism. Production needs both before the first SMS sends to a phone number the platform did not receive explicit consent for.
Rate-limit buckets, spent challenge tokens, and the catalog cache all live in a JavaScript Map — in memory, per process. Correct for the single instance a POC runs on. Behind a load balancer each store becomes N times looser, because each process has its own counters. Production needs Redis or an equivalent shared store.
The arithmetic CAPTCHA raises the cost of scripting from zero to nonzero. An attacker with an LLM solves it trivially. Production wants reCAPTCHA, hCaptcha, or Cloudflare Turnstile — something that costs real money to defeat at scale.
The system knows a message was accepted by the provider. It does not know it was delivered to the handset, read by the farmer, or acted upon. Twilio delivery receipts, WhatsApp read receipts, and a feedback loop from farmers would close the gap between "sent" and "useful".
The model is tested manually — speak, check, adjust. Production wants a benchmark: a set of recorded audio samples with known-correct extractions, run against each model version before deployment. The confidence score is already extracted; an evaluation pipeline would give it a calibration dataset.
The rate limits bound the bill. They do not detect a pattern of requests designed to enumerate the producer database — which producers carry what, where, at what price. The producer phone number never leaves the server for a buyer response, but the public directory already shows names, cities, and price ranges. Production needs to decide how much of that is intentionally public.
| Component | POC approach | Production approach |
|---|---|---|
| Rate limiting | In-memory Map, per process | Redis, shared across instances |
| CAPTCHA | HMAC-signed arithmetic | reCAPTCHA / Turnstile |
| SMS provider | WhatsApp Cloud API (free, 5 recipients) | Funded Twilio or MessageBird account |
| Model | Pinned to gemini-3.1-flash-lite | Evaluated against a benchmark suite per version |
| Consent | None | GDPR consent flow + SMS opt-out |
| Proximity | County adjacency graph | PostGIS radius search with producer coordinates |
| Catalog cache | In-memory, per process | Redis or CDN-cached, invalidated on admin changes |
| Delivery tracking | Provider acceptance only | Delivery receipts, read receipts, farmer feedback |
This list is honest rather than aspirational. Everything in the "Production approach" column is a real gap, not a feature backlog padded for completeness. The POC proves the pipeline works. Production proves it works at the scale where the gaps matter.