Taming a State Machine: Bulk Admin Ops
In an earlier post I walked through the first cut of audit-safe bulk status changes on the workflow platform: preview and execute modes, atomic per-task transactions, append-only events, and an admin panel that made intent legible before anyone committed. That design was sound in the abstract. Then real users, real QA, and a thirteen-state source machine walked into the room. What followed was not a rewrite—it was iteration. Thirteen commits across four days, spread over more than ten pull requests, each closing a gap the spreadsheet-friendly architecture had politely hidden until someone clicked the wrong combination.
This post is the continuation: what happens after “works in dev” when the feature meets the state machine as it actually exists—transition matrices, worker runs, downstream pipelines, and the admin grid’s need for a single human-readable label.
The problem deepens
The initial bulk endpoint accepted a target status and validated that each task’s row could be updated. That was necessary but not sufficient. The domain exposes more than thirteen distinct source unified statuses, each with its own small set of legal targets. Allowing an admin to bulk-move early work—Draft, Checking Quality, Ready to Submit—into terminal outcomes is not just wrong product-wise; it breaks trust with the delivery team, who suddenly see tasks jump past gates they were still coordinating.
Worse, some intermediate states are asymmetric. For example, a failure after automated review should only be bulk-routable toward outcomes like Rejected or Duplicate, not toward “passed” style targets that downstream billing or scoring would misread. The mental model is a sparse matrix: roughly thirteen sources by six high-level targets, mostly empty cells. The product work was to encode that matrix as data the server enforces, not tribal knowledge in a wiki.
Transition rules and the quality-check edge case
We centralized allowed transitions in a registry keyed by unified source status. Empty arrays mean “no bulk transitions from here.” Narrow arrays encode domain-specific escape hatches. The preview path consults the same registry as execute so operators never see a plan the server would refuse.
type UnifiedStatus =
| "DRAFT"
| "CHECKING_QUALITY"
| "READY_TO_SUBMIT"
| "QUALITY_CHECK_FAILED"
| "PASSED"
| "REJECTED"
| "DUPLICATE";
/** Sparse bulk-transition matrix: only listed targets are legal. */
const BULK_ALLOWED_TARGETS: Record<UnifiedStatus, UnifiedStatus[]> = {
DRAFT: [],
CHECKING_QUALITY: [],
READY_TO_SUBMIT: [],
QUALITY_CHECK_FAILED: ["REJECTED", "DUPLICATE"],
PASSED: ["REJECTED", "DUPLICATE"],
// …remaining sources mapped to their allowed targets
};
function canBulkTransition(from: UnifiedStatus, to: UnifiedStatus): boolean {
return BULK_ALLOWED_TARGETS[from]?.includes(to) ?? false;
}
One subtle bug surfaced in QA: the query that selected “quality check failed” tasks matched any failed review on a task, even when a later review passed. Operators would see rows labeled as failed in the grid, run a bulk fix, and the API would reject them—or worse, the inverse mismatch would confuse support. The fix was a guard in the where-clause path: include failed reviews and assert that no passing review exists for the same task at a later timestamp (or higher sequence, depending on your schema). That single predicate turned a noisy boolean into a stateful predicate aligned with how humans read the timeline.
Workflow dispatch decoupling
Originally, changing status implicitly scheduled the same side effects the product triggers on single-task flows: output generation, post-QA scoring hooks, payment-related jobs. For an admin acting on dozens of rows, that coupling was dangerous. A mistaken target status could enqueue expensive pipelines across the cluster. The delivery team needed the ability to correct labels without becoming accidental batch orchestrators.
The solution was default-off workflow dispatch for bulk operations. The preview response lists which pipelines would run for the proposed transition. The admin panel renders that list beside an explicit opt-in checkbox. The primary button label reflects the choice: either change status only for the selected count, or change status and run pipelines. Server-side, the execute handler reads the same flag; absent opt-in, only the transactional status update and event append run.
type BulkExecuteBody = {
taskIds: string[];
targetStatus: UnifiedStatus;
reason: string;
/** When false, status/events update only—no workflow enqueue. */
runAttachedWorkflows: boolean;
};
function bulkPrimaryLabel(
selectedCount: number,
runWorkflows: boolean,
eligiblePipelineCount: number
): string {
const n = selectedCount;
if (!runWorkflows || eligiblePipelineCount === 0) {
return `Change Status Only (${n})`;
}
return `Change Status & Run Pipeline (${n})`;
}
Copy changes matter here: the button text is the last guardrail when someone is tired and working through a long checklist.
Unified status resolution and invisible rows
The admin grid does not show raw enum tuples; it shows a unified label derived from stage, persisted status, and recent worker activity. A task can sit in a pre-submit stage with an “active” worker flag yet present as either “Checking Quality” or “Quality Check Failed” depending on review rows. The resolver therefore needs worker run types and outcomes in the same Prisma query that feeds the table—omitting them produced labels that disagreed with the detail drawer and with bulk validation.
A second bug was operational: two worker types (output generation and expert quality review) were filtered out of the list query for performance reasons in an older code path. Tasks dominated by those workers disappeared from the admin table entirely while still existing in search-by-id flows. The fix was to extract a shared constant—call it STATUS_RELEVANT_WORKER_TYPES—used by both the list loader and the resolver so “performance tuning” could not drift from “correctness.”
const STATUS_RELEVANT_WORKER_TYPES = [
"QUALITY_AUTOMATION",
"EXPERT_REVIEW",
"OUTPUT_GENERATION",
] as const;
function resolveUnifiedLabel(task: TaskWithRuns): UnifiedStatus {
const runs = task.workerRuns.filter((r) =>
STATUS_RELEVANT_WORKER_TYPES.includes(r.type as (typeof STATUS_RELEVANT_WORKER_TYPES)[number])
);
if (task.stage === "PRE_QA" && hasFailedReviewWithoutLaterPass(task.reviews)) {
return "QUALITY_CHECK_FAILED";
}
if (runs.some((r) => r.state === "RUNNING" && r.type === "QUALITY_AUTOMATION")) {
return "CHECKING_QUALITY";
}
// …map remaining combinations to a single admin-facing label
return mapPersistedStatusToUnified(task.status);
}
JSON upload and selection that survives the table
Operators often receive identifiers from external spreadsheets or partner exports. We added JSON file upload to the admin panel: parse an array of task IDs, fetch matching rows from the API, merge them into the current selection set, and surface any IDs that did not resolve. Selection is keyed by stable task ID in client state so filters, sorts, and pagination cannot silently drop bulk intent—when the user changes the view, checked IDs remain checked because the key is identity, not row index.
That behavior matches what I described in the first post, but the iteration pass hardened it: the upload path shares the same selection reducer as click-to-toggle, so there is one code path for “what is in scope for the next preview call.”
Analytics and sort safety
Review feedback caught two sharp edges. First, analytics events for bulk transitions were firing even when the target status was non-terminal, which inflated funnel metrics and made week-over-week dashboards noisy. We introduced an explicit terminal check and only emit the “bulk transition completed” product event when the target is terminal—passed, rejected, duplicate, or whatever your enum defines as end-of-line.
const TERMINAL_UNIFIED: ReadonlySet<UnifiedStatus> = new Set([
"PASSED",
"REJECTED",
"DUPLICATE",
]);
export function isTerminalStatus(status: UnifiedStatus): boolean {
return TERMINAL_UNIFIED.has(status);
}
async function recordBulkAnalyticsIfNeeded(
target: UnifiedStatus,
payload: BulkAnalyticsPayload
) {
if (!isTerminalStatus(target)) return;
await analytics.track("admin_bulk_status_terminal", payload);
}
Second, sorting the visible table by unified label required computing that label for many rows. An early implementation loaded the full candidate set into memory to sort client-side. That worked until it did not. We capped unified-status sorting at five thousand rows for a single request and set a response header—X-Unified-Sort-Truncated: true—when truncation occurs so the UI can show a banner. The long-term fix may be database-side materialization; the short-term fix was honesty about limits.
Together, those two fixes illustrate a recurring theme: derived state is easy to display and dangerously easy to misuse for side effects. Terminal-only analytics keep dashboards aligned with business outcomes. Bounded sorting keeps latency predictable and gives operators a clear signal when they need to narrow filters instead of widening scope.
The iteration pattern
Thirteen commits in four days is not heroics; it is evidence of tight feedback loops. Each QA pass surfaced an edge case the whiteboard state machine had not enumerated: a review ordering bug, a missing include in Prisma, a default-on workflow assumption left over from single-task UX. The pattern that worked was deliberately boring—ship the narrow core, let QA stress the combinatorics, fix fast with small diffs, keep preview and execute symmetric the whole time. Most pull requests were reviewable in minutes because they targeted one invariant at a time.
If you are building similar tooling, treat the transition matrix and the resolver as first-class modules tested on their own. Keep workflow dispatch behind explicit consent in bulk flows. And when analytics or sorting touches derived state, ask whether you are measuring reality or measuring whatever was cheap to query on the first try.
For the foundational architecture—preview, execute, transactions, events—see Audit-Safe Admin Tools with Event Sourcing. This installment is the story of what came after: the state machine pushing back, and the small, precise commits that pushed back harder.