Workflow Surfaces: State, Tasks & Time
A funnel that only moves forward, flags that cannot lie, a payoff you can safely double-click, and a courier that never delivers twice.
An application moves through ten statuses along explicitly allowed edges β forward or to Closed, never backward. What an application needs (resume? cover letter?) is never stored; it's computed from what documents actually exist. "Mark applied" is idempotent and returns the live weekly count for the scoreboard toast. Reminders are delivered by an hourly job that claims before it sends.
This module is where Wera becomes a daily tool instead of a pipeline demo. The design problem: workflow state is where products rot. Statuses drift from reality, "needs resume" stickers stay on after the resume exists, double-clicks double-count, reminder emails arrive twice. Wera's answers are all the same shape β make correctness structural.
The build journey
Write the funnel as data
The legal moves live in one map; every status write in the product asserts against it.
Like a board game: pieces advance along printed arrows, any piece can leave the board (Closed), and nothing teleports backward. Here is the entire game board, from packages/core/src/index.ts:
const forwardTransitions: Record<ApplicationStatus, ApplicationStatus[]> = {
discovered: ["saved", "closed"],
saved: ["in_prep", "closed"],
in_prep: ["ready_to_apply", "closed"],
ready_to_apply: ["applied", "closed"],
applied: ["follow_up", "rejected", "closed"],
follow_up: ["interview", "rejected", "closed"],
interview: ["offer", "rejected", "closed"],
offer: ["closed"],
rejected: ["closed"],
closed: [],
};
For every status, list exactly where a piece may move next:
A freshly discovered job can be saved, or closed as low-fit.
Saved leads to prepβ¦
β¦prep to readyβ¦
β¦ready to applied β the payoff move.
Once applied, life branches: follow up, get rejected, or close it out.
Following up can earn an interviewβ¦
β¦an interview can earn an offerβ¦
β¦and an offer's only exit is closing the game.
Rejected also just closes.
Closed goes nowhere. The game is over for that piece.
Everything the pipeline may ever do, in eleven lines you can read aloud.
Derive needs β never store them
"Resume needed" is true exactly when zero non-failed resume versions exist β computed in the same query that lists applications.
deriveApplicationNeedsFlags (pure, in core) fed by one aggregate query with count(distinct β¦) filter (where status != 'failed') per document kind.
A stored flag is a second copy of the truth, and second copies drift. A derived flag cannot disagree with the documents table β there's nothing to forget to update.
The spec's query contract: needs-flags come from joins in the same query as the list β never per-row lookups β so the dashboard costs a constant number of queries at any volume. An integration test asserts it.
Make the payoff beat double-click-proof
markApplied checks if you're already in an applied-family status; if so it changes nothing β and either way returns the live weekly count for the toast.
One store method that transitions to applied, stamps appliedAt, logs an event, auto-completes open "submit" tasks, and upserts a follow-up task due in 7 days.
This is the emotional core (SPEC Β§6.10: "the number moved because I moved it") β and the most-clicked button, so it's the one most likely to be clicked twice. The second click must not count twice.
The follow-up task's idempotency key is system:{applicationId}:follow_up with a unique index β apply, un-apply, re-apply and you still get exactly one follow-up task. The weekly count is computed fresh (applied_at >= startOfWeek() β in UTC, with a comment explaining why local time miscounts near midnight).
Deliver reminders like a careful courier
The hourly job atomically stamps due reminders "sending" before emailing β so even overlapping runs can't deliver one reminder twice.
claimDueEmailReminders: select due scheduled reminders, then UPDATE β¦ SET status='sending' WHERE status='scheduled' and keep only the rows the update actually touched.
Two job runs could overlap (a slow run plus the next hour's). Without claiming, both would email the same reminder. The status column doubles as a compare-and-swap.
Delivered β sent (legal only from sending); failure β failed with the reason merged into metadata. The deliberate bias: a crash mid-send loses an email rather than doubling one β at-most-once, chosen on purpose.
The central idea: derived state beats stored state
A car's fuel gauge reads the tank. Imagine instead a sticky note on the dashboard where you write "about half full" after each drive β that's a stored flag. The note is easier to build, and it's wrong within a week, because keeping it honest depends on a human remembering. Wera's "needs resume" chip is a gauge, not a note: it reads the actual documents table every time, so it is correct by construction β the only way to turn it off is to actually create the resume.
Needs-flags are a projection computed at read time from source-of-truth tables, following the spec's "one source of truth per fact." The cost model: derivation adds joins + aggregation to every list read (fine, because it's one indexed query with GROUP BY), while stored flags add a write-side obligation to every code path that could change the fact (resume created, resume failed, resume deleted, version restoredβ¦) β an O(paths) maintenance surface where missing one is silent corruption.
The general rule: store events and artifacts; derive statuses. Wera stores resume versions (artifacts) and application events (facts), derives needs and substates. When derivation gets expensive at scale, you add a cache or materialized view β which is a performance decision you can verify, not a correctness bet. The named tradeoff: read cost and query complexity, paid where bugs are visible, instead of write-side drift, paid where bugs are invisible.
CSV import distinguishes appliedAt omitted from appliedAt: null. Omitted means "status is applied, stamp it now." Explicit null means "a historical applied row with no date β do NOT pretend it happened today," or your imported spreadsheet would inflate this week's scoreboard. The code checks "appliedAt" in input β JavaScript's rare legitimate use of the in operator as API semantics.
Apply it
A user on hotel wifi double-clicks "Mark applied." What does the scoreboard show?
A user generates a tailored resume, but the "resume needed" chip is still showing. Assuming the version saved successfully, what code must run to clear the chip?
The reminder job hangs for 70 minutes, so the next hourly run starts while it's still working. Why doesn't a user get two emails?
State machines, idempotency & delivery semantics
Three load-bearing concepts. Explicit state machines: legal transitions as data, asserted on every write. Idempotency: operations safe to repeat, built from deterministic keys and unique indexes. Delivery semantics: every notifier chooses between at-most-once (may miss, never doubles) and at-least-once (never misses, may double) β "exactly once" is a slogan, not a setting. Wera chose at-most-once for reminders, consciously.
Best practices
- Write the transition table as data and assert it at the storage boundary β not in scattered
ifs. - Derive statuses from artifacts and events; store only the facts.
- Claim before you act on shared work items; let the atomic update pick the winner.
- Do time math in one timezone (UTC) and write the comment explaining why.
Common pitfalls
- Boolean flag columns mirroring facts stored elsewhere β drift is a when, not an if.
- Treating "user clicked twice" as an edge case instead of the common case.
- Sending the email before recording that you sent it (double-send) β or crashing between claim and send with no visibility (Wera's stuck-in-sending rows are findable; are yours?).
new Date().getDay()on a server β whose midnight did you just use?
Production-grade thinking about the guarantees behind "the count is right" β free online.
β Article Designing robust and predictable APIs with idempotencystripe.comThird appearance in this course β because it's the one essay that makes the pattern stick.
β Docs Date β MDNdeveloper.mozilla.orgThe UTC-vs-local method pairs (getDay vs getUTCDay) behind the week-boundary bug class.
β