06

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.

TL;DR

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

1
Stage 7 Β· Workflow

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:

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: [],
};
PLAIN ENGLISH

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.

2
Stage 7 Β· Workflow

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.

What

deriveApplicationNeedsFlags (pure, in core) fed by one aggregate query with count(distinct …) filter (where status != 'failed') per document kind.

Why

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.

How

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.

3
Stage 7 Β· Workflow

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.

What

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.

Why

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.

How

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).

4
Stage 7 Β· Workflow

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.

What

claimDueEmailReminders: select due scheduled reminders, then UPDATE … SET status='sending' WHERE status='scheduled' and keep only the rows the update actually touched.

Why

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.

How

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.

πŸ’‘
The subtlest bug in the codebase

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?

πŸŽ“ Level Up

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?