The tool state machine
How it works, in a paragraph. The checker is one client component holding one reducer. Its state is a discriminated union rather than a bag of booleans, which means the combinations that would be bugs are not merely avoided — they cannot be written down. There is exactly one place that decides what the visitor sees, and every failure the tool can encounter is a named member of that union rather than an error boundary.
referred and outcome are terminal for that submission; failed returns to capture because none of its causes are the visitor’s mistake.The shape is the safety argument
The outcome is a tagged union, so a stage only exists on the member that has one:
| Member | Carries |
|---|---|
unreadable | nothing — the photos could not be read |
excluded | nothing — the pattern is not one we will estimate |
stage | the stage, and a confidence of medium or high |
Two absences are doing the work. unreadable and excluded have no stage
field, so “a Norwood verdict on a photo we said we could not read” has no
representation. And confidence has no low — low confidence does not produce a
stage, it produces an abstention, so the risky combination cannot be constructed by
a caller who forgets the rule.
The same union is stated three times, in the three places a wrong record could be created: this type, the API’s response schema, and a check constraint in the database. Any one of them alone would be a convention; together they are a guarantee.
Failures are states, not exceptions
quota, capacity, provider and offline are members of a Failure union and
each renders its own panel. They are kept separate from the outcome deliberately,
because none of them consumes one of the visitor’s checks — they describe
something that went wrong on the way rather than a conclusion about their scalp.
Two of them look alike and are not. quota means this visitor has used their
allowance today; capacity means the tool as a whole has. Telling a first-time
visitor they have used up their checks would be a lie, so the distinction survives
all the way to the panel they see.
Per-photo state, and why converting exists
Each of the two photo slots is its own small union — empty, converting,
ready, invalid — where an upload widget would normally offer two states. The
extra ones earn their place:
convertingcovers the seconds an iPhone HEIC takes to become a JPEG in the browser. Without it the interface looks frozen at exactly the moment most visitors are using it.invalidcarries a reason —type,size,dimensions,heic— so the slot can say what to fix rather than that something is wrong.
The gate blocks by default
The gate has three answers, not two: yes, no, and not sure. A binary would force everyone who genuinely does not know into “no”, which is precisely the population the gate exists to catch.
The blocking condition is written as “not no” rather than “yes or unsure”.
That is not a stylistic choice: a fourth answer added later blocks by default
instead of silently falling through to the permissive branch.
One submit, whatever the visitor does
The submission is keyed on entering the working step rather than on the click, so
a double-tap or an impatient second press cannot start two checks. The same
submission also reuses one idempotency key across retries, so a retry of a failed
attempt does not spend a second check from the allowance.
Deliberately not exported
The helpers that decide whether both photos are ready and whether the gate blocks stay private to the module. Exporting them would invite a second caller to re-derive “can this be submitted”, and two answers to that question is how a disabled button and a live handler end up disagreeing.