DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Crowdsourcing data into a health app with no moderation queue

Munchable reads a food label and tells you whether the product suits your gut condition. When we do not have a product, we ask the person standing in the supermarket aisle to photograph the label, and what they send becomes the data every other user sees for that barcode.

That is a crowdsourced write path into health-adjacent data, with no moderation queue, no human review step, and not even a "does this look right?" confirmation screen for the contributor.

Here is how you make that safe, which turned out to be almost entirely a state machine problem rather than a machine learning one.

Decision one: no human confirm step

The instinct is to show the contributor the parsed result and ask them to check it. We deliberately do not.

A confirmation step feels like a safety measure and mostly is not. The person tapping "looks right" is standing in an aisle, has no idea what our taxonomy considers correct, and will tap the button. What you have built is a consent screen that transfers blame to a user who could not possibly have made an informed judgement, while adding a step that makes the contribution flow worse for the well-meaning majority.

Trust comes from three things instead: machine gates at ingest, consensus between independent contributors, and a visible "Doesn't look right?" button for every reader afterwards.

The gates: pure functions, hard rejections

Nothing gets persisted if a gate fails, and every gate is a pure function over plain values so it can be unit tested without a request.

export const MAX_TEXT_LEN = 8000;
export const MAX_TOKENS = 150;
export const MAX_TOKEN_LEN = 120;
Enter fullscreen mode Exit fullscreen mode

The important one is subtler than a length check:

/**
 * Text sanity: an ingredient statement is short-ish, tokenizes, and is not a
 * pasted image or a wall of one word. Structure is derived from THIS text only,
 * so this is also the poisoning guard: nothing else the client sends shapes
 * the tags every later scanner sees.
 */
Enter fullscreen mode Exit fullscreen mode

Structure is derived from the submitted text only. The client sends other fields, and none of them influence the ingredient tags that later scanners are scored against. That is the whole poisoning surface, closed by construction rather than by validation. A malicious client can send whatever it likes in the other fields and it cannot reach the thing that produces verdicts.

Two smaller decisions worth stealing:

We removed the quality floor. There used to be a rule that at least 80 percent of a capture's words had to be in our ingredient lexicon. It is gone:

// There is no lexicon hit-rate floor (removed 2026-09-13; it was 0.8). A
// capture whose words the lexicon does not know yet is still a real product,
// and it is saved with its honest unknown count.
Enter fullscreen mode Exit fullscreen mode

The floor was rejecting real products for the crime of containing ingredients we had not learned yet. It was measuring our coverage and blaming the contributor for it. Saving the capture with an honest count of unknown words is strictly better: the data is preserved, the gap is visible, and the nightly curation job now has something to work on.

Generous limits where reality is messy. The allergen field accepts 2,000 characters, because a pan-European pack prints its precautionary advice in ten languages and a conscientious contributor transcribes the whole block. A limit tuned to English packaging silently truncates exactly the most careful submissions.

The trust state machine

Once a capture is accepted, the real question is what it does to what everyone else sees. Four statuses, four possible decisions:

export type CatalogStatus = 'unverified' | 'consensus' | 'flagged' | 'withheld';
export type TrustDecision = 'insert' | 'preserve' | 'promote' | 'replace';

/** Two independent reads of the same panel agree at or above this similarity. */
export const SIMILARITY_CONSENSUS = 0.85;
Enter fullscreen mode Exit fullscreen mode

Consensus is the core idea: two different people photographing the same physical ingredients panel should produce nearly the same text. OCR noise means "nearly", so we compare with Postgres trigram similarity and treat 0.85 as agreement.

The transition table is where all the adversarial thinking lives:

Situation Decision Result
No existing capture insert unverified
Different contributor, similarity ≥ 0.85 promote consensus
Same contributor, others have reported the row preserve status and reports kept
Row withheld, someone else captures it replace unverified, never straight to consensus
Different contributor below 0.85 replace unverified, must earn trust again

Read the third row carefully, because it is the attack:

resubmitting your own row never launders someone else's report

Without that rule, anyone whose contribution got reported could simply submit it again and clear the complaint. The system would be self-cleaning in favour of whoever was most persistent. Keying the decision on contributor identity plus who reported it closes that.

The fourth row is the same instinct applied to a subtler case. A withheld row took two distinct reporters to get there. A fresh capture replaces it at unverified and is never promoted straight to consensus, however closely the new text matches the disputed text. Matching a text that two people said was wrong is not evidence of correctness.

And the last row: changed content earns trust again from zero. A genuine reformulation lands there too, which is correct. The recipe changed, so the old agreement is about a product that no longer exists.

The bug hiding in "is there a row?"

We keep imported seed rows and community-contributed rows in one table. That is the right call and it produced the most interesting bug in this system.

Trust has to key on captures only, not on rows:

/**
 * A seed row has no revision to compare against, no contributor to agree or
 * disagree with, and nobody has vouched for it, so a capture landing on one is
 * the FIRST capture: `insert`, at unverified, and reward-eligible if it is a
 * real find. Passing a seed row into decideTrust instead would make it non-null
 * `existingStatus`, which would quietly turn every first capture on a known
 * barcode into a `replace` and cost the contributor their reward.
 */
export function existingCapture<T extends ProductRowFacts>(
  row: T | undefined | null,
): (T & { currentRevisionId: string }) | undefined {
  if (!row || row.source !== 'community' || !row.currentRevisionId) return undefined;
  return { ...row, currentRevisionId: row.currentRevisionId };
}
Enter fullscreen mode Exit fullscreen mode

"Is there a row for this barcode?" and "has anyone contributed one?" became different questions the moment two kinds of row shared a table, and every piece of code that conflated them was wrong in a way that only showed up as users quietly not getting rewards they had earned.

The narrowing in the return type is doing real work too. A database CHECK constraint guarantees a community row has a current revision, so asserting it here makes the similarity lookup that follows total rather than something with a null branch nobody can reason about.

One definition of "servable", used everywhere

/**
 * THE definition, used by the lookup (which answers "not found" when this is
 * false), by the contribution gate (which calls that a 'miss') and by the
 * reward check. Written out separately in each place, the three drifted the
 * moment one of them learned about a new status: the scanner would say "not
 * found" while the capture screen said the product was not open, and a reward
 * would be owed for a barcode that was already being served.
 */
export function isServableRow(row: GateRowFacts | undefined | null): boolean {
  if (!row || row.status === 'withheld') return false;
  return (row.ingredientsTags?.length ?? 0) > 0;
}
Enter fullscreen mode Exit fullscreen mode

Three features asked the same question and each had its own answer. Adding one status made them disagree, and the symptom was a user being told a product was not found and then told it was not open for capture, in the same thirty seconds.

The fix is not clever. It is one exported function that all three call.

The fallback that matters more than the fast path

We cache "this barcode was a miss" in Redis so the capture gate can answer instantly. Redis is best effort, and treating it as authoritative was a real bug:

a marker write that failed, a marker that expired between the scan and the capture, or a Redis outage all used to close the gate, and the user who had just photographed a label Munchable did not have was told the product was "not open"

The catalogue can answer the same question on its own, slower. So it does. If your fast path is best effort, the slow path has to be able to answer the question completely, not just serve as a cache miss handler. Otherwise a cache outage becomes a correctness outage, and it hits precisely the users who were doing you a favour.

What I would take from this

  • A confirmation step that a user cannot meaningfully evaluate is theatre. Spend the effort on gates and consensus.
  • Every trust transition should be tested against "what if the same person does this twice?"
  • Never let a fresh submission launder an existing complaint.
  • If one table holds two kinds of row, find every place that asks "is there a row?" and work out which question it actually meant.
  • Derive everything a later reader is scored against from exactly one client-supplied field, so the poisoning surface is a property of the design rather than a checklist.

You can see the read side of all this at munchable.app/answers, where the engine's verdicts are published as pages. The contribution flow itself lives in the app, which is free to try with no card: scan something obscure from the back of your cupboard and there is a decent chance you will find a gap and get to fill it.

Top comments (0)