Refusal and Move Contract
Three sentences make up this contract.
A move is a walk of occurrences, not an event. Reaching a state can take more than one step, and which steps depends on where the card is now.
A refusal explains itself in the user’s words. The backend answers a refused move with a reason code; the frontend owns the sentence a cardholder reads, and every screen shows it.
A walk that stops part-way says where it left the card. The occurrences already taken are facts on the card’s log, so a failure carries the truth about what happened rather than only the intent that failed.
A move is a walk
Section titled “A move is a walk”The deployment’s lifecycle marks some statuses active, and a move may not skip an active status. That rule belongs to the kanban module, which owns it and states it — see Kanban Cards. The frontend adapts to it and does not own it.
Because READY is active in this deployment, “restock a card that is in receiving” is not one event but two: complete production, then record the arrival.
sequenceFor(from, to) answers what a move costs, and its shape carries two decisions.
from is an argument, not a lookup. Every caller already knows where the card is. The version that inferred it read a cache the caller had never written to, so on the items grid the verb depended on whether the user had previously visited the order queue.
Backward and forward are different questions. Forward is a walk along the cycle. Leaving a state is what decides a backward move, because shelve un-accepts onto the same cycle where request opens a new one, and the guards refuse each in the other’s place.
moveCard walks the sequence, and three properties of the walk are deliberate:
- A step that fails stops the walk where it is and does not restart. Re-sending an occurrence already taken is refused as a backward step, so a retry resumes at the step that failed.
- A refusal is not retried. A card the rules say no to gets the same no three times, and the user is owed the reason now rather than after two more round-trips.
- An authentication failure is not retried either. Retrying delays the sign-out the caller is waiting to offer, and turning it into “contact support” hides the one thing the user can fix.
A refusal explains itself
Section titled “A refusal explains itself”KANBAN_REFUSAL_COPY maps each backend reason code to a sentence written for someone holding a card, never named for the code. Twelve codes carry copy today. An unmapped code falls back to naming itself and reports to Sentry, because a code with no copy means the backend and the frontend have drifted, and the count is the signal.
Reading a refusal is a recursive walk, not a field access. A composite refusal carries one reason per entry under details.errors, each with its own nested details.reason. A reader that inspects only details.reason at each node finds nothing in a composite and reports it as an ordinary failure. refusalReasons descends cause, causes, errors, data and details to a bounded depth and de-duplicates; refusalMessage returns nothing when the body carries no code at all, which is how a caller tells a guard refusal from an ordinary failure.
KanbanRefusal extends Error, and the distinction it draws is the useful part: its message is copy fit to show a user; an ordinary error’s message is a diagnostic. Two functions act on that distinction.
withReason appends a refusal’s explanation to the caller’s line and returns the caller’s line byte-for-byte unchanged for anything else. Appended rather than substituted, because the caller’s line says which action failed and a reason alone leaves the user to guess. The sentence break is added inside the function so the no-refusal case reads exactly as before.
firstRefusal picks the failure that explains itself out of a batch that failed for mixed reasons. Taking the first failure would show a reason only when the refusal happened to come first — a message that appears and disappears with ordering.
Action names and their success and failure lines interpolate the state label rather than spelling it, so the words a user reads come from one place. Two screens writing “Restocked” themselves is two places to drift, and they had already been copied once.
A stopped walk says where it left the card
Section titled “A stopped walk says where it left the card”The problem is specific, and the adaptation created it. A walk commits the production step and then fails on the arrival. The backend has the card at READY. Every caller still believes it is in process. The retry re-sends the production step, and the guards refuse it as a backward move — permanently, until something re-reads the card.
So the walk re-reads on a part-way failure and records the outcome on the failure it throws. One reader gives the caller three answers:
| Answer | Meaning | What a caller does |
|---|---|---|
| nothing committed | the card did not move; the caller’s belief holds | retry unchanged — safe |
| committed, with a reached state | it moved, and here is where | reconcile from that state |
| committed, no reached state | it moved and the re-read failed | reconcile — this is the loud case |
Four decisions there were each got wrong first.
One reader, not two. An earlier version answered with the state alone, so “nothing moved” and “moved, could not read” were the same empty answer — the safe case and the dangerous one wearing one face. Two functions would have restored the same hazard by letting a caller check one and not the other.
The reached state is the backend’s status string, not one of the app’s card states. The state a stopped walk most often reaches is READY, which this app deliberately does not model, so mapping it into the app’s vocabulary would answer “unknown” for the commonest case. Carrying the string is a ruled decision; READY stays outside the app’s union until a screen has to render it.
The record sits on the thrown object, not on one error class. A stopped walk throws whatever its failing step threw — a refusal, an occurrence failure, or a retry-exhausted error — and all three leave the card somewhere. On a single class it would have covered the least common case and missed refusals, which are the commonest way a walk stops.
No re-read for an expired session, which needs a sign-out rather than a read it has no permission for; and none when nothing committed, which would spend a round-trip telling the caller what it already knows.
Three surfaces act on the outcome, each of which previously left a card the backend had moved looking as though it had not: the card-state menu refreshes; the order-queue batch publishes staleness for the committed-but-failed card, the one site where staleness escapes the collection because the collection reconciles only its own rows; and the receiving list drops those cards from in-transit without calling them fulfilled, since where they actually are comes from the next read.
See also
Section titled “See also”- Kanban Cards — the module that owns the active-status rule this contract adapts to.
- Frontend Data and State Patterns — the section this page belongs to, and its target-state framing.
Copyright: © Arda Systems 2025-2026, All rights reserved