Skip to main content

Flow engine — ready queue, review queue, blocked reveal & decisions

The flow engine (PT-602) turns the data the Project Tracker already has — tasks, BLOCKS dependency links, status categories, the agent-claim convention, and the pr_link custom field — into the live views an AI-agent fleet and its human reviewers actually need:

  • Ready set"what can I safely start right now?" The dependency-gated set of tasks an agent can pull.
  • Review queue"what is piling up in review?" The fleet's real bottleneck, surfaced so humans can clear it.
  • Blocked reveal (PT-685) — "what's queued up behind the ready set, and what unblocks it?" The complement of the ready set: would-be-ready tasks held back by an unfinished predecessor. See Blocked reveal.
  • Decisions (PT-905) — "which in-progress agents are blocked on a human decision?" Tasks whose claimed agent recorded a question it cannot answer. See Decisions queue.

The first three are live-computed smart views over existing data, with no new entity and no migration — and because readiness is computed on read, auto-promote is emergent: merging a blocker (which the GitHub webhook already moves to a done status) makes its now-unblocked successors appear in the ready set on the very next query, with zero extra writes. The Decisions lane is the one exception: it is backed by a dedicated projects.task_decision_requests entity (ADR-028), because an agent's blocking question and the human answer are durable state the task must own, not a view over existing columns.

Readiness

A task is ready when all of the following hold:

  1. its status is in the project's not-started (todo) category, and
  2. it is unassigned (no assignee — including no synthetic agent+<class>@constellation.local claim), and
  3. every BLOCKS-predecessor task is in a done category.

"Done" follows the project's status configuration (explicit isCompleted flags → last status by sort order → the legacy COMPLETED fallback), so it is correct for custom workflows, not hardcoded to COMPLETED.

Review queue

A task is awaiting review when either:

  • its status is approval-ready (the project's "in review" column, e.g. QA), or
  • it carries an open pr_link custom-field value and is not yet done.

The queue is ordered most-urgent-first (by SLA urgency, reusing the issue-queue's computeSlaUrgencyScore with the task's priority mapped onto a severity), with each row's review-wait age so reviewers see what has been waiting longest.

Ordering

QueueOrder
Ready setpriority descending (URGENT first), then SLA-urgency, then oldest first
Review queuemost-urgent first (SLA-urgency), then longest review-wait first
Decisions queueoldest-first (longest-waiting decision at the top)

API

The flow views are exposed as filters on the task-list endpoints. ready, review, blocked (below), and awaiting_decision (see Decisions queue) are mutually exclusive.

SurfaceReady setReview queue
REST (per-project)GET /api/projects/{id}/tasks?ready=trueGET /api/projects/{id}/tasks?review=true
REST (cross-project roll-up)GET /api/tasks?ready=true (optionally &projectId=<id>)GET /api/tasks?review=true (optionally &projectId=<id>)
MCP list_tasksready: truereview: true
pt CLIpt list-tasks <project> --readypt list-tasks <project> --review

Each row carries the task fields plus its epic ({ taskKey, title }, or null) and a slaUrgency object (active target, breach flag, overdue minutes, deadline); review-queue rows additionally carry reviewWaitMinutes and pullRequestUrl (the task's open pr_link value for the PR chip, or null when the review row is approval-ready without a PR). The cross-project ready roll-up (GET /api/tasks?ready=true) additionally returns a wip array — one entry per readable project with tasks, each with the agent-in-progress count vs PT_AGENT_WIP_LIMIT and an atCap flag — feeding the flow board's per-project WIP indicator (PT-667).

Blocked reveal (PT-685)

A third, opt-in view — the complement of the ready set — surfaces the near-miss: tasks that are unassigned and not-started and would be ready, except that at least one BLOCKS-predecessor is not yet in a done category. It is exposed as a ?blocked=true filter on the same task-list endpoints (GET /api/projects/{id}/tasks?blocked=true and the cross-project GET /api/tasks?blocked=true, optionally &projectId=<id>), mutually exclusive with ready/review and gated on the same full tasks.read. It is REST-only for now — not wired into MCP list_tasks or the pt CLI. Rows rank exactly like the ready set (priority-desc then SLA-urgency) and each additionally carries a blockedBy array of its still-unfinished predecessors ({ id, taskKey, title }); this array is absent on ready/review rows.

All flow views are tenant-scoped and respect project read access — none leaks tasks from another tenant or from a project the caller cannot read.

Decisions queue (PT-905)

The fourth lane surfaces tasks where a claimed, in-progress agent is blocked on a human decision — invisible to the other three lanes (it is neither unassigned, nor carrying a PR, nor dependency-blocked). Ownership and invariants are fixed by ADR-028 (INF-434): Project Tracker owns the blocking state as a task-scoped decision-request entity (projects.task_decision_requests), at most one open per task.

  • Raising (agent, via MCP/CLI). An in-progress agent marks its own task — mark_awaiting_decision (MCP) / pt mark-awaiting-decision (CLI) — recording the question and optional agent-proposed options. Only the task's current assignee may raise, and only while the task is in an in-progress status — so an agent following the claim convention passes agentId (MCP) / --agent (CLI) naming the synthetic user it claimed with, because its API token belongs to a human and the token owner is not the assignee. Delegating needs tasks.update.any and accepts synthetic agents only. Cancelling takes no such parameter: it is authorised for the raiser (an agent using its own token) or for tasks.update.any. Marking is idempotent: a repeat returns the existing open request. The raise also posts the question as a task comment, so any @[name](id) mention and the task's watchers are notified through the existing comment rail.
  • Answering (human, via the task banner). An "Awaiting your decision" banner on the task detail page shows the question and the proposed options as buttons plus a free-text box. Answering (a picked option or free text) resolves the request; the answer is read back deterministically by the resuming agent via get_task — passing the id the raise returned, as decisionRequestId (MCP) / --decision-request-id (CLI), since an un-addressed poll returns the task's LATEST request and would never surface the answer once a newer question opened on the same task. Answering requires tasks.update.any; cancelling is allowed for tasks.update.any or the agent that raised it.
  • The request is a sidecar. It never changes the task's status or assignee and never emits projects.task.unblocked; the agent simply resumes on its next get_task poll.
  • A done task never lingers in the queue, by two independent mechanisms. Every completion path a task is WRITTEN through auto-cancels the open request and writes a terminal audit entry. (One exception, tracked in PT-940: a container task — an epic, or any task with children — has its completion COMPUTED from its children by the rollup rather than authored by a completion path. The rollup does write the values — its progress and, where a status can be derived at all, its status and completion timestamp — but it is not one of the completion paths above, so it runs no auto-cancel. A container can therefore cross the completion line with no hook running for it. Read-surface suppression still hides its request throughout, and a new one cannot be raised on it.) Most do it in the same transaction as the completion itself — task delete, the bulk tools, the GitHub webhook, an Excel import that writes a completed status or progress, and reclassification when a project's status definitions change (which also fires in the reopen direction, since un-flagging a completed status makes those tasks workable again). Three — updateTaskTool, approveTaskTool and the Kanban reorder — write their status inside the callee service's transaction, so their close runs just after it commits, best-effort: a failure there is logged and swallowed rather than failing the user's mutation, and can leave a request open on a completed task (the residual tracked in PT-934). Those same three additionally close any stale request before a write that could reopen the task, so the moment the task becomes workable again there is no stale row left for a resuming agent's next question to be silently merged into. That is what the second mechanism is for: the read surfaces suppress a request on a completed task — the queue, the banner, and get_task — so a request left open by a swallowed close or a crash is still never shown or answerable, and a later reopen closes it rather than resurfacing it. Raising is also refused outright on an already-complete task, so a stale request cannot be joined by a new one. The one exception to suppression: a request fetched by decisionRequestId that is already answered/cancelled is returned even on a completed task, so a resuming agent can read the answer it was waiting on — frequently the very thing that completed the task.
  • Wait metric. Each row carries decisionWaitMinutes = wall-clock now − asked_at (floored at 0), the direct analogue of review-wait age, ordered oldest-first.
  • Audit. Each terminal transition (answer / cancel / auto-cancel) writes a durable audit entry recording metadata only — actor, time, outcome, and the decision-request id — never the free-form answer body.
SurfaceDecisions queue
REST (per-project)GET /api/projects/{id}/tasks?awaiting_decision=true
REST (cross-project roll-up)GET /api/tasks?awaiting_decision=true (optionally &projectId=<id>)
MCP list_tasksawaiting_decision: true
pt CLIpt list-tasks <project> --awaiting-decision
Raise / answer / cancelPOST / PATCH / DELETE /api/projects/{id}/tasks/{taskId}/decision-request

The awaiting_decision filter is mutually exclusive with ready / review / blocked and gated on full tasks.read. Not yet shipped (fast-follow): a home-dashboard widget (the PT-603 analogue), a dedicated notification kind, and a historical wait-duration trend series — the live per-task metric is what ships here.

UI

The Flow view (/flow in the Project Tracker) is a cross-project cockpit for driving the agent fleet (PT-667). It opens with a fleet-pulse strip of four fleet-wide aggregates — Ready to start, Awaiting review, Agent tasks in flight (in-progress agent-assigned tasks, counted per task — one agent holding two tasks counts twice), and Projects at cap — read from the two roll-ups so the top-line numbers are glanceable before any table is scanned. The two row-count tiles show N+ if a roll-up hits its row cap. The strip stays fleet-wide even when a project is focused below.

Under it sit three cross-project roll-ups, none gated on a project pick — the Review queue, the Decisions lane (PT-905, described above), and Ready across your projects:

  • Review queue — the cross-project set of tasks piling up in review, most-urgent-first. Each row carries the shared rich cells: the project (colour dot + name), an epic chip, the assignee (resolved to a person through the row's people directory; agent users show a bot marker in the cross-project roll-up, where the people source flags them — the marker is absent in the focused single-project view, whose mentionable-users source does not; an assignee that directory does not include, e.g. a cross-org collaborator outside it, shows "Assignee unavailable" rather than being mislabelled as unassigned), the status badge and priority (icon + label), the task's PR chip (a link to its open pr_link), and the review-wait age. When nothing is awaiting review it shows an explicit "Nothing is waiting for review." empty state.
  • Ready across your projects — the dependency-ready backlog (unassigned, unblocked work) across every project you can read, in a By project / Flat list toggle. Each project card shows a per-project agent WIP indicator (slot pips + an N/M count vs PT_AGENT_WIP_LIMIT, and a Full badge at cap). Each eligible row has a one-click Claim — it self-assigns the task via the standard task-assign path, and the claimed task drops out of the ready set on the next refresh. In an at-cap project the Claim is instead disabled for every viewer and reads At cap: a conservative signal that the project is full for the agent fleet, whose claims into an in-progress status would bounce at the WIP gate (PT-651). Humans aren't WIP-capped, so the work itself isn't hard-blocked — it can still be picked up from another surface (e.g. assigning it on the task); only this board's Claim button is gated.

Beneath the ready set, a collapsed-by-default Blocked · N reveal (PT-685) surfaces the blocked view above: expanding it lists the would-be-ready tasks held back by an unfinished predecessor as dimmed, non-actionable rows, each with a "blocked by <key>" chip linking to the predecessor that must be done before the task is ready. It appears on both the cross-project ready roll-up and a focused project, and hides itself entirely when nothing is blocked.

A Decisions section (PT-905, above) lists the cross-project set of tasks whose in-progress agent is awaiting a human decision, oldest-first — each row showing the question, the asking agent, and the wait age, linking through to the task where the answer banner lives. Like the review queue, the section always renders its heading and shows an explicit "No agent is waiting on a decision right now." empty state when nothing is waiting.

Focus on a project (from a card header, or a deep link that sets ?project=<id>) replaces only the ready roll-up with that project's cumulative-flow diagram (PT-804), its collapsed Trends section (PT-805 — review-queue depth/age and rework rate), the cycle-time-by-agent-class chart (PT-806, detailed below), its ready set, and its own scoped review queue. The fleet-pulse strip and the cross-project Review queue and Decisions roll-ups stay in place above it — the pulse is always fleet-wide, and those roll-ups keep the fleet's bottleneck and its pending decisions visible while you work a single project (so a focused view shows both the fleet review queue and the focused project's scoped one). Clearing focus restores the ready roll-up.

The same cross-project review queue is also surfaced as a review-queue home-dashboard widget (PT-603) — a reorderable section (default-on, near the top) so the team's real bottleneck is glanceable without navigating to /flow. It renders the global roll-up most-urgent-first with each row's review-wait age and a click-through to the task, and an explicit empty state when nothing is awaiting review. It shares the review-queue component, so as of PT-684 its rows carry the same enriched cells as the /flow review queue (project, epic, assignee, PR chip) — the review view is consistent across both surfaces. It reuses the same getReviewQueue service and GET /api/tasks?review=true endpoint, so the same tenant + readable-project scoping applies; the widget is shown only to viewers with full tasks.read plus any projects.read.

Consuming the ready set (PT-604)

PT-602 builds the ready set; PT-604 wires it into the two places work is actually chosen — softly, with no migration.

Coordinator candidate context

The coordinator brain now grounds "what should we work on next?" in the ready set, not just in-progress work. On each consult it loads the initiative's ready to start tasks — unassigned, not-started, dependency-unblocked, redacted to the caller's readable scope, round-robin'd across the initiative's projects, ordered priority → age — and renders them as a distinct "Ready to start (unblocked, unassigned)" prompt section.

The brain shares the same readiness predicate as the flow service (extracted to @constellation/contracts), computed over its own transaction-local reads — it never calls the flow service across the app boundary. The brain proposes claiming a single ready task through the existing confirm-gated assign action.

Atomic claim-next-ready — the fleet's race-free pull path

assign (the coordinator action above) routes through the ordinary update_task path and is not race-free: two agents that both read the same top ready task and PATCH it would both "succeed", the second silently overwriting the first. For the agent loop, PT-604 adds an atomic claim:

POST /api/projects/{id}/tasks/claim-next-ready body: { assigneeId }

In a single READ COMMITTED transaction it loads the project's ready rows, walks them top-down issuing a conditional compare-and-set —

UPDATE projects.tasks SET assignee_id = :a, status = :started
WHERE id = :id AND assignee_id IS NULL AND status = :expected RETURNING …

— and on the first row that still holds, writes a security-critical audit entry in the same transaction (the claim rolls back if the audit fails). Two callers therefore never both claim the same task: the loser's CAS matches zero rows and it advances to the next ready row. READ COMMITTED (not RepeatableRead) is deliberate — a lost claim must advance, not abort with a serialization error.

The started status is resolved by category — the project's first in_progress-category status by sort order, not the literal name IN_PROGRESS. A project with no in_progress column keeps the task's status unchanged (assignee set, status left as-is).

An empty ready set returns 200 { data: null } — an explicit "nothing to claim", not an error. The same null contract applies when the claim assignee is a synthetic agent and the project is already at its agent WIP cap (PT-651, below): "nothing claimable here", so a fleet loop naturally moves on to another project. Human claim assignees are never capped.

SurfaceClaim-next-ready
RESTPOST /api/projects/{id}/tasks/claim-next-ready (body { assigneeId })
MCPclaim_next_ready_task (projectId + assigneeEmail)
pt CLIpt claim-next-ready <project> --assignee <uuid|me|agent+…>

The MCP tool and CLI take the agent's email (agent+<class>@constellation.local, INF-113) and resolve it to a user id before posting; the REST route takes a UUID and validates project-scoped assignability (assertAssigneeAssignable — the same guard as task create/update: the assignee must be able to access the project; Guest Customers are rejected, with a same-tenant synthetic-agent carve-out so agent claims pass). The route requires tasks.update.any plus project read access (a non-readable project gets a 403).

assign vs. claim — the asymmetry to know: the coordinator's assign action is a human-confirmed convenience and is not collision-free; only claim-next-ready is atomic. Use claim-next-ready for the autonomous fleet pull path; assign for a human picking a specific task.

Scope (v1)

Agent claims are hard-gated by the ready set (PT-643). An update_task PATCH (REST or MCP — field patches funnel through updateTaskTool, and the REST route's parent-only branch invokes the same gate directly) that would leave a task assigned to a synthetic agent user (agent+<class>@constellation.local) in an in_progress-category status while at least one BLOCKS-predecessor is not in a done-category status is rejected with 409 TASK_NOT_READY; details.blockingTaskKeys names every unfinished predecessor. The gate evaluates the resulting state, so a status-only PATCH on an already-agent-assigned task is gated too (no two-PATCH loophole), while an assign-only PATCH that leaves the task in a todo-category status still succeeds — agents may be pre-assigned to backlog work. The check is a deliberately best-effort read (no transaction fence with the write), matching the atomic claim's cooperative-agent stance. claim-next-ready needed no change — its CAS only ever operates on rows already inside the tx-local ready set.

Agent claims are capped per conflict zone (PT-651). Zone = project (v1). The number of agent-assigned tasks in in_progress-category statuses a project may hold is bounded by PT_AGENT_WIP_LIMIT (a platform default — 3 when unset; 0 disables the cap; no per-project column yet). The cap composes into the same gate points: an agent update_task transition into an in_progress-category status in a project at cap is rejected with 409 WIP_LIMIT_REACHED (details carries { limit, current }, structured so a coordinator can route the agent elsewhere), and claim-next-ready returns null at cap instead of erroring. The gate fires only on a transition into WIP — updating an already-in-flight agent task (or an agent-to-agent handoff of one) is not a new admission, so a lowered limit never freezes in-flight work. Like the dependency gate, the count read is deliberately best-effort: two simultaneous admissions can briefly over-admit by one; the next admission attempt sees the true count. Unfinished predecessors take precedence — a task that is both not-ready and at-cap rejects with TASK_NOT_READY.

Humans stay soft-gated: the views rank and show the sets, but a human claim whose predecessors aren't done is never blocked, and humans are neither gated by the WIP cap nor counted against it. Predecessor-completeness and the agent WIP cap are the only hard gates so far. The cumulative-flow diagram (PT-804) is layered on top of these views and has shipped: focus a project on /flow and it renders above that project's ready set, reading the cumulativeFlow series below. The review-queue-depth/age and rework-rate charts (PT-805) have also shipped, in a collapsed Trends section under the same focused view, as has the cycle-time-by-agent-class chart (PT-806) — so the metrics phase is complete.

Newly-unblocked work is pushed, not just polled (PT-650). When a task completion leaves a successor with every BLOCKS-predecessor in a done category (the same fully-clear rule the ready set uses), PT publishes a projects.task.unblocked domain event through the platform outbox — one event per (successor, completed-blocker) pair, deduplicated so a blocker that is reopened and re-completed does not re-fire. Emission is completion-driven and covers every done-transition writer (task PATCH across REST/MCP/coordinator/bulk paths, approval, Kanban drag, and the GitHub merged-PR auto-close). The successor's watchers and reporter get an in-app notification (task_unblocked notification type, opt-out via notification preferences; synthetic agent users are never mailed), and the event is available on the outbox for coordinator/automation subscribers. The ready set stays computed-on-read — the event is a push optimisation, and polling ?ready=true remains the source of truth. Blocker deletion, link removal, and done-set reconfiguration do not emit (the poll path covers them).

Flow-event capture stream (PT-801, PT-802)

The cumulative-flow, review-queue-depth, and cycle-time-by-agent-class charts (PT-549) read from a status-transition stream rather than recomputing history from live task state. projects.task_flow_events records one STATUS_TRANSITION row per genuine task status change, written by a database AFTER UPDATE OF status trigger on projects.tasks — so every status writer (the task service, bulk-move, the GitHub webhook, reorder, …) is captured without threading capture through each one, and a no-op write or a status-definition rename never enters the stream. Each row denormalises, frozen at capture time, the source/destination status category (reusing project_statuses.category), the assignee, and the project, so a later reassignment or cross-project move does not retroactively rewrite past rows. The stream is tenant-isolated via RLS (with a cron-bypass read policy for a future cross-tenant aggregation cron), and a database guard rejects any cross-tenant row.

PR-lifecycle events (PT-802). The same table also records what happens to a task's pull request, which is not a status change and so cannot ride that trigger: when a PR referencing a task is opened the webhook records PR_OPENED, and when it merges, PR_MERGED — or PR_REVERTED when the PR's title marks it a revert (GitHub's own Revert "…" title, or the Conventional-Commits revert: forms). One event is recorded per referenced task, carrying that task's project and assignee frozen at the time, and timestamped with GitHub's PR clock rather than the moment the delivery arrived, so a late delivery still lands in the right time bucket. Each event is keyed to GitHub's immutable identifier for the pull request, so replaying a webhook delivery is recognised as the same event rather than counted twice, while two different pull requests against one task stay distinct — including two that share a number in different repositories. Using the immutable id rather than the PR's URL also means renaming or transferring a repository does not break the link between a PR's open and merge events. Recording is best-effort: if it fails, the PR's real side effects (auto-close, pr_link, outbound sync) are unaffected and only the metric is skipped.

Flow-metrics read API (PT-803)

The capture stream is read back through one project-scoped endpoint:

GET /api/projects/{id}/flow-metrics?from=<iso>&to=<iso>&interval=day|week

It returns four series in one payloadcumulativeFlow, reviewQueue, rework and cycleTimeByAgentClass — because the first three share one scan of the same window; serving a chart per endpoint would repeat that scan for a response measured in kilobytes. (Cycle time is the one series the window scan cannot answer on its own — a task completed inside the window may have started long before it — so it adds a second, targeted read for completions and their histories.) Access is gated exactly like the sibling flow-counts and overview reads: full projects.read or scoped projects.read.own, evaluated against the project's own organisation, plus project access.

MCP and pt CLI exposure is deliberately deferred until an agent-consumption need emerges. The charts are its consumers: the cumulative-flow diagram (PT-804), the cycle-time-by-agent-class chart (PT-806) and the Trends section (PT-805) all read it. They do not yet share one read — PT-806 introduced useProjectFlowMetrics as a single per-surface fetch, but the cumulative-flow card still issues its own and Trends issues its own when opened, so a focused project can pay for the aggregation more than once. Consolidating them onto that hook is open work.

What the chart cannot tell you

Two caveats the cumulative-flow diagram (PT-804) surfaces but cannot resolve, both properties of this payload rather than of the chart:

  • A truncated payload names no specific degraded read. The flag is the OR of five capped reads that discard on different axes, so the chart says only that the series may be incomplete or misreported in an unknown direction — the wording this page uses below — and never which end or which read was clipped. Narrowing it needs per-read truncation flags on the response.
  • An empty chart means nothing was charted for that window, not that the project had no work. The series covers only the tasks currently in the project, so one that was here all window and has since moved out is absent from it; a client clock running behind can also place to before recent work. The one cause that is excluded is the live-task cap, which retains the newest tasks created by to.

Window semantics

from and to must be ISO-8601 UTC timestamps (Z, not a numeric offset; seconds and fractional seconds are optional). The endpoint validates against the very schema its OpenAPI pattern is generated from, so a request the published pattern accepts is never refused, and one it rejects never silently answers a different window. Send seconds anyway if your client asserts the parameter's format: date-time: RFC 3339 makes seconds mandatory, so a strict format validator is narrower than this endpoint and would refuse a seconds-less instant the route would have taken.

The window is resolved before it is served, and the payload always reports the resolved window rather than the requested one:

  • Defaults. to defaults to now, from to 30 days before the effective to, interval to day.
  • Both from and to are sample points — the exclusivity is about MEMBERSHIP, not sampling. from is always the first point of the series and to always the last, so a chart's axis spans the closed interval. What from excludes is the data counted into it: the event scan is occurred_at > from, and the completion read applies the same boundary, so an event or completion landing exactly on from belongs to the span before the window rather than to its first bucket.
  • Sub-millisecond precision is accepted and then silently dropped. The grammar allows arbitrary fractional seconds, but the value becomes a JS Date, which holds milliseconds — …:00.123999Z is carried as …:00.123Z. That truncated instant is what the returned window reports and what the exclusive-from / inclusive-to predicates compare against, while the stored event timestamps keep microsecond precision. An event inside the sub-millisecond band of a boundary can therefore fall on the wrong side of it. Harmless at day or week sampling, and tracked as PT-913.
  • A future to is capped at the present. The reconstruction projects each task's live status forward, so sampling beyond now would return confident, entirely fabricated history rather than nothing. A window lying wholly in the future collapses to from >= to and is rejected with 400.
  • An over-long range is clamped, not rejected. A span longer than 179 intervals is narrowed to the most recent 179 — the left edge moves, so to (the sample a dashboard reads first) stays exact, and the resulting 180 points stay evenly spaced. The clamp stops one interval short of the 180-point cap precisely so from lands on the sample grid instead of opening with a short first bucket.
  • 400 is returned for an unparseable from/to, an unknown interval, a from at or after the effective to, or any of the three supplied more than once — a repeated parameter is rejected rather than silently resolved to one of its values, even when each value is individually valid.
  • truncated: true says an internal row cap clipped one of the underlying reads, so a chart can label itself partial instead of quietly misreporting. It does not say which read clipped, and the direction of the error depends on that — see the residuals below for which reads are capped, which are not, and why a truncated payload is not simply an undercount.

Every read of the project's own event and task data runs inside a single RepeatableRead snapshot, so the live task state the reconstruction anchors on and the event stream it walks back through always describe the same instant — a task transitioning mid-request cannot be seen with its new status but without the event that produced it. Resolving frozen assignee IDs to agent classes runs after that transaction closes and is not part of the snapshot: it reaches the identity store on a second connection, which under a single-connection pool would starve the request rather than serve it. What that costs is a read-time dependency rather than a snapshot inconsistency: the event freezes the assignee id, but the class is derived from that user's current email, so renaming an agent user — or an identity read that resolves nothing for it — reclassifies completions that already happened. The cohort a past completion is credited to is therefore a statement about who that assignee is today, not about how they were labelled at the time.

The four series

SeriesWhat it measures
cumulativeFlowTasks per status category at each sample instant — the CFD bands (PT-804).
reviewQueuedepth plus mean/max wait in hours at each instant. "In review" is an approval-ready status, not a category — the status half of the flow engine's review predicate, and only that half (PT-805).
reworkPer-bucket reworkEvents / totalTransitions plus a window summary — see the field definitions below (PT-805).
cycleTimeByAgentClassFirst-start → completion hours for measurable completions in the window, grouped by agent class with mean plus nearest-rank p50 and p90. taskCount is the number of samples retained per class, not the number of tasks that reached a done status — see below (PT-806).

Buckets are half-open — (previous instant, instant] — so an event landing exactly on a boundary belongs to exactly one bucket. Two consequences for a chart author:

  • Position points by at, not by date. Every point carries both. at is the exact ISO sample instant and the bucket's closing edge; date is only its UTC YYYY-MM-DD label, produced by truncating at. Because the grid is anchored on the effective to, samples inherit its time of day — so a window ending at noon produces points at noon, and plotting date as if it were the timestamp silently moves every one of them to midnight.
  • bucketCount counts returned sample POINTS, not elapsed buckets, and from is point zero. The default 30-day daily window therefore returns 31 points spanning 30 buckets. The opening point has no measured span before it — the event scan is occurred_at > from — so a rework value of 0 there is structural, not evidence of a quiet first day. (One exception, from the millisecond truncation above: an event landing in the sub-millisecond band just after from passes the microsecond-precision scan, is then carried as a Date truncated onto from itself, and is bucketed into that opening point. A small non-zero opening value is that artefact rather than a measured first bucket — PT-913.)

Rework counts exactly two transition shapes, and both require the destination to land back in live work — a resolved category of todo, in_progress or blocked:

  • a reopen — leaving a done category for live work. Moving a finished task to cancelled is a disposal, not rework, and does not count.
  • a review rejection — leaving an approval-ready status for a known destination that is itself not approval-ready and resolves to a live category. Invisible to the category test, because review lives inside in_progress. A destination with no recorded status name does not count: an absent name is never read as evidence that the task left review, since that would manufacture rework nobody observed. An unrecorded destination category is a weaker case and does not exclude the transition — a captured NULL is filled from the project's live status configuration, and failing that from the shared name inference, before the rework test runs.

PR_REVERTED events add to the numerator but never to totalTransitions, which counts status transitions only — putting them in the denominator would pad it with events that can never be rework.

That asymmetry has two consequences a chart must not misread, and neither is a defect:

  • rate is a ratio, not a percentage, and it can exceed 1. A bucket holding one forward transition and three reverts rates 3: the transition is in the denominator only, while all three reverts are in the numerator. It is deliberately not clamped — capping it would collapse a real difference, since one transition with one revert and one with five would both read 1. A renderer that wants a 0–100% axis is the right place to cap.
  • A bucket containing only reverts reports rate: 0, not a high rate. With no status transition in the span the denominator is zero, and the rate is defined as 0 rather than NaN. So reworkEvents can be non-zero while rate is 0 — plot reworkEvents alongside the rate rather than inferring activity from the rate alone.

rework.summary fields, over the whole window — one of which is easy to read as something it is not:

FieldCounts
reworkEventsQualifying rework transitions plus PR_REVERTED events.
totalTransitionsStatus transitions only — the rate's denominator.
reworkedTaskCountDistinct tasks with at least one rework event, reverts included.
activeTaskCountDistinct tasks that recorded a status transition in the window. Not a WIP or "currently active" count.
revertedPrCountPR_REVERTED events in the window.
ratereworkEvents / totalTransitions over the window, with the same two properties as the per-bucket rate above.

reworkedTaskCount can exceed activeTaskCount: a task whose only rework was a PR revert never recorded a status transition, so it enters the first and not the second. Do not read reworkedTaskCount / activeTaskCount as a proportion of active work.

Review-queue age is measured from the current review stretch, not the first one: a task rejected and re-submitted starts its wait again. The question a queue-age chart answers is "how long has this been waiting for a reviewer", not "how long since anyone first looked at it".

One exception, and it can overstate the wait badly. That holds when the entry into the current review stretch was captured. For a task already sitting in review when the window opens, the entry instant comes from the pre-window lookup — and where that finds nothing, the age falls back to the task's createdAt. Capture was never backfilled (events start at migration time forward), so for work that entered review before capture began, createdAt can precede the real review entry by months, and that time is charged to the queue. It inflates averageAgeHours and especially maxAgeHours, and it decays as pre-capture work drains out of review. Treat a single implausibly old entry as a capture-boundary artefact before treating it as a stuck review.

Agent class is resolved on read from the assignee frozen on the completing event, not the task's live assignee: reassigning a task after it shipped must not move credit for work already delivered. An agent+<class>@constellation.local email yields <class>; any other real user is human; an event that froze no assignee, or whose assignee ID the identity lookup can no longer resolve at all, is unassigned — kept distinct from human so unattributable work cannot silently understate agent throughput. An agent whose class would collide with one of those fallback labels is reported in the qualified form (agent+human, agent+unassigned) rather than being folded into the bucket it collides with. The lookup is deliberately not tenant-scoped: PT allows cross-organisation collaborators as assignees, and a scoped read could not see them, so every completion they delivered would be mislabelled unassigned. Only a class bucket is derived — no email, name or other identity attribute reaches the response.

Cycle time measures from a task's first crossing into an in_progress category, so a reopened task carries the full elapsed time it actually took — a cycle-time chart that hides rework defeats its own purpose. "Crossing into" is literal: a project with several in-progress statuses emits Doing → Review as an in-progress-to-in-progress move, and counting that as a start would date the task from the wrong moment. The same rule applies at the other end — Done → Archived stays inside the done category and is not a second completion, so archiving an old task does not re-count it. Completions are selected from the events this project recorded, but the task's history is read across the tenant: a task that started in project A and finished in B has its start frozen to A, so scoping the history to B would drop it from the series rather than measure it. Reopening a task after the window closed does not remove it from that window — it was complete for the whole period being charted.

Capture began at migration 071 and was not backfilled, so a task older than that has a truncated history and its earliest captured event is all the evidence there is about what came before. Three readings follow, because the evidence differs:

  • Opens already in_progress — either created directly into an in-progress status (the capture trigger fires on status updates, so creation records no crossing) or in flight when capture began. Its creation time stands in as the start, and takes precedence over any later crossing. For the created-in-progress case that is exact; for the in-flight case it yields lead-time-from-creation rather than time-in-progress — an over-estimate, but a defined one, and better than dropping delivered work out of throughput.
  • Opens already done — the earliest captured event shows the task was already complete, so it had completed at least once before capture began. (That event need not be a reopen; a move within done, say Done → Archived, opens the same way.) Dating the cycle from it would report only the stretch after that point under a metric that promises first-start-to-completion, and the real start is unrecoverable, so the task is omitted.

So taskCount is a count of measurable samples, not of completions. Two exclusions sit between "reached a done status inside the window" and "appears in this series": a completion that no longer stands at to — the task was completed and then reopened before the window closed — is not counted as a completion at all, and a task whose start cannot be established (the omitted case above) is dropped even though it did complete. Summing taskCount across classes therefore under-reports completions, and it is not the right number to answer "how much did we finish?".

Nothing in this payload is an authoritative throughput count. The cumulative-flow done band is not one either: it is inventory, the tasks reconstructed as sitting in a done category at each instant, so a task that is reopened — or that moves to another project — leaves the band again and the level falls. A stock is not a flow. If you need completions per period, count STATUS_TRANSITION events into a done category directly rather than differencing this series.

  • Opens anywhere else (backlog, blocked, cancelled) — the first observed crossing into in_progress is taken as the first start. This is the lossy case, and it is a residual worth knowing when reading a chart that spans the capture boundary: a legacy task that was worked, parked back into the backlog, and resumed after capture began reports only the stretch after the resume, and nothing on the captured row distinguishes it from a task that simply sat in the backlog until someone picked it up. Omitting on that suspicion would drop essentially the whole legacy cohort on no evidence at all, which is the larger distortion; the bias shrinks to nothing as post-capture history accumulates.

Where no start can be established at all, the sample is omitted — the metric never fabricates one.

Both a mean and p50/p90 are reported because they disagree in the way that matters: one task blocked for a fortnight drags the mean far above what a typical task costs, and the percentile pair is what tells you that happened. The percentiles are nearest-rank, so every reported figure is some real task's actual duration — which is what makes a p90 quotable, at the cost of an even-sized cohort's p50 not being the arithmetic median.

Check taskCount before quoting a percentile. The rank is ceil(fraction × taskCount) over the ascending durations, which has two consequences on the small cohorts an agent-class breakdown routinely produces:

  • p90Hours is simply the slowest task whenever taskCount is 9 or fewerceil(0.9 × 9) = 9, the last element. It only starts behaving like a 90th percentile at 10 samples or more. A chart that labels it "90th percentile" for a class with three completions is presenting a maximum.
  • p50Hours on a two-sample class is the faster of the two, not their midpoint — ceil(0.5 × 2) = 1.

Neither is a defect: nearest-rank is chosen so every figure is a real duration. But a class with a handful of samples should be rendered as the individual points it is, not as a distribution.

The cycle-time chart (PT-806)

The series is rendered on the flow board's focused-project drill-in (/flow?project=<id>) as a bar per agent class, and it consumes the payload as served — no client-side percentile arithmetic, re-ordering or agent-class resolution. Five of its reading rules follow directly from the caveats above, and are worth knowing when interpreting what is on screen:

  • One statistic at a time. Median, average and p90 are a toggle, not three bars per class: a median and a 90th percentile are not comparable on one axis. It opens on the median, the statistic least disturbed by a single extreme duration.
  • Each statistic is qualified where it degenerates, and only there. Under p90, a class with fewer than 10 samples is marked (*) and named in the accessible table as that class's slowest task — an extra qualification, not a change of statistic: nearest-rank p90 is still a percentile, it just lands on the cohort maximum below ten samples, which is why the caption and the column header still read P90. Under median, the caption always says nearest-rank — that caveat is about parity, not size, so it holds for an even cohort at any size and for no odd one. The average carries no qualification.
  • Small cohorts are marked, not re-rendered as points. The guidance above — that a handful of samples should be shown as the individual points they are — is not available to this chart: the payload carries per-class summaries, never the samples, so there are no points to draw. Marking and naming the figure is the honest maximum from this response. Rendering such a cohort as points would need the endpoint to expose the underlying durations, which it deliberately does not.
  • Only the eight largest classes are plotted, and the rest are reported as a count. No "Other" bar is synthesised: the payload carries summaries, so neither a median nor a p90 could be reconstructed for such a cohort.
  • Empty has two meanings, and truncated decides which. With no truncation, no completion still standing at the window's end had a measurable cycle time — reopened completions are excluded even when their duration was measurable. With truncation, the window is indeterminate — two of the five capped reads behind that bit (the completion-candidate page and the per-task history page) feed this series, so the caps may have dropped every otherwise measurable sample. Neither wording is a claim about the project's throughput. When rows are present, truncation shows as a caveat beside them and names no particular capped read, because the single bit cannot tell the five reads apart.

The chart requests no from/to, so the window is the server's own default anchored on the server clock, and the range it displays is read back off the response's window rather than recomputed locally.

Why the series are reconstructed per task

A cumulative-flow diagram is usually built from a transition log by running a diff — +1 to_category, −1 from_category. That is not what happens here, and deliberately. A combined cross-project and status move records one event stamped with the destination project but carrying a from_* endpoint belonging to the (unrecorded) source project; nothing on the row marks it as a move, so a consumer cannot filter those rows out. One unbalanced diff then desynchronises the whole series permanently.

So each task's timeline is instead reconstructed from its own transitions, walking backward from live state, and every sample is a count of tasks rather than an accumulated delta. A mis-attributed endpoint can then misplace at most the single task it belongs to. That one-task bound is what makes the stream usable for a per-project CFD without first adding a project-move discriminator to the capture table. It bounds the cross-project attribution errors specifically — it is not a blanket claim about every residual below, some of which (truncation, a configuration-wide recategorisation) reach more than one task.

Category resolution has one fallback worth knowing: a legacy project with no configured statuses records a NULL category on every captured event, because the capture trigger resolves the category by lookup and finds no row. The series fill those in from the live status configuration, and failing that from a name-based inference, so such a project still gets real numbers instead of empty ones. A category recorded at capture time is never overwritten — the fill-in only ever touches endpoints the capture left empty. For an event frozen to a different project, the fill-in deliberately ignores the charted project's status configuration and uses the name inference alone: a NULL category means the event's own project had no row for that name, so borrowing this project's meaning of a same-named status would invent a boundary that never happened.

Residuals — what these charts cannot tell you

Each of these has a concrete failure a chart reader could otherwise misread as data. None can desynchronise a series. This is the chart-facing set; narrower implementation caveats are recorded in the source docblocks and in the spec's Risks section.

The review-queue depth is narrower than the live board's count. The board (/flow, the review-queue widget, flow-counts) admits a task on inReview || (hasOpenPrLink && !isDone). This series covers only the status half: a task with an open PR sitting in an ordinary Doing status counts on the board and not here, so the board's number can legitimately be the higher of the two. This is an upstream decision, not an oversight — awaitingPr reads the live pr_link field, which has no history at all, and its only historical equivalent (PT-802's PR_OPENED / PR_MERGED rows) is deliberately excluded from the metrics scan: on a PR-heavy project those rows would evict the older status transitions the reconstruction needs and truncate the response for no gain. Closing the gap means re-opening that event-cap tradeoff in the loader and restating what depth counts — a contract change, so it is a human decision rather than a silent fix.

Review readiness is read from the live configuration. Both the review queue and the rework series test the status name frozen on the event against the project's current approval-ready set, so renaming a review column — or toggling its isApprovalReady flag — retroactively changes how past events classify: it moves depth and it changes which transitions count as review rejections. There is nothing better available: the capture table freezes endpoint names and endpoint categories, but approval-readiness is a per-status flag rather than a status category (the project_status_category enum has no review member), so no capture-time value exists to read back. Closing it needs a new frozen column on the capture table.

Cross-project attribution has two distinct cases, and one of them is silent. cumulativeFlow and reviewQueue count the tasks currently in the project — a snapshot taken at request time, not at to. (The other two series scope differently, and deliberately: rework counts every transition captured in this project during the window, including from tasks that have since left, and cycleTimeByAgentClass credits a completion to the project frozen on the completing event.) A task that moved in at any point up to the task snapshot is attributed for the whole window; one that moved out is absent for it. What the chart shows for the pre-move span depends on which kind of move it was:

  • A status-changing move writes one event, stamped with the destination project but carrying a from_* endpoint belonging to the unrecorded source project. The scan sees it, so the task's pre-move status shows through for the span before the move.
  • A status-preserving move (a bulk move into a project that has a status of the same name) writes nothing at all — the capture trigger's NEW.status IS NOT DISTINCT FROM OLD.status guard suppresses the row. The reconstruction then has only live state to anchor on, so the task's post-move status shows through for the entire window.

Neither is repaired by widening the scan, because for that span the task was not in this project at all: no band it is placed in answers "how many tasks did this project hold in that category at time T". The status-changing case has one further wrinkle: the event looks local (its recorded project is the charted one), but its from_* endpoint belongs to the source project, so a NULL source category on such a row is filled in from this project's status configuration rather than the source project's. A third shape follows from the same gap — a task that left, transitioned elsewhere and returned can be counted in a band the charted project never recorded, and on the review queue that can move depth in either direction (and overstate averageAgeHours / maxAgeHours, since a task reconciled into a review status is aged from the instant it entered the previous one).

Event order is the database's, and the database's clock is not monotonic. occurred_at is clock_timestamp(), and the capture table carries no monotonic sequence — id is gen_random_uuid() and created_at is transaction-frozen. Two failures follow, and they share one fix:

  • A wall clock that steps backward between two transitions orders them wrongly without tying them. Untangling that in general needs a topological sort that real workflows — which revisit statuses — make ambiguous.
  • Microsecond-distinct pairs read as ties. Postgres stores occurred_at to the microsecond, but Prisma hands it over as a millisecond-precision JS Date, so two rows a microsecond apart — which the database did return in true causal order — arrive indistinguishable from a genuine tie. The aggregation therefore reverses the descending page rather than re-sorting it, which preserves the database's own full-precision ordering; and it repairs tied runs by chaining fromStatus/toStatus, all-or-nothing per run. On a complete history that reproduces the true order. On a history scoped to one project the linking transition can be missing, and the remaining pair may chain in reverse — so the repair can itself invert an order that was right.

Two narrower cases sit in the same family. The lookup that finds a task's last transition before the window opened resolves an exact-microsecond tie by taking the greatest row id — which is a random UUID carrying no chronology, chosen only for being a stable, repeatable total order rather than letting row order decide it differently on a replay; the causal repair is not applied there. And a post-window row whose timestamp truncates onto to exactly is nudged to to + 1 ms so it stays recognisably after the right edge, which can make it tie with a genuinely distinct row a millisecond later, leaving the causal repair to decide them on the workflow chain rather than on an instant that was never theirs.

The honest fix for all of it is a monotonic, precision-preserving order key on the capture table, read through on the capture path — a Slice A change, not something any consumer can do. An unresolved reversal is not as short as the tie that caused it: tied segments share one start instant and the sampler answers with the last of them, so a reversed pair can be read by every sample from that instant until the next later-starting segment, which may be the rest of the window.

Sub-millisecond bucket boundaries are off by one bucket. From the same millisecond truncation: an event falling inside the first millisecond after a sample instant compares equal to it and is counted in the preceding bucket. Left as-is deliberately — sample instants are anchored on the request time and therefore arbitrary relative to event times, so this is roughly one event every few thousand requests, and the consequence is one event in one bucket of a 180-point trend. A related case in cycle time: a completion landing in the same millisecond as from is selected as a candidate by the database (which compares at microsecond precision) and then dropped by the in-memory re-check, so it spends a cap slot and yields no sample.

Editing a status's category moves historical bands — but only for endpoints capture left empty. Every segment keeps the category frozen on the event that opened it, including the segment a task was already sitting in when the window opened (a separate lookup fetches that task's last transition at or before from and carries its frozen category in). So the same instant does not read differently in a wide window and a narrow one, and today's definition is never projected backward over recorded history. What does still move is a captured-NULL endpoint: migration 071 records NULL when no project_statuses row matched that name at the time, and the series fill that gap from the live configuration — so adding or recategorising a status row today changes how those unrecorded endpoints read tomorrow. It is bounded to endpoints that were never recorded, and closing it needs versioned status definitions. Only the status name on the terminal segment is reconciled to live state, because a rename cascade suppresses capture and would otherwise drop a renamed review column out of the review queue.

A segment with no resolvable category is skipped, not bucketed — but the capture invariants make that unreachable. The code leaves such a task out of that instant's bands rather than assigning it a default, which would make the bands sum to slightly under the task count. Read it as a defensive branch rather than as a residual to expect: resolution only yields nothing when the segment has no status name at all, and migration 071's CHECK requires both endpoints of a status transition to be present, while a task's own status is non-null. The shared name inference places every other name, defaulting to in_progress, so an unrecognised status is mis-bucketed rather than dropped.

Cycle time can understate legacy work. Covered above with the three opening cases: a task whose captured history opens in neither in_progress nor done is dated from its first observed crossing into in_progress, so a task worked and parked back into the backlog before capture began reports only the stretch after it resumed. Read a chart spanning the capture boundary with that in mind; the bias shrinks as post-capture history accumulates.

truncated does not cover every read, and truncation does not always drop the oldest rows. It is set by the capped reads — the live-task page, the in-window and post-window event pages, and the cycle-time completion-candidate and history pages. Two reads inside the same transaction are uncapped and so can never set it: the project's project_statuses rows (normally a handful, but nothing bounds how many a project may have), and the follow-up read behind the pre-window entry lookup, which fetches one row per transition a task made in a residual band the cap does not bound. Exceeding the transaction budget through either surfaces as a failed request, not as a truncated payload. Which rows a cap discards also differs by read: the in-window event scan is newest-first, so it degrades the oldest history — the left edge of the chart before the right — while the post-window scan is ascending and the cycle-time reads are ordered by task, not by age.

The live-task page deserves separate mention, because its cap can shift points anywhere in the window rather than at one edge — though not every point, since a dropped task contributes nothing before its own createdAt even in an uncapped read. It is ordered by createdAt descending, so truncation keeps the newest tasks and drops the oldest — and that page supplies the task population that both cumulativeFlow and reviewQueue sample. A truncated run is therefore not an unbiased subsample of the project: it under-represents older work, which is disproportionately the completed and long-lived work, so the done bands and the queue's long tail thin out first.

The direction depends on WHICH cap bit, and truncated is a single flag that does not say. When it is the live-task page that clipped, the effect is subtractive: the CFD band counts and the queue's depth are floors — tasks are missing, never invented — and maxAgeHours is a floor too, since dropping tasks can only remove the maximum. Even then, averageAgeHours is biased in no bounded direction, because tasks are dropped by createdAt and creation age does not determine the current review-stretch age; read it as unreliable rather than as a lower bound.

The other capped reads each behave differently, so "an event page clipped" is not one case:

Capped readOrderA cap dropsEffect on the series
Live-task pagecreatedAt descthe oldest tasksSubtractive, as above — CFD counts, depth and maxAgeHours are floors; averageAgeHours unbounded.
In-window event scanoccurredAt descthe oldest transitionsNot a floor: the reconstruction projects a later category or review status backward across the gap, which can raise a band, depth or maxAgeHours.
Post-window event scanoccurredAt ascthe later transitionsAlso not a floor — the unwind back to the state at to is incomplete, so a terminal segment can be attributed to the wrong status.
Cycle-time readsby taskwhole tasks, not a time edgeDoes not touch cumulativeFlow or reviewQueue; leaves taskCount a lower bound on measurable samples.

Since truncated is a single flag that does not say which read clipped, a truncated payload is best read as misreported in an unknown direction unless you can establish which cap bit.

The second UI consumer of the read API above, after the cumulative-flow diagram. On /flow, focusing a project reveals a collapsed Trends section carrying two charts.

It loads only when you open it. The aggregation is the most expensive read on the flow surface, so this section fetches nothing until it is expanded; after that, collapsing and re-opening does not re-run it. Switching to a different project resets the section to collapsed rather than silently running the new project's aggregation.

Read that precisely: it means the Trends section adds no further flow-metrics read to focusing a project — not that focusing runs none. Focusing already issues two eager reads of the same endpoint: the cumulative-flow card's own request (with its own explicit window), and the useProjectFlowMetrics hook that feeds the cycle-time chart. Opening Trends makes a third. PT-806 introduced that hook precisely as the single per-surface read the three charts should share; folding the other two onto it is open follow-up work.

Review queue — depth & age

Queue depth on the left axis (tasks), mean and longest wait on the right (hours).

  • Depth counts the status half of the queue only — tasks sitting in an isApprovalReady status. The live board additionally counts a task with an open PR that is not yet done, so the board's review count can legitimately be higher than this chart's depth. That gap is a documented contract difference, not a bug; see Residuals above.
  • An empty queue shows no waiting time, not 0 h. The API reports 0 for the ages when the queue is empty, and drawing that would read as "reviews completed instantly" — the opposite of "nothing was waiting" — so the age lines break instead.
  • Age is the current wait. Being sent back for changes takes a task out of the queue entirely; the clock starts again from the moment it is resubmitted for review, not from the rejection. A task already in review when the window opens is aged from its last captured transition at or before the window's left edge; where none was captured for it, the age falls back to the task's creation and can overstate by months. What triggers that fallback is a missing captured entry, not the window's position — so it is not confined to a window spanning the start of capture. It applies in any window, and persists for that task until a transition is captured for it. (Capture was never backfilled, which is why pre-capture review work is the usual cause; see the fuller treatment under Residuals above.)

Rework rate

Rework events as bars on the left axis (count), the rate as a line on the right (percentage).

  • The rate is not capped at 100%. A reverted PR counts as rework but is not a status transition, so it enters the numerator without entering the denominator: a bucket with one non-rework transition and three reverts is genuinely 300% (had that transition been a reopen it would count in the numerator too, giving 400%). Capping it would clip real signal and make one revert and five reverts indistinguishable.
  • Rework events are charted, not just tooltipped, because that same asymmetry produces buckets holding reverts and no transitions at all — a rate of 0 over an empty denominator. A rate-only chart would draw those as quiet.
  • A rate with no denominator is shown as not applicable (), never as 0%.
  • The summary strip reports "Tasks with rework" and "Tasks with status transitions" as two independent figures rather than a ratio: a task whose only rework was a reverted PR is counted in the first without appearing in the second, so a ratio phrasing could print a numerator larger than its denominator. PR-revert events are labelled as already included in the rework events — and they are events, not pull requests: capture writes one PR_REVERTED row per referenced task, so a single PR reverting three tasks contributes three.

Truncation

When the payload's truncated flag is set, the section shows a caveat saying the figures may be inaccurate in an unknown direction — deliberately non-directional, because the flag says a capped read hit its cap without saying which one, and the table in Residuals above shows the direction differs per cap. It is a separate matter from window clamping, which is visible in window.from.