Munchable scans a barcode and checks the product against conditions the reader has. When the barcode is not in the catalogue, the app asks the reader to photograph the ingredients panel instead, and that capture becomes a row everyone else benefits from. Contributors get a subscription discount for finds.
That means a few hundred lines of code are answering questions like "is this barcode open for a capture", "what does an accepted capture do to the row that is already there", and "has this product actually been contributed, or does it merely exist". Getting those wrong does not throw. It quietly tells a user who just did the work that their product was not wanted, or it loses them a reward they earned.
Everything below lives in two files of pure functions with no I/O. The routes gather facts under a row lock and call them; the unit tests exercise the exact decision tables without a database. That shape is most of why any of it is debuggable.
Three copies of "do we have this?"
The first function is nine lines and it is the one I would most like to have written first:
/**
* Is there anything here Munchable may serve for this barcode?
*
* THE definition, used by the lookup (which answers "not found" when this is
* false), by the contribution gate below (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 [...]
*/
export function isServableRow(row: GateRowFacts | undefined | null): boolean {
if (!row || row.status === 'withheld') return false;
return (row.ingredientsTags?.length ?? 0) > 0;
}
Three callers, three questions that sound different:
- the scanner asks "do you have this product?"
- the capture screen asks "is this barcode open for me to photograph?"
- the reward check asks "was this a genuine find?"
They are the same question. While each one spelled out its own version, a new row status taught one of them and not the others, and the symptom was a scanner saying "not found" while the capture screen said the product was not open for contribution. Both answers were internally consistent. The user had no way to make sense of either.
The lesson is not "do not repeat yourself". It is narrower and more useful: when several features phrase the same predicate differently, the drift shows up as a contradiction in the product rather than an error in the code. Nothing crashes. Two screens just disagree about reality, and the bug report is "the app is confused", which is nobody's search term.
A gate that does not trust its own cache
Opening a barcode for capture has a fast path in Redis: markers written when a lookup missed, or when someone reported a row. Markers are cheap and they are also, in the real world, wrong sometimes. 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 message was the worst possible one: the user, standing in a shop having just photographed a label, was told the product was not open for contribution.
So the catalogue can answer the same question by itself:
export function gateFromCatalog(row: GateRowFacts | undefined | null, hasOpenReport: boolean): Gate | null {
const disputed = row?.source === 'community' && (row.status === 'flagged' || row.status === 'withheld');
if (disputed || hasOpenReport) return 'dispute';
if (!isServableRow(row)) return 'miss';
return null;
}
Three outcomes. dispute means a contributed row somebody has flagged, so a fresh read is wanted. miss means nothing servable, which is exactly the condition under which the lookup says "not found", because it is the same function. null means the product is served and the way to change it is the correction button, not a blind POST.
The pattern generalises past this app. A cache in front of a question is fine. A cache that is the only source of the answer turns every infrastructure hiccup into a user-visible refusal, and refusals are where you can least afford to be wrong. If the database can answer the question on its own, let it, and keep the cache as the fast path it was supposed to be.
One table, two kinds of row, and a reward that nearly vanished
All product rows live in one table, whichever way they arrived. That single decision has kept the schema simple and it has exactly one sharp edge, which cost a real bug before the comment went in:
/**
* The existing CAPTURE for a barcode, or undefined when there is none.
*
* catalog.products holds imported rows and captured rows in one table, so the
* presence of a row no longer means somebody has contributed one. Trust must
* still key on captures only [...] 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) {
if (!row || row.source !== 'community' || !row.currentRevisionId) return undefined;
return { ...row, currentRevisionId: row.currentRevisionId };
}
"Is there a row?" and "has anybody contributed one?" became different questions the day the table started holding both kinds, and every piece of trust logic needs the second one. There is no error if you pass the wrong one. The decision table just takes a different branch and somebody is not paid.
The currentRevisionId narrowing is the same rule from the other side. A database CHECK constraint guarantees a contributed row has a current revision, so this function can return a type where that field is non-null, and the similarity lookup downstream becomes total instead of having a branch for a state the database forbids. A constraint you can narrow a type with is worth more than a constraint you merely trust.
Four things an accepted capture can do
export type TrustDecision = 'insert' | 'preserve' | 'promote' | 'replace';
/** Two independent reads of the same panel agree at or above this pg_trgm similarity. */
export const SIMILARITY_CONSENSUS = 0.85;
The whole decision, in words:
- No row yet: insert, unverified.
- A different person's capture that closely matches what is already there: promote. Two people held the same pack and read the same words, which is the strongest signal this system can get without a human reviewer.
- A row two separate people have withheld, captured fresh by someone else: replace, back to unverified. A disputed row is never promoted straight to trusted, however well the new read matches the text that was disputed.
- Your own row, while other people have reported it: preserve. Status and the reports stay.
- Everything else, including a close-but-not-close-enough match and a reformulated product: replace, unverified. Changed content earns trust again from the start.
The preserve branch is the one worth stealing. Its comment:
// "Reported by others" rather than "any report" is deliberate. The one-button
// correction loop means the contributor's own "Doesn't look right?" is the
// normal way to retake a bad read of their own capture, and that retake must
// replace the row rather than leave it flagged forever.
Two user actions look identical in the database and mean opposite things. A stranger reporting your row is a dispute. You reporting your own row is you fixing your own mistake, which is the behaviour the UI actively encourages. So the predicate is not "are there reports", it is "are there reports from somebody else", and without that distinction the correction loop would be a trap: use the button the app offers you and your row is stuck flagged forever.
Any time a moderation rule counts events, check whether the actor matters. It usually does.
Demote freely, promote reluctantly
The two functions that react to user signals are deliberately asymmetric.
/**
* What a recorded report does to a community row. Demote, never promote:
* one distinct reporter flags the row, a second withholds it (never served
* again until a fresh capture replaces it). A row already withheld stays withheld.
*/
export function nextStatusAfterReport(current: string, distinctReports: number): CatalogStatus {
if (current === 'withheld' || distinctReports >= 2) return 'withheld';
return 'flagged';
}
/**
* What a recorded "Looks right" does to a community row. Promote, never
* demote, and only from `unverified`: one distinct match from someone other
* than the contributor is a second person who held the pack and read the same
* words, which is the same standing a second matching machine read earns.
* A flagged row stays flagged, because a report is a stronger signal than an
* agreement [...]
*/
export function nextStatusAfterMatch(current: string, distinctMatches: number): CatalogStatus {
if (current === 'unverified' && distinctMatches >= 1) return 'consensus';
return current as CatalogStatus;
}
Reports move a row down one step at a time and can never move it up. Agreements move it up exactly one step, and only from the bottom. A flagged row cannot be cleared by people clicking "looks right", because agreement is cheap and a dispute asks a specific question that only a fresh read answers.
That asymmetry is a design position, not an accident of implementation: in a system where the two signals are not equally expensive to produce, treating them symmetrically is how a brigade of agreements buries one correct report. The ratchet only turns one way for free.
Note also the equivalence being drawn in that second comment: one person saying "looks right" is given the same standing as a second independent machine read that matched. Both are "a second party saw this panel and agrees". Whether that is the right exchange rate is genuinely arguable, and it is written down where it can be argued with instead of being buried in a route.
What I am not happy with
- Four statuses and four decisions means sixteen combinations, and the decision table in the comment is prose. The tests cover the branches, but the table is not the artefact being tested, and I have been meaning to turn that comment into a parameterised case list so the documentation and the test are the same object.
-
statusAfterTrustcasts a string to the status union on one branch, which is exactly the kind of cast that is correct until the day a migration introduces a fifth status. -
SIMILARITY_CONSENSUS = 0.85is a tuned number with no test that it is still the right one. It was chosen against real pairs of captures and nothing re-checks the assumption as the catalogue grows.
See the end of it
The trust states are invisible by design: the point of all of this is that a reader never has to think about them. What you can see is the output, on the public pages the same catalogue feeds: the conditions index, the answers, and what Munchable is for, which states the data position in plainer language than this post does.
Related, on the endpoint that stops this gate being a catalogue anybody can write to: our OCR endpoint signs a receipt, because the confidence the client sends us proves nothing.
Top comments (0)