brain/knowledge/decisions/000015-fan-in-is-an-event-driven-waitpoint-barrier.md
"Pause this run until N things report back" is one generic mechanism: a waitpoint of type BARRIER
carrying sealed and a nullable policy jsonb, plus one waitpoint_signal row per awaited thing, created
up front. A signal carries id (primary key and the resume-link token — a random apId), refId (the
child run or approval link it stands for, set by a compare-and-set claim), nullable sequence (the
producer's ordinal), nullable label (the human name in the summary) and a small result jsonb.
Release is an unconditional floor rule: a sealed barrier releases once no signal is still PENDING. No
configuration can produce a hang; policy only ever releases sooner — requiredSuccesses (K-of-N
approvals) and releaseOnFirstFailure (veto). Evaluation is a coalesced BarrierJobName.EVALUATE job on a
dedicated BARRIER_JOBS queue, deduplicated on the barrier id, owned by the waitpoints module. Counts are
taken once, from a single GROUP BY status, and handed to a pure predicate. Signals are deleted on release,
in the same transaction that completes the waitpoint — summary first, then complete, then delete.
Four asks need the same primitive: array batching into subflows, streaming CSV batching, request-approval waiting on multiple approvals (K-of-N, veto, accept/reject reasons), and later parallel branches.
The obvious cheaper design is to count child runs by a parentWaitpointId on flow_run. It serves only
the first ask: anything without a run row — a human approval, a batch that never dispatched — has no way to
be awaited. Counting children also forces three separate concepts for "a thing that will never report"
(expected-minus-actual, failed-to-dispatch, not-started), and forces an expectedChildren column to exist at
all, purely because child rows appear later than the dispatch that created them.
NOT_DISPATCHED outcome. Nothing has to be inferred by subtraction, and the summary is exact
rather than reconstructed.signalKey would
have to be identity, idempotency key, ordinal and human label at once, and is wrong for at least one of
those in every use case. sequence is nullable on purpose: a producer that writes the barrier and all
N signals in one transaction already gets idempotency from the barrier's own (flowRunId, stepName) key.
Only a streaming dispatcher — re-entered by redelivery, re-parsing from row 0 — has to answer "did I
already insert batch 4 200?", and sequence is that answer. Postgres treats NULLs as distinct, so the
partial unique index does not bite the rows that do not need it.UPDATE … WHERE status = PENDING plus a deduplicated resume job id.policy input, so both are known at create.clearEvaluationDeduplication, and every producer commits
its signal row before enqueuing. Otherwise the final signal lands while the job meant to see it is
already running, its enqueue is dropped as a duplicate, and the barrier waits out its deadline holding a
run that was ready to resume.core-execution, not in the service.
shouldReleaseBarrier({ policy, sealed, counts }) takes counts rather than issuing its own queries, so one
GROUP BY status replaces up to three round-trips and the policy matrix is unit-testable without a
database./v1/flow-runs/:id/signals/:signalId/confirm — in the path, not the query string, because resumePayload
is built from { body, headers, queryParams } and persisted with the run. The link must never carry
label: the route's entire guard is that every segment is unguessable, so a semantic segment lets one
legitimate approver substitute the string and cast the whole quorum.result is the first place unauthenticated free text reaches a jsonb column. Bounded to 2 000
characters server-side (rejected at the page, never truncated), passed through
sanitizeObjectForPostgresql() before the insert, stored as text and rendered escaped.reasonRequiredOn exist now because retrofitting them means another migration.policy carries no deadline override. The plan allowed "or the policy's shorter value"; nothing
produces one, so resolveDeadline() is now + AP_PAUSED_FLOW_TIMEOUT_DAYS and takes no arguments. Add the
field when a caller for it exists, not before.app.all serving an HTML form: a Zod body schema would also apply to the GET that renders the page, and
a schema rejection returns JSON where the handler returns a themed 400. The bound is enforced server-side
either way — this is about which response the browser gets.