Skip to main content

Cycles

Cycles are time boxes (sprints / iterations) used as a reporting lens over the flow engine. A cycle scopes a planning period, groups the tasks worked in it, and backs a reopen-stable burndown and per-cycle velocity. A cycle is scoped to a project, an initiative, or a set of projects — exactly one, DB-enforced by a num_nonnulls(projectId, initiativeId, projectSetId) = 1 constraint (migration 070 for the first two, PT-774; widened to three arms by migration 080, PT-938). A project-scoped cycle accepts tasks from that one project; an initiative-scoped cycle accepts tasks from any project whose epic chain is tagged to that initiative, so it spans projects the way an epic does; a multi-project cycle accepts tasks from any project it explicitly names. There is at most one active cycle per scope. The DB + domain model (PT-793), the REST lifecycle surfaces (PT-794 — create / list / get / activate / complete / archive), the MCP/CLI surfaces (PT-795), and — since PT-828 — the cross-project detail-read API and GUI (the per-cycle burndown and task-board reads, and the detail page over them, aggregating members across projects behind PT-283 redaction) are all in place.

Cycles are a lens, not the execution primitive — the dependency-gated ready queue and review queue (see Flow) remain how work is actually pulled. A cycle simply measures a period.

Data Model

Each cycle belongs to exactly one scope — a project, an initiative, or a project set (projectId XOR initiativeId XOR projectSetId). Key fields:

  • name — human-readable name, e.g. Sprint 7 (required)
  • sequence — ordering position, unique per scope (per project for a project-scoped cycle, per initiative for an initiative-scoped one, per project set for a multi-project one) among non-archived cycles; a soft-deleted cycle may reuse a sequence. Optional on the REST create: omit it and the server allocates the next free number for the scope — and if a concurrent create claims the number it derived, it re-reads and retries, so simultaneous creates each end up with their own number instead of one succeeding and the rest failing with a 409 (PT-937). An explicitly supplied duplicate still returns HTTP 409, as does an allocation that finds no free number within its retry budget. The MCP create_cycle tool and pt create-cycle still require sequence on the project and initiative arms (it is optional on the multi-project projectIds / --projects arm, whose owning set may not exist yet; PT-951 lifts the rest) — see MCP tools & pt CLI below
  • stateplannedactivecompleted. A cycle is born planned; it never reverts.
  • goal — optional theme / objective
  • cadenceTypeweekly | biweekly | monthly | custom (default custom)
  • isRecurring — when true, closing this cycle auto-materialises its next-in-sequence successor (see Auto-recurrence below); default false
  • startDate / endDate — optional ISO-8601 datetimes; anchor the ideal burndown line
  • targetPoints / targetMergedTasks / reviewHoursBudget — optional agentic capacity targets
  • sourceCycleId — read-only provenance: the cycle this one was auto-materialised from. Null whenever no source pointer is retained — a cycle created directly, or an auto-materialised successor whose source row was later hard-deleted (the tenant-pinned FK clears just this column via ON DELETE SET NULL (source_cycle_id), leaving the successor itself intact). Archiving a source via the DELETE endpoints below is a soft delete — it sets deletedAt and leaves sourceCycleId still pointing at the archived row
  • deletedAt — set when the cycle is archived (soft-deleted)

Tasks join a cycle via a nullable Task.cycleId and carry an optional Task.estimatePoints (the cycle sizing unit, distinct from estimatedHours which stays a time-tracking signal). Membership is scope-shaped: a task may join a project-scoped cycle only when it is in that same project; an initiative-scoped cycle only when an epic in its chain (the epic itself, its children, or their subtasks) is tagged to that initiative — so an initiative cycle deliberately spans projects; and a multi-project cycle only when its own project is one the cycle's project set names (no epic chain is consulted). Either way an archived cycle is rejected, and the failure returns a 400. Membership changes append ENTERED / REMOVED / ESTIMATE_CHANGED provenance events (actor / source / reason / correlation). In the GUI (PT-949), a task's cycle is set, changed and cleared from the Cycle field in the task-detail Properties panel — see Assigning a cycle from the task detail. Besides the normal task-update surfaces, a task can be assigned to a project-scoped cycle by the coordinator — the brain proposes an assign_task_to_cycle action that is staged and executed through the same confirm-gated pending-action pipeline and the same validateCycleMembership path (initiative-scoped assignment is deferred to PT-794; see Assignable agents). This slice hydrates no cycle roster into the brain, so the exact target cycle UUID must appear in the consult question or supplied context — the coordinator does not discover cycles autonomously yet.

Lifecycle

State transitions are a distinct capability from a descriptive edit — they flip the lifecycle and capture a burndown snapshot in the same transaction:

  • Activate (state → active) — enforces one-active-per-scope (a second active cycle in the same project, initiative or project set returns 409 — uq_cycles_one_active_per_project_set, migration 080) and writes the activation snapshot (the burndown's day-0 anchor). Driven from the cycle detail header on all three scope arms (PT-947), and from the MCP update_cycle tool / pt update-cycle --state active (all three arms since PT-946).
  • Complete (state → completed) — writes the close snapshot, freezing remaining-at-close so per-cycle velocity (scope − remaining) stays stable even if a task is reopened later. Reachable both from the API/UI and, once the cycle's window has elapsed, from the hourly auto-close cron; both write the close snapshot through the same shared code, with the audit and close-timestamp differences that section lists.

The lifecycle is forward-only (planned → active → completed, never reverts): a cycle can only be activated while it is planned and completed while it is active. Any other transition — activating an already-active or completed cycle, or completing a planned or already-completed one — is rejected with a 409, keeping the frozen close snapshot (and the velocity read from it) stable.

This is enforced in the database, not only by the API. The rule is that a cycle is born planned and advances exactly one step at a time, and both halves are enforced by a single DB trigger, trg_cycles_state_forward_only: migration 075 (PT-895) created it to reject any UPDATE that reverts or skips a step, and migration 076 (PT-920) recreated it to fire on INSERT as well, rejecting any cycle that starts at active or completed. Both arms raise a check_violation. So raw SQL, an import, or a maintenance script cannot mint a cycle that reached a terminal state without ever being activated — which matters because the activation snapshot is the burndown's day-0 anchor. Losing it does not break the chart: assembleBurndown falls back to the earliest snapshot as its baseline. It silently degrades it — the ideal line is then anchored to whatever partial state happened to be captured first rather than to true day-0 scope, so that cycle's historical baseline is no longer reconstructable.

The triggers govern state ordering; they do not themselves guarantee a snapshot was written (that is the service's job on the transition paths above). What they remove is the ability to skip the ordered walk entirely in a single statement.

Closing freezes the cycle's scope

Once a cycle is completed its membership set is frozen against ordinary edits: a task can neither be added to it nor removed from it. Attempting either returns a 400 naming the cycle as completed, and the rule is backed by a database trigger so raw-SQL and import paths cannot slip past it. It is not absolute — three announced, provenance-writing paths still detach members, listed below — so what the freeze stops is the silent change.

This exists because the close snapshot is measured once and can never be superseded: a cycle may only ever hold one close snapshot, so no later row can be inserted to correct a wrong one. (The constraint blocks a superseding insert; it does not make the existing row immutable — a direct UPDATE by a tenant-authorised role still would.) A silent post-close membership edit would therefore leave that permanent record describing a set the cycle no longer holds, with nothing anywhere saying so. Freezing both directions is deliberate: a removal shrinks the membership below the frozen count, which is the same divergence as an addition.

Note what the freeze is not defending against. The retrospective's contributor breakdown is probed inside the closing transaction itself, so it never re-reads membership afterwards; and since PT-917 the displayed progress reads the snapshot rather than the members. The freeze is the write-side guarantee that the frozen figures keep describing the cycle they were measured from — not a patch over a live read.

Displayed progress no longer drifts this way at all — since PT-917 a completed cycle reads its close snapshot rather than its members; see Progress is frozen once a cycle closes.

Two clarifications that are easy to get backwards:

  • Freezing a cycle's scope does not freeze the tasks in it. Members of a completed cycle stay ordinarily editable — renaming, changing status, re-parenting, re-tagging an epic. Only the membership edge itself is fixed. One qualification, unchanged by the freeze: on an initiative-scoped cycle, membership depends on the task's epic chain, so an edit that would move a member out of the cycle's initiative is still rejected and asks you to clear cycleId first — the same rule that applies to an active cycle.
    • The same protection now covers the reverse direction — mutating a carrier, not the member (PT-928). Because an initiative cycle's membership is derived through the epic chain, a task can be stranded without ever being touched: re-tagging its carrier epic to a different initiative, or changing a carrier parent's epicId, breaks the chain for that carrier's dependents. On a completed cycle such a carrier mutation is now rejected with a non-oracular 400 (it names the frozen cycle and blocking member only to a caller who can already read both, otherwise a generic message), so a member can no longer be silently stranded out of a frozen set through the back door. On a planned or active cycle the newly-ineligible dependents are instead auto-detached, each writing a REMOVED provenance event — an announced, provenance-writing move like the completed-cycle exit paths below, though this one applies to planned/active cycles rather than being another way off a completed one. A statement-level DB trigger backstops both, so raw-SQL and bulk writes cannot bypass it.
  • Three paths may still move a member off a completed cycle, because each is an announced, provenance-writing move rather than a silent edit: auto-recurrence rollover (which runs only against completed cycles, and is the sanctioned way unfinished work leaves one), archiving the cycle, and a bulk move that changes a task's project. Rollover leaves the frozen close snapshot undisturbed because only unfinished members move.

Progress is frozen once a cycle closes

The scope freeze above stops silent membership edits, but three sanctioned paths still move members off a completed cycle — and rollover is one of them. So a closed cycle's displayed progress reads its frozen close snapshot, not its current members. This matters most on a recurring cadence: rollover carries every unfinished member to the successor and leaves the completed ones behind, so a closed cycle's live membership collapses toward "only the work that got done". Reading it live made the cycle that delivered worst show a progress bar near 100%.

The rule applies to both surfaces that display those figures — the cycles list and the cycle detail stats strip — and only to completed cycles. A planned or active cycle's progress is still live, which is what a current-progress bar should show.

Each figure states how it was obtained, so a reader is never left inferring:

CycleCycles list rowCycle detail stats strip
planned / activea live reduction over current members (done = completed_at is set)also live, but counting tasks in a completed status per getCompletedStatusNames (an isCompleted-flagged status, else the last by sortOrder, else the legacy COMPLETED) — a pre-existing difference in how the two define "done"
completed, live closethe frozen close figuresthe frozen close figures
completed, approximate closebar + percentage, with an Approximate qualifier (visible and to a screen reader)each tile labelled (approximate)
completed, close snapshot missing or unusable"Progress unavailable" — no bar, no percentage, and specifically not a zeroan em-dash in each of Scope / Done / Remaining — again not a zero

(The list row is the only one of the two that draws a progress bar at all; the strip's progress portion is always three numeric tiles (Scope / Done / Remaining — Days left and Pace are governed separately), so "unavailable" reads there as the same em-dash it shows while loading.)

The approximate case is treated less strictly than velocity, which is suppressed outright for the same row: velocity is compared across cycles to plan with, whereas progress is read in the context of the one cycle it describes, so a labelled approximation informs where a labelled approximate velocity would mislead. The unavailable case is reachable — the operator backfill deliberately completes a stale cycle without writing a close row when there is nothing honest to copy — and zero was rejected for it because it is indistinguishable from a cycle that genuinely delivered nothing. A close row that is present but unusable reads the same way: PostgreSQL numeric admits NaN (and the columns' non-negative checks let it through), so a counter that is not a finite number is reported as unavailable rather than published as a measured figure.

One deliberate asymmetry on the detail page: the stats strip freezes, but the task groups beneath it keep listing current (non-cancelled) membership, and the page says so. A close snapshot stores aggregates, not task identities, so the board as it stood at close cannot be reconstructed from anything PT persists. After a rollover the strip and the groups therefore disagree on purpose — the strip says what the cycle delivered, the groups say what is in it now. (Since PT-730 the List and Board views both exclude cancelled members — a presentation-only exclusion that leaves scope and every metric counting them; the flat tasks payload still carries the cancelled rows, with lane: null.)

Completing a cycle additionally attempts to file a retrospective consult — by either path. A manual close attempts it inline (described here); an auto-close enqueues it and a separate cron does the filing shortly afterwards. "Attempts" is the accurate word on both paths: the gates below decide whether there is one to file at all, and the filing itself is best-effort rather than guaranteed. Both feed the same captured metrics through the same synthesis pipeline; they differ in who the consult is attributed to and in the close timestamp it is stamped with (see that section). Once the close transaction has committed, the coordinator synthesizes the cycle's delivered velocity and slippage from that frozen close snapshot, alongside per-assignee contributor counts aggregated from the same in-transaction task read. Those counts are not stored as cycle data — there is no contributor table, and they are not part of the frozen snapshot. On the auto-close path they do travel through the job queue, so they sit in that job's jobs.queue row (payload included) for as long as the row is retained. The filing is best-effort on both paths — a failure is logged and can neither fail nor roll back the close. That holds for the auto-close path's queue write too, even though it happens inside the close transaction: it is wrapped in a savepoint, so a successful enqueue commits atomically with the close — the job cannot be left behind by a close that rolled back — while a failed one is unwound on its own and the close still commits, with the loss logged and counted. So no retrospective work — the provider call, the drain, or the queue write itself — can roll back the close it belongs to. One indirect cost does remain, and is documented rather than denied: each attempted queue write may wait briefly for a lock, so sustained contention on the queue makes a sweep close fewer cycles per run, and a backlog that outgrows that reduced rate can age past the auto-close window into the historical bucket described under auto-close. On the manual path it is also awaited, so a close takes as long as the model call (moving that off the request path is tracked separately); on the auto-close path the model call happens on the queue drain instead, so nothing waits on it. On an UNCLASSIFIED initiative, and where a knowledge-base space is configured, the result is also filed into the tenant KB as searchable organisational memory; on a classified initiative it stays a consult record only. A cycle that resolves no initiative gets no retrospective, since the consult is scoped to one. The consult is idempotent per (tenant, cycle) — one retrospective per cycle, per tenant, whatever initiative it is filed under.

A once-daily cron additionally captures one daily snapshot per eligible active cycle, so the burndown is assembled from immutable per-day rows rather than replaying the single-valued completedAt. Eligible means the cycle's window has not closed: snapshotting stops after its inclusive UTC end day (PT-846), so rows no longer accrue past the window on a cycle that is still active only because nothing has retired it yet. An undated cycle has no window and keeps snapshotting indefinitely.

Auto-close

Completing a cycle no longer depends on someone remembering to. An hourly cron (/api/cron/cycles/auto-close) closes every active, non-archived cycle whose window has recently elapsed — the same state → completed transition, with the same close snapshot and the same one-active-per-scope consequences as a manual close. "Recently" is a deliberate bound, not an implementation detail: a cycle that elapsed longer ago is left alone for a separate backfill (see the recency bullet below), so this job is not what repairs a cycle that has been stuck active for weeks.

Without it an elapsed cycle stayed active indefinitely, and that was not cosmetic: because at most one cycle per scope may be active, the dead cycle held its scope's only active slot and every replacement failed activation with a 409 — the team could not start a new cycle. Velocity also never froze (it is read from the close snapshot, which is only written at close), and the dashboard's current-cycle widget, which ranks by soonest end date, pinned to the elapsed cycle ahead of every genuinely current one.

  • The window is inclusive, and the boundary is UTC. A cycle stays active for the whole of its end day and becomes eligible the instant the next UTC day begins — an end date of 18 Jul is eligible from 19 Jul 00:00 UTC. There is no multi-day grace period and no per-cycle or per-tenant timezone: everything else in the cycle/bucket model normalises to UTC too. A cycle with no end date is never auto-closed; it has no window to elapse.

  • Only recently-elapsed cycles. The close snapshot is computed from the cycle's tasks as they are when the cron runs, which is a fair stand-in for end-of-window state only near the boundary. So the sweep closes only cycles that elapsed within the last 2 days. Longer-elapsed ones are left active, counted, and warned about in the run's telemetry — closing them from present-day task state would record fabricated history that no rival close row could ever correct, because the one-close-per-cycle constraint admits only one (see Snapshot provenance). Draining that backlog is the job of the operator-run backfill described in Draining cycles stuck past the auto-close window, which closes such a cycle either as closedApproximate (copying the end-date daily row, velocity withheld) or as closedWithoutSnapshot (no close snapshot at all, so its progress reads as unavailable). Until someone runs it, a cycle stuck active for longer than the window stays visible through this warning rather than being silently stranded.

  • It runs before rollover, deliberately. Auto-close is scheduled at 55 * * * * and the auto-recurrence rollover at 0 * * * *; rollover only considers completed cycles. Closing at minute 55 — five minutes before the hour, not after it — means a recurring cycle closed by the cron is already completed when rollover runs, so it rolls over in the same hour instead of missing that pass and waiting a full hour for its successor.

  • A manual close and the cron race cleanly — exactly one wins. Neither path is privileged: whichever reaches the still-active row first performs the transition, and a cycle therefore gets exactly one close snapshot however the two interleave. The loser's outcome differs by path, though: a manual close that arrives second is rejected with a 409, exactly as completing an already-completed cycle always has been, while the cron simply records the cycle as raced and moves on — a lost race is normal operation for a sweep, not an error.

  • Each cycle closes in its own transaction, and an auto-close writes a critical audit entry (cycle.completed, system actor) atomically with the state flip — so the audit cannot be missing for a state change the cron made. One failing cycle costs only itself; the rest of the sweep still lands. A run is also bounded by a candidate cap and an elapsed budget, and processes the most overdue first, so what one run does not reach the next hourly one picks up — but only for as long as it stays inside the recency window. That ordering is best-effort, not a starvation guarantee: a leftover close to the two-day edge can age out of eligibility before a later run reaches it, at which point it stops being retried and joins the historical count instead. That count is what makes the shortfall visible.

  • Known differences from a manual close. Two, both deliberate. A manual close records its audit after the close commits and best-effort (a failure there is logged, and never fails the close), where the cron's is atomic and attributed to the system actor. And a manual close stamps its close snapshot at the moment of closing, where the cron stamps it against the cycle's end date, so an elapsed cycle's burndown ends on its own last day rather than the day the sweep happened to run.

  • Retrospective queueing is attempted, not skipped. An auto-closed cycle is put up for the same retrospective consult a manually-closed one is — simply filed a few minutes later, and on the same best-effort terms rather than as a promise. The retrospective makes a model call, which does not fit inside the sweep's per-run budget, so the sweep instead enqueues a job in the same transaction as the close and a per-minute cron (/api/cron/cycles/file-retrospectives) does the filing. Enqueuing inside the close transaction is what makes it durable rather than best-effort, and the guarantee it buys runs one way: a queued job can never outlive a close that did not commit, and there is no gap after the commit in which a crash loses the retrospective. It does not mean every closed cycle has a job waiting. The enqueue sits in a savepoint, so if the queue write itself fails (a missing grant, a schema that has drifted from the deployed code, contention) the close still commits without one, and the sweep reports it as a lost retrospective rather than refusing to close the cycle. That direction is deliberate: an elapsed cycle that could not close would go on blocking its scope's next cycle, which is worse than the missing document. The facts the retrospective describes — the cycle's targets, its contributor counts, its initiative — travel with the job, captured inside that transaction, so a later edit cannot rewrite what the filed document says about the close.

    Two gates decide whether a job is enqueued at all, both inherited from the manual path: the close must have written a new frozen snapshot (on a no-op, the stored metrics and the freshly probed contributors would describe different moments), and the cycle must resolve an initiative (a retrospective is scoped to one). A cycle that fails either gets no retrospective, exactly as on a manual close.

    The delay is safe for provenance: the initiative a retrospective is filed under is frozen with the close, not looked up when the job runs. A project-scoped cycle inherits its initiative from its project, which can be re-pointed at any time — so the close records which one it belonged to, and the filing reads that. Moving the project afterwards changes nothing about a retrospective describing a close that already happened.

What the two paths do share is the outcome and the close-snapshot handling: the same state → completed result, the same frozen close snapshot written by the same shared code, and therefore the same effect on velocity and on the one-active-per-scope slot. The transition itself is each path's own — the cron's carries the eligibility and recency guards described above, which a manual close has no use for — so treat the shared part as "what closing a cycle records", not as one code path with two entry points.

Auto-recurrence

A cycle flagged isRecurring keeps the cadence going without manual setup: once it is completed, an hourly cron (/api/cron/cycles/rollover) materialises the next cycle in sequence and rolls the closed cycle's unfinished work into it.

  • Successor dates come from the cadence. Cycle windows are inclusive on both dates (a 1–7 Jul cycle is 7 days long), so the successor starts the day after the closed cycle ended — the two never share a day — and spans exactly one cadence period: 7 days (weekly), 14 days (biweekly), a whole calendar month (monthly — 1–31 Jul becomes 1–31 Aug), or the source window's own length (custom). For the fixed cadences (weekly / biweekly / monthly), a source with no end date has one implied a period out, so it still advances. custom is different: it takes its length from the source window, so it needs BOTH bounds — a custom source missing either one, or whose end precedes its start, yields a dateless successor rather than an invented window.
  • Rollover moves every member whose work is still open (completedAt is null) at the time the cron runs onto the successor, writing REMOVED (old) + ENTERED (new) membership events with a SYSTEM source. Completed members stay on the closed cycle, so its frozen close snapshot and velocity are never disturbed.
  • The successor is born planned and inherits isRecurring + cadenceType, so the chain continues on its next close. Activating it stays a separate, one-active-per-scope transition.
  • The job is idempotent — a UNIQUE source_cycle_id back-pointer plus ON CONFLICT DO NOTHING means a re-run never duplicates a successor or double-moves a task — and each rollover records an audit entry (cycle.cron.rolled-over). Non-recurring completed cycles are left untouched.

Burndown

GET …/cycles/{cycleId}/burndown returns:

  • ideal — a linear guideline from full scope down to zero across the cycle window
  • actual — remaining work per snapshot date, carrying both a task count and points, so a client can toggle lines with no server round-trip
  • coverage — an estimated N of M indicator: tasks without an estimatePoints still count toward the task line but are excluded from the points line
  • paceahead | on_track | behind, comparing the latest actual remaining to the ideal at that date

Snapshot provenance

A cycle carries at most one close snapshot, database-enforced since migration 074 across all dates — not just per day. So a late close, a repair, or a re-close cannot move a velocity frozen at completion by adding a second, newer close row, which was the open gap: velocity reads the latest close, and the old index only stopped a repeat on the same day. (The constraint is on inserting a rival row; it does not make the existing row immutable — a direct UPDATE still would.)

Every snapshot also records how its numbers were obtained. A live snapshot was computed from the cycle's tasks inside the writing transaction; an approximate one was copied from another snapshot because the true end-of-day state was not reconstructable — the case of a cycle left active for longer than the auto-close window, which the operator backfill then drains. Copying the end-date daily row gives the burndown a correct end-of-window point, but it misstates the final day's delivery — understating it when work lands after that early-morning capture, and overstating it when a task the row counted as complete is reopened before the day ends.

So an approximate close is kept and still feeds the burndown, while velocity reads as unavailable (null) for that cycle wherever velocity is published — the summary read model behind the cycles list, which renders it as "Velocity unavailable". The cycle-close retrospective likewise skips filing for such a cycle rather than recording a copied figure as durable knowledge-base memory. Velocity is the number compared across cycles to plan with; publishing a copied figure beside genuinely frozen ones would be fabricated history. Migration 074 adds the column as NOT NULL DEFAULT 'live', so every snapshot written before it is backfilled to live — correctly, since all of them were computed from live tasks. Nothing persisted is ever without a provenance; the readers additionally treat an absent value as live to cover callers that project a partial row, so the suppression can only ever fire on a close explicitly marked approximate.

Draining cycles stuck past the auto-close window (operator)

Automated closing does not reach back. Any cycle still active 2 days or more past its end day is outside the cron's window forever — the boundary instant itself belongs to the backfill, not the cron — the cron counts it and warns, but will never close it, because it could no longer do so honestly. And because one-active-per-scope is a partial unique index on that state, the stuck cycle holds its scope's only active slot (uq_cycles_one_active_per_project, uq_cycles_one_active_per_initiative, or uq_cycles_one_active_per_project_set) and blocks every replacement from being activated.

This is not a one-time migration concern. The original backlog — cycles that elapsed before auto-close existed — was drained on 2026-08-04, but the same state recurs whenever the cron is interrupted for more than two days, a deployment is paused, cycles arrive by import, or a newly-onboarded tenant brings long-elapsed ones. So the release runbook registers the drain as a standing check run at every release, and the same procedure is the ad-hoc remedy whenever a stuck cycle is reported.

An operator script clears them, and it is the only thing that ever writes an approximate close:

# 1. DETECT — read-only. `scanned=0` means no backfill candidates. It does not mean
# nothing to do: a preceding line reports cycles deferred to the cron, and if the
# cron is not running those age past the window and become stuck. Check both.
DATABASE_URL='…' npm run db:backfill-stuck-cycles:dry-run -w @constellation/project-tracker

# 2. APPLY — only if step 1 reported candidates.
DATABASE_URL='…' CONFIRM_APPLY=1 npm run db:backfill-stuck-cycles -w @constellation/project-tracker

CONFIRM_APPLY=1 is mandatory on the apply, and there is a separate :dry-run script rather than a flag, because npm swallows --dry-run: npm run … --dry-run consumes the flag as an npm option and runs the lifecycle script without it, so a script defaulting to apply would write for real. Every misspelling is swallowed the same way, so the confirmation is an environment variable — npm's flag parsing cannot eat one. In practice that means npm run db:backfill-stuck-cycles --dry-run now refuses: it neither applies nor inspects anything, so use the :dry-run script rather than reading the refusal as a completed dry run. (Adding the flag to an already-confirmed apply still applies — the confirmation wins.)

The only database connection variable it needs is DATABASE_URLDIRECT_URL has no effect on it, and the CONFIRM_APPLY=1 above is a safety confirmation rather than a connection setting. Against a transaction-mode pooled DSN that URL must carry pgbouncer=true. Without it the run fails with prepared statement "s0" already exists, and the fix is to add the parameter and re-run — but do not assume nothing was written. Where the failure has been observed it aborts inside the initial scan, before any cycle is touched; the same error can also strike a later per-candidate transaction, and candidates commit one at a time, so earlier ones are already durable. A scanned=… summary line means the loop finished and its counts are what happened. Its absence proves less: the line is printed only after every candidate has been attempted, so a run killed mid-loop ends without one too, after some cycles have already been closed. A missing summary means "wrote nothing" only when the error is positively located in the initial scan, before any per-cycle line was logged. Anything else is potentially partial — treat it as such. The release runbook (.claude/skills/release-and-migrations/SKILL.md § Post-deploy data repairs) carries the full procedure, including how to verify afterwards and why the apply run's needsManualRepair list must be captured from that run rather than recovered later.

It is safe to run while the hourly auto-close cron is live: the cron owns cycles that elapsed recently (where it can still compute an honest close from live tasks) and the script owns everything at or beyond the boundary, each applying the other's complement. The two predicates are exact complements for a shared instant — but each invocation captures its own now, so a cron starting just before the cutoff and a backfill starting just after it can both select the same cycle. What makes that safe is the conditional CAS on the cycle's state: exactly one writer wins and the other reports the cycle as raced. Recently elapsed cycles the script declines are reported as deferredToCron rather than silently skipped.

Per cycle it takes one of three paths, and reports which:

OutcomeWhenEffect
closedApproximatean end-date daily snapshot existsits aggregates are copied into a close row marked approximate — burndown intact, velocity null
closedWithoutSnapshotno such daily rowclosed with no close snapshot; the cycle id is listed under needsManualRepair
closedWithExistingSnapshotthe cycle already holds a close rowthat row is left untouched and its own provenance is reported

It never computes a close from today's tasks — for a cycle that ended weeks ago, live state is not end-of-cycle state, and an unavailable velocity is preferable to a fabricated one. Every close writes a critical audit entry recording the trigger, the outcome above, and the provenance the close row actually carries. The run is idempotent, and it exits non-zero if any cycle failed, so a deployment runner cannot mistake a partial drain for success — re-run it after investigating.

API

The project-scoped routes (/api/projects/{id}/cycles…) enforce both route-level project access (verifyProjectAccess) and a permission check — RLS isolates tenants, not intra-tenant project access. Two endpoints are the exception — workspace-scoped (active-org, gated by requireCrossProjectListAccess rather than verifyProjectAccess): the cross-project GET /api/cycles list and the dashboard GET /api/cycles/current (below).

Two list surfaces feed the cycles list GUI: the project-scoped list accepts ?summary=1 for an enriched read model, and the cross-project GET /api/cycles returns that same summary across the caller's non-archived readable projects in the active org (cycles in COMPLETED / CANCELLED projects are excluded — the same listing semantics for full-read and scoped callers). The project-scoped ?summary=1 path is intentionally not project-status-filtered: a caller who opened a specific (possibly archived) project still sees its cycles.

MethodPathPermissionDescription
GET/api/cyclesfull tasks.read + any projects.readCross-project "all cycles" summary list — active-org scoped, narrowed to the caller's non-archived readable projects. Requires full tasks.read (a .own-only grant is rejected, like /api/tasks); a projects.read.own caller still sees only their projects' cycles
GET/api/projects/{id}/cyclestasks.readList cycles (archived excluded unless ?includeArchived=true); ?summary=1 returns the enriched list read model
POST/api/projects/{id}/cyclestasks.createCreate a cycle (born planned)
GET/api/projects/{id}/cycles/{cycleId}tasks.readGet a single cycle
PATCH/api/projects/{id}/cycles/{cycleId}tasks.update.anyEdit descriptive fields, or send { "state": "active" | "completed" } to transition
DELETE/api/projects/{id}/cycles/{cycleId}tasks.delete.anySoft-delete (archive) — detaches member tasks and frees the one-active / sequence slot
GET/api/projects/{id}/cycles/{cycleId}/burndowntasks.readRead the burndown (ideal + actual, coverage, pace)
GET/api/projects/{id}/cycles/{cycleId}/taskstasks.readRead the task board — status groups (List view) and a flat tasks list carrying each task's server-resolved lane (Board view) + scope/done/remaining stats
GET/api/projects/{id}/tasks/{taskId}/eligible-cyclestasks.readThe cycles this task may join (below) — the read half of the membership rules, in the slim cycle-option shape. Archived and completed cycles are excluded
GET/api/initiatives/{id}/cyclesfull initiatives.read + initiative accessList an initiative's cycles (PT-774) — the cross-project scope arm; archived excluded unless ?includeArchived=true. Hardened to FULL scope in PT-828 (A3b)
POST/api/initiatives/{id}/cyclestasks.create + initiative accessCreate an initiative-scoped cycle (born planned)
GET/api/initiatives/{id}/cycles/{cycleId}full initiatives.read + initiative accessGet a single initiative-scoped cycle. Hardened to FULL scope in PT-828 (A3b)
PATCH/api/initiatives/{id}/cycles/{cycleId}tasks.update.any + initiative accessEdit descriptive fields, or { "state": … } to transition
DELETE/api/initiatives/{id}/cycles/{cycleId}tasks.delete.any + initiative accessSoft-delete (archive) an initiative-scoped cycle
GET/api/initiatives/{id}/cycles/{cycleId}/burndownfull initiatives.read and full tasks.read + initiative accessRead a cross-project cycle's burndown (PT-828). Aggregates are computed over all members, so they do not vary with the caller's readable set
GET/api/initiatives/{id}/cycles/{cycleId}/tasksfull initiatives.read and full tasks.read + initiative accessRead the cross-project task board (PT-828) — same groups + flat tasks shape as the project arm, with members in projects the caller cannot read redacted
POST/api/cyclestasks.create and full projects.read and INTERNAL memberCreate a multi-project cycle (PT-938) — the one create whose scope travels in the BODY (projectIds, at least two), because its owner is derived server-side
GET/api/cycle-sets/{id}/cycles/{cycleId}full projects.read + set accessGet a single multi-project cycle. {id} is the cycle's projectSetId
PATCH/api/cycle-sets/{id}/cycles/{cycleId}tasks.update.any and full projects.read + set accessEdit descriptive fields, or { "state": … } to transition — the two are mutually exclusive in one call
DELETE/api/cycle-sets/{id}/cycles/{cycleId}tasks.delete.any + set accessSoft-delete (archive) a multi-project cycle — also how a project in the set is unblocked for deletion
GET/api/cycle-sets/{id}/cycles/{cycleId}/burndownfull projects.read and full tasks.read + set accessRead a multi-project cycle's burndown (PT-938). Aggregates cover all members, so they do not vary with the caller's readable set
GET/api/cycle-sets/{id}/cycles/{cycleId}/tasksfull projects.read and full tasks.read + set accessRead the multi-project task board (PT-938) — same shape as the other two arms, with members in projects the caller cannot read redacted

The initiative-scoped routes (PT-774) are the cross-project arm: access is resolved against the initiative, and the tenant is the initiative's own org (the PT-687 cross-org fix), not the caller's active-org cookie. readInitiativeUnderOwnOrg evaluates initiatives.read and runs the read under the initiative's own org; withInitiativePermission evaluates the write permission against that org and hands the handler that org id, which the cycle tools then write under via withTenant (so a raw inline write in one of these handlers would still need to repoint the tenant itself). The full lifecycle (create / list / get / activate / complete / archive) is wired, and PT-828 added the two detail-read item routes (…/tasks and …/burndown) plus the detail page over them.

All four initiative-scoped READ routes require full-scope initiatives.read, re-checked inside the callback — a .own-scoped grant satisfies a base permission in this codebase's matchPermission, so the outer route-level check alone would admit a caller who may read only their own initiatives. PT-828 hardened the two pre-existing GETs (the collection and the item) for that reason, and the two detail-read routes it adds are gated harder still: they additionally require full tasks.read, because unlike the other two they return other people's TASKS rather than cycle rows. Every denial — unknown initiative, access-envelope denial, permission denial — returns the byte-identical 403 body, so the route cannot be used as an existence oracle for initiatives the caller cannot see.

Multi-project cycles

A cycle can also span an explicit set of projects (PT-938, migration 080). The initiative arm already crosses project boundaries, but only through the epic chain, so "run one sprint across these three projects" meant creating and tagging epics whose only purpose was to satisfy the membership predicate.

How you create one. In the cycle create modal the Scope control has two options, Project/s and Initiative, and the project side is a multi-select. Which arm the create lands on is derived from the count: tick one project and you get an ordinary project-scoped cycle; tick several and you get a multi-project one. The field caption follows the selection (Project / Projects). There is no third button, and nothing to name or configure.

What it is underneath. A multi-project cycle is owned by a row in projects.cycle_project_sets, which names its projects in cycle_project_set_members. That row is plumbing, not a feature: it has no name, no page, no nav entry and no CRUD routes, and nothing in the product lists or edits one. It exists because sequence, one-active-per-scope, the tenant guard and the whole cross-project read model all key on the scope owner being a scalar column with an id — a set of projects hung directly off the cycle would mean rebuilding every one of them, whereas cycles.project_set_id is a third column of the same shape.

Sets are reused per project combination. The set's member_key (its sorted project-id list) is derived by a database trigger from the member rows — never written by the application — and is unique per tenant. So picking the same projects again resolves to the same owner, and the new cycle is Sprint 2 of that combination. That reuse is what keeps sequence and one-active-per-scope meaningful on this arm: without it every cycle would sit at sequence 1 and both indexes would exist while constraining nothing, silently disabling recurring rollover and auto-close here alone. Two people creating the same combination at the same moment converge on one owner rather than each minting their own.

Membership. A task may join iff its own project is named by the set — no epic chain, which is also why this arm has no carrier-revalidation problem: nothing about a third task can make an existing member ineligible. Only editing the set could, and the database rejects removing a project while a task of that project sits in one of the set's cycles.

Reading one. The cross-scope GET /api/cycles list surfaces these cycles to callers holding full projects.read, with an owner label derived from the member project names (the first two, sorted, then +N) — a set has no name to show.

Guests do not see this arm at all. A GUEST member is project-scoped by policy however their role reads, and a multi-project cycle carries no project predicate to narrow by — its very label is built from the member project names. So the list omits the arm for any scoped caller, and the item routes require an ACTIVE INTERNAL membership in the set's organisation on top of full projects.read (plus full tasks.read for the board and burndown, and tasks.update.any for the PATCH). Every denial — unknown set, non-member, guest, missing permission, malformed id — returns the byte-identical 403, so the routes cannot be used to discover that a set exists. The detail page, task board and burndown are the PT-828 surfaces unchanged, including PT-283 redaction of members in projects the caller cannot read.

What it deliberately does not have. No project-set management surface of any kind. The MCP / pt CLI surface arrived in PT-946 — see The multi-project arm — and it deliberately never names, lists or edits a set: projectSetId is only ever echoed back — by the create response and by the org-wide list — and then passed straight back to the cycle ITEM routes. Nothing anywhere supplies it to a set-management surface, because there is none.

One parity gap, stated rather than implied. A multi-project cycle gets no retrospective on close. The retrospective is filed against an initiative, which a cycle resolves from its own initiativeId or from its project's — and this arm has neither, so the close logs skipped (no-initiative) and files nothing. That is the existing behaviour of any initiative-less cycle rather than something this arm introduces, and everything else (rollover, auto-close, burndown, velocity, the frozen close snapshot) works as it does on the other two arms. Giving this arm a retrospective means first deciding which initiative it belongs to when its projects disagree.

Deleting a project the set names. It is refused while the set still owns a planned or active cycle — removing the project would silently narrow that cycle's scope, and its one-active and sequence indexes are keyed on the set, separately from the remaining project's own cycles, so two "active" cycles could end up describing one effective scope. Archive the cycle first (DELETE /api/cycle-sets/{id}/cycles/{cycleId}) and the delete proceeds. A completed cycle never blocks: it cannot be re-activated and reports a frozen close snapshot, so narrowing its set changes nothing it measures.

Once the delete proceeds. The membership row cascades away and the set drops out of the reuse index — retired from reuse unless a later membership change happens to land it on a combination nothing else holds — while the cycles it already owns keep it as their owner, because those are history. The set is not merged into, or replaced by, whichever set now has the same membership.

Cross-project reads and redaction

An initiative cycle spans projects, and a caller who can read the initiative does not necessarily hold access to every member project. Rather than hide those members (which would make the board disagree with the cycle's own totals) or expose them, PT-828 redacts them:

  • Aggregation is viewer-independent. scope / done / remaining, the burndown series, and velocity are computed over all members, before any redaction. Two callers with different readable sets get identical figures. The residual is deliberate and documented: a viewer can infer the aggregate completion of members they cannot read — the alternative, viewer-dependent totals, would make the stats strip disagree with the frozen close snapshot.
  • Redaction is a response projection. A member in an unreadable project is reduced to the PT-283 sentinel shape — an id, the fixed title (cross-project dependency), an UNKNOWN status, and an isCrossProject marker — and nothing else. No task key (which would leak the project), no project label, no assignee, no lane, and no navigation target.
  • They render as one neutral group. Both views collect the redacted rows into a single Restricted group carrying no status, colour, lane or project, and the tasks section states how many there are. Redaction is applied regardless of lane, so the restricted count cannot be differenced against scope to recover which lane a hidden member sits in.
  • Status presentation is resolved per project, server-side. Because two projects may configure the same status name with different labels, colours and categories, the response embeds each readable row's own resolved statusLabel / statusColor / statusCategory rather than shipping a single status catalog the client would apply uniformly. Rows whose presentation is identical merge into one card; rows that only look alike do not.

The summary read model (GET /api/cycles and GET /api/projects/{id}/cycles?summary=1) returns each cycle enriched with progress (completed / scope tasks + points — see Progress is frozen once a cycle closes), a trend (the burndown remaining-work series), a pace verdict, and velocity (from the frozen close snapshot; null until the cycle completes, and also null when that close is approximate). Archived cycles are excluded.

The task board (…/tasks) returns the cycle's tasks grouped by status, with a derived "Awaiting review" group placed first when present (empty groups are omitted). That group is not a status value: a task is awaiting review when its status is approval-ready or it carries a non-empty PR link and is not yet done (the same review inputs as the flow-engine review queue; PR state is not queried — a merged/closed PR drops out once the task is done). Completion takes precedence over Awaiting review, though: a completed status is Done, including a completed QA (QA is always treated as approval-ready), so it is not surfaced as Awaiting review — this is the mutually-exclusive board rule (PT-676), which differs from the flow queue's non-exclusive membership only in that edge. It is never double-counted — a qualifying task appears only in the awaiting-review group.

Since PT-730 the same response also carries a flat tasks list: every task already classified into its four-lane lane by the single server-side classifier (classifyCycleTasks), so the List groups and the Board's lanes are computed once and can never diverge. Cancelled tasks are carried with lane: null and are excluded from both views, but still counted in scope — the exclusion is presentation-only, so a completed cycle's frozen figures are untouched. Each row also carries its own projectId, so the classifier resolves each task against its own project's status catalog — unchanged for a single-project cycle, and what makes the cross-project initiative board correct.

On PATCH, a state transition and descriptive fields are mutually exclusive in a single call. Archiving a cycle writes a REMOVED event per member task before clearing its cycleId, so no task is stranded on a soft-deleted cycle.

Which cycles may a task join

Membership is decided by three rules, one per scope arm, and until PT-948 they existed only on the write path. A picker built without a matching read offers options the validator refuses, and each wrong choice is a 400 the user could not have predicted — so GET /api/projects/{id}/tasks/{taskId}/eligible-cycles answers the question directly, in the same slim cycle-option shape (id / name / owner / projectName) that ?options=1 returns elsewhere.

The three rules:

  • project — the task must sit in the cycle's own project;
  • project set — the task's project must be named by the set;
  • initiative — an epic in the task's chain must be tagged to that initiative: the task itself when it is a tagged EPIC, its epicId, or its parent's epicId (a subtask inherits its parent's epic). That last hop is why a client cannot compute this list for itself.

Two properties make the read trustworthy:

  • It agrees with the write by construction, on both halves of the scope freeze. The rules are not restated for the read — one shared predicate decides both, so a rule change lands in one place. On the target side, archived and completed cycles are excluded: a completed cycle is absent even when the task is already in it, because this answers "may join" and re-joining a frozen cycle is what the freeze forbids. On the source side, a task whose current cycle is completed gets the empty list — it may not leave that cycle at all, so every option would be one the write rejects before it even looks at the target.
  • It may under-offer, never over-offer. The candidate set is narrowed by what the caller can see, never by the membership rule: the project arm covers the one project the route access-checked — and works cross-org, like every project-scoped read — while the initiative and project-set arms are admitted by the same gates GET /api/cycles applies. Those two enumerate an organisation rather than one verified project, so they additionally require it to be the caller's active organisation, an active membership of it, and some projects.read — the same floor requireCrossProjectListAccess puts in front of the collection: a cross-org collaborator sees only their project's own cycles until they switch, exactly as /api/cycles returns them an empty list — an active membership of the organisation for both, then full-scope initiatives.read and org-wide projects.read respectively. The membership check is explicit rather than inherited: verifyProjectAccess admits a project creator or active collaborator (properties of the resource), and permission resolution does not filter on membership status, so a deactivated member who kept a role assignment would otherwise reach the cross-project arms here while /api/cycles refuses them. (The two currently resolve membership through different lookups — tenant-keyed here, organisation-keyed in requireCrossProjectListAccess — so treat the subset relationship as the intent rather than a guarantee until that gate is aligned.) A caller whose visibility is narrower than their eligibility therefore sees fewer options — the safe direction for a picker, since an offered option that 400s is a bug while a withheld one is a permission boundary.

Assigning a cycle from the task detail

PT-949. The task-detail Properties panel carries a Cycle field, next to Story points. It is the first place a person can set membership directly: PATCH task { cycleId } was previously reachable only from the API, the MCP tools and the pt CLI, or — from the GUI — by confirming an assign_task_to_cycle action the coordinator proposed, which is an agent choosing the cycle and a human approving it rather than a human picking one.

Its options come from the per-task eligibility read above, never from the org-wide cycle list, which is what makes the picker honest: every option it offers was eligible as of that read, and the initiative arm's two-hop epic walk (which no client can compute) is resolved server-side. That is a statement about the read, not a promise that no write can fail — the PATCH re-validates, so a cycle completed or re-scoped between the fetch and the click is still rejected, and the rejection surfaces in the save toast like any other. It inherits that read's under-offer direction too — a caller whose visibility is narrower than their eligibility simply sees fewer options.

Four behaviours are worth knowing:

  • One write path. Set, change and clear are all the same PATCH { cycleId } an agent issues, so ENTERED / REMOVED provenance and the ESTIMATE_CHANGED behaviour are identical whichever surface made the change. The field adds no second path.
  • A closed cycle is read-only in both directions. When the task's current cycle is completed the row shows the cycle name and says so: the scope freeze rejects leaving such a cycle as firmly as joining one, so neither a move nor a clear is offered. The client guard is an affordance — the server rejection remains the boundary.
  • An empty option list is not the same as a frozen cycle. A task in an active cycle whose options come back empty (its cycle sits on a scope arm this caller cannot enumerate) keeps its No cycle clear option, because the server would accept that removal.
  • Nothing eligible says so. A task with no eligible cycle and no current cycle renders an explanatory line rather than an empty dropdown.

The field is shown read-only to any caller who lacks tasks.update.any or holds only a .own-scoped tasks.read — the latter because the eligibility read refuses a .own grant, so a picker would otherwise mount and 403 its own option fetch.

Dashboard endpoint

GET /api/cycles/current is not project-scoped. It returns the single "current" active cycle across every non-archived project (COMPLETED / CANCELLED projects are excluded) the caller can read in the active org — the nearest checkpoint (soonest endDate; undated cycles rank last; ties broken by most-recent startDate, then cycle id ascending), since a cycle is one-active-per-project — together with its burndown, or { "data": null } when no readable project has an eligible active cycle. Eligibility excludes cycles whose UTC end day is already past (PT-846): because the ranking is by soonest endDate, one elapsed cycle would otherwise sort ahead of every genuinely current cycle and stay pinned to the widget until something retired it. So { "data": null } is now also the answer when readable active cycles exist but every one of them has elapsed — undated cycles stay eligible, having no end day to have elapsed. The eligibility rule is re-checked after the burndown read, so a cycle whose end day elapses mid-request is skipped rather than returned. It powers the dashboard Current cycle widget (PT-677) and is gated on full tasks.read and projects.read | projects.read.own plus an active membership, rather than the per-project verifyProjectAccess check the routes above use. Unlike the Task Velocity widget it does not accept tasks.read.own: the burndown is a cycle-wide aggregate with no per-assignee dimension to scope to, so a .own-only reader is rejected — like the project-scoped /burndown route, which also rejects a bare tasks.read.own grant. A GUEST / projects.read.own caller is restricted to collaborator/creator projects.

Creating and editing a cycle

One modal serves both, on all three scope arms (PT-679 for the project arm; PT-929 added the initiative arm; PT-938 the multi-project one). Every request the two OWNER arms issue is derived from the cycle's owner{ kind, id } — so the project arm writes to /api/projects/{id}/cycles… and the initiative arm to /api/initiatives/{id}/cycles…, with an identical body: scope comes from the URL, never the payload, so a caller cannot smuggle a second scope past cycles_scope_check. The multi-project arm is the exception the rule explains — its owner is derived server-side and may not exist yet, so its create posts projectIds to /api/cycles and its edits address /api/cycle-sets/{id}/cycles/{cycleId}.

Where it opens from

  • All cycles (/cycles) — New cycle creates on any of the three arms; the row pencil edits any cycle the list shows, project-, initiative- or project-set-scoped.
  • Project cycles (/projects/{id}/cycles) — New cycle is pinned to that project, so no scope control renders at all.
  • + Create → Cycle (PT-929) — the header create menu and the search command mode (type >, then cycle) share one registry entry, so the modal is reachable from any page. It pins no owner, even on a project route: choosing the scope is the point of a global entry, and pinning would remove that choice.
  • The cycle detail page's Edit cycle action (PT-936) — edit only, on every arm and in every cycle state. It is the one entry point that seeds the modal from a cycle it holds by id rather than by name, which is why its read-only Scope line names the kind alone (Project / Initiative / Projects) where the other entry points also name the owner.

Scope is chosen once, at creation. In create mode a Project/s / Initiative segmented control selects the arm and the matching picker below it. There is no third button: the project side is a multi-select, and which arm the create lands on follows from the count — one project gives an ordinary project-scoped cycle, several give a multi-project one (2–50, distinct). In edit mode there is no control — the scope renders as a read-only line naming the owner (or, from the detail page, the kind). That is a correctness constraint, not a styling one: enforce_cycle_scope_tenant rejects re-pointing either arm, and the update schema is strict with no owner field, so an offered switch could only ever produce a 400.

What each picker offers

  • Project/s — every non-archived project the caller can read, as a multi-select (COMPLETED / CANCELLED are filtered out, because /api/cycles hides their cycles and a cycle created there would vanish from the refetched list). HELPDESK projects are included, since their cycles do appear in the list.
  • Initiative — every initiative from GET /api/initiatives, which is initiatives.read-gated and active-org-scoped. Deliberately no archived-owner filter: unlike the project arm, the /api/cycles initiative arm applies no such exclusion, so a cycle created under any listed initiative does reappear.

Permissions mirror the routes on every arm: tasks.create to create, tasks.update.any to edit, matched at full scope. A .own grant is deliberately not enough — the routes call requirePermission with no owner context, where the platform matcher fails closed on .own, so accepting it would open a modal that 403s on every submit. The project arm evaluates against the project's own org, the initiative arm against the initiative's own org (withInitiativePermission). The multi-project arm is gated harder than either — tasks.create plus org-wide projects.read plus an ACTIVE INTERNAL membership — because every read of that arm requires the same, and a caller who could create one without them would never see it again.

Failure handling. A picker with nothing chosen reports it as a field-level message on the control itself, announced to assistive tech through the field's own error association rather than as a generic dialog banner. If the active organisation changes while the modal is open it closes, and a selection the refreshed list no longer offers is reported and blocks the submit — so a cycle cannot be created in the organisation the user just left.

Cycle detail page

…/projects/{id}/cycles/{cycleId} — and, since PT-828, …/initiatives/{id}/cycles/{cycleId}, joined by …/cycle-sets/{setId}/cycles/{cycleId} for a multi-project cycle (PT-938) — is the GUI over the reads above: a single cycle's status board and progress at a glance. It is reached by clicking a row in either cycles list (All cycles or Project cycles). One page component serves all three arms, deriving its three fetch URLs and every one of its mutation targets — Edit, and the Activate / Close lifecycle transitions — from the cycle's owner; the initiative and project-set arms additionally require the caller's active org to be the owner's own org and redirect to the org switcher otherwise, rather than rendering a cross-org page. That requirement holds for as long as the page is open, not only when it is rendered: switching org does not unmount the page (the switcher refreshes while preserving client state, and a switch made in another tab arrives only via the cookie), and those cycle routes resolve access under the owner's own org — so a page left mounted could otherwise close a cycle in the org you have just left. Both arms watch all three switch signals (via ActiveOrgGuard) and navigate away as soon as one reports a different org. The project arm is deliberately exempt: it supports cross-org collaborators and never requires its project's org to be the active one.

  • Header — a wave-aware title (Wave N · Name for the custom cadence, the plain name for calendar cadences), the state badge, goal, date range, a Day X of Y line (active cycles only), and a days-remaining segment for any non-completed cycle that has a usable date window (both bounds present and well-ordered) whose start date has arrived. That segment is rendered through the same formatter as the dashboard Current cycle widget, so the end day itself reads Ends today and an elapsed cycle reads N days overdue rather than the ambiguous 0 days left (PT-843). A cycle that has not started yet omits the segment: before the window opens the underlying count measures the window's own length rather than the wait until it ends, so the date range is what conveys the schedule. A half-dated cycle (only a start, or only an end) omits it too and shows just that one date — unlike the dashboard widget, which needs only endDate and so still prints a full label for those shapes: No end date for a start-only cycle, and an Ends today / N days left / N days overdue label for an end-only one. There is deliberately no in-page back link (PT-936): the intended journey is a cycles list row → this page, so browser / OS back is the way out. Direct entry (a bookmark, a pasted link, open-in-new-tab) therefore has no in-app parent affordance — an accepted trade-off, with the global nav's Cycles entry still reaching the cross-project list (the project-scoped list is reached through a project's own Cycles area). The header's action row is gated as a whole on tasks.update.any — the permission the underlying routes enforce — and holds an Edit cycle action in every cycle state plus exactly one lifecycle action, keyed to the one state it can transition from: Activate cycle for a planned cycle (confirm → PATCH { state: active }, PT-947) and Close cycle for an active one (confirm → PATCH { state: completed }). A completed cycle offers neither — the lifecycle is forward-only and no control anywhere proposes a reversal — but keeps its Edit action, so the row is never empty. Both transitions confirm first, because both are one-way: activation stamps the burndown's day-0 baseline at the moment it fires, and closing freezes the velocity measurement. Both PATCH the same owner-derived cycle URL the page's own reads use, so all three scope arms — including a multi-project cycle at /api/cycle-sets/{setId}/cycles/{cycleId} — are addressed without a per-arm branch. Activation's two distinct 409s are reported as the server states them rather than as a generic failure: another cycle is already active in this scope (go close that one), and only a planned cycle can be activated (the page was behind — it re-reads the cycle so the badge catches up). Edit opens the same modal described under Creating and editing a cycle, seeded from the cycle on screen; scope stays immutable there, and because this page knows the cycle's owner by id rather than by name, the modal's read-only Scope line names the kind (Project / Initiative / Projects) without the owner's name it shows when opened from a list row. Saving refetches the cycle and the burndown — an edited window moves the ideal line and therefore the pace verdict — but not the task board, since a descriptive update cannot change cycle membership. Note that this applies to a completed cycle too: closing freezes the close snapshot (velocity and the scope / done counters), but the burndown chart is re-derived from the cycle's stored window on every read, so editing a completed cycle's dates redraws its historical chart. What closing makes immutable is the measurement, not the window it was measured over.
  • Stats strip — scope · done · remaining · days-left · pace, with the pace value in the semantic pace colour (behind → warn, on-track/ahead → ok). Once the cycle is past its end date the days tile flips its label to Days overdue and shows a positive magnitude, rather than a bare negative number under a Days left label (PT-843). A completed cycle shows there, since a cycle closed before its scheduled end has no meaningful days-left figure.
  • Burndown — the embedded burndown chart (ideal + actual, coverage, pace) from the …/burndown read.
  • Tasks — Board / List (PT-730, reordered in PT-828) — a per-page toggle (default Board) switches between a four-lane Board (Todo / In progress / Awaiting review / Done) and the status-grouped List (the derived "Awaiting review" group first and emphasised; a task carrying a linked PR shows a PR badge). Both views are driven by one server-side classifier, so they never disagree on a task's lane, and both share the tasks page's own column chrome and project chip rather than a lookalike. Clicking a row or a card opens the same task drawer the tasks page opens, in place — it does not navigate away. The board is read-only: unlike the tasks page there is no drag-and-drop, because a cycle lane is a server-derived classification (Awaiting review has no status behind it at all) rather than an editable status column. Cancelled tasks are excluded from both views — a presentation-only exclusion: the cycle's scope and every metric still count them, so a cancelled member disappears from the board while scope still rises with it. The stats strip above stays authoritative and identical on both sides of the toggle (the Board's own progress strip is suppressed inside the page). On a cross-project cycle each row additionally carries a project chip, and members in unreadable projects appear as one neutral Restricted group (see Cross-project reads and redaction).

MCP tools & pt CLI

The cycle surface is exposed through the constellation MCP server and mirrored in the pt CLI, one command per tool (hyphenated command names). Each command takes exactly ONE of its own scope arms — two, or none, is a clear error naming the arms that collided. A cycle is scoped to a project, an initiative or a set of projects, and the tools mirror that everywhere except list_cycles, whose third arm is not a scope but the org-wide discovery mode allScopes / --all-scopes (a project set has no cycle collection to address). On the CLI the project arm is a <project> positional for list-cycles / update-cycle / delete-cycle but the --project <hint> flag for create-cycle (which takes no positional); the initiative arm is --initiative <id-or-name> on every command, and on update-cycle / delete-cycle it is <cycleId> --initiative <id> (the flag replaces the <project> positional). Initiative scope (PT-774) pairs with the initiative-scoped REST endpoints in PT-794; the multi-project arm (PT-946) is described below.

MCP toolCLI commandNotes
list_cyclespt list-cycles--include-archived to include soft-deleted cycles; --all-scopes for the org-wide read
create_cyclept create-cycle--sequence required on the project / initiative arms (unique per scope), optional on --projects
update_cyclept update-cycle--state active|completed to transition; else descriptive
delete_cyclept delete-cycleArchives the cycle

Activation is reachable from every first-party surface (PT-947). The REST PATCH routes above accept { "state": "active" } on each arm; the MCP update_cycle tool and pt update-cycle --state active drive it on all three arms since PT-946; and the detail page's header holds an Activate cycle action for a planned cycle. It stays a distinct capability rather than a side effect: Edit cycle (PT-936) is offered in every state but PATCHes descriptive fields only, deliberately omitting state, so renaming a cycle can never start it. A cycle nobody activates from any of those surfaces stays planned indefinitely, never taking its day-0 activation snapshot and never becoming eligible for auto-close or rollover.

The multi-project arm (PT-946)

The third scope arm is spelled differently on each of the three surfaces, because they name different things: a project selection on create (the owning set is derived server-side and may not exist yet), a discovery mode on list (the org-wide read is not per-owner at all), and the set's id on the item path.

SurfaceMCPCLI
createprojectIds (2–50)--projects <hint,hint,…>
listallScopes: true--all-scopes
update, deleteprojectSetId--project-set <uuid>

Both surfaces accept UUIDs or project name/prefix hints in a selection, and reject every malformed one before the CREATE request is sent. The size and blank-entry checks run before any lookup at all; duplicate detection necessarily runs after resolution — compared case-insensitively, because Postgres compares uuids by value and two different hints (a name and its UUID) can name one project. sequence is optional on this arm alone: with no owning set yet there is no scope whose highest sequence a caller could have read, so the server allocates the next free value. Everywhere else it is still required, until PT-951 lifts that.

There are exactly two places you can DISCOVER a projectSetId without already holding one — the create_cycle response (the CLI prints a ready-to-paste --project-set <uuid>) and the org-wide list, whose rows carry each cycle's owner together with the argument that addresses it. The set-scoped item GET returns it too, but only to a caller who already had it, so it discovers nothing. There is no GET /api/cycle-sets/{id}/cycles, so that list is also the only read that LISTS a set's cycles (the item GET shows one, to a caller who already had its id); --include-archived is therefore rejected alongside it rather than silently ignored, since that endpoint never returns archived cycles.

Cycle names, cycle goals and scope-owner names are tenant-authored free text, so both surfaces single-line-encode them before rendering and fence the rendered list with an untrusted-content note — encoding makes them safe to render, not safe to obey. The org-wide arm is what makes this load-bearing rather than theoretical — it renders names from every scope in the organisation, including a project-set label built from other people's project names, into the context of an agent that may hold more authority than whoever typed them. pt list-cycles --all-scopes --json is accordingly a fenced, sanitised projection{ warning, coverage, cycles }, where warning is the untrusted-content note and coverage states what the view excludes — rather than the raw response: fields the surface does not model are dropped, so nothing reaches an agent unsanitised. The per-scope --json keeps its existing bare-array shape and is not yet sanitised — a known gap tracked by PT-952. The org-wide response is parsed with Zod at the boundary, so a drifted row is a legible error rather than a crash inside a formatter.

A 409 from a state transition is restated on all three arms to name the scope it was issued for and the read that lists that scope's cycles. It deliberately does not diagnose which 409 it was — the one-active index and the forward-only guard both raise it, and the server's own message says which — so that read is offered for diagnosis generally, not as a promise that a competing active cycle exists (activating an already-completed cycle trips the forward-only guard with nothing else active).

Recurrence (PT-830)

create_cycle / update_cycle accept an isRecurring boolean, mirrored on the CLI as --recurring true|false (any other value is rejected before the create/update mutation is sent — a name-based --project / --initiative hint is still resolved over HTTP first). It is stored configuration, not behaviour: the rollover engine (PT-831) is what materialises a next-in-sequence successor and carries over the work still unfinished when it runs, so the flag is inert in a deployment where that engine is not present.

Both per-scope read surfaces mark recurrence in their human-readable rows — [recurring] when the flag is set, [↩ from <shortId>] when the server recorded the cycle as a successor of another (sourceCycleId), and [recurring, ↩ from <shortId>] when both apply. The unformatted isRecurring / sourceCycleId fields are in the CLI's per-scope --json output; MCP list_cycles returns the formatted summary only. The org-wide arm (allScopes / --all-scopes) shows neither marker and carries neither field: its read model is the list summary, which does not include them, and the sanitised --json projection strips whatever it does not model. Read recurrence from the owning scope's own list.

Task membership and points are set on the task tools, not the cycle tools:

  • create_task / update_task (MCP) and pt create-task / pt update-task (--cycle-id, --estimate-points) accept cycleId and estimatePoints; on update, null (MCP) or "" (CLI) clears either field.
  • list_tasks (MCP) and pt list-tasks (--cycle-id) filter tasks by cycle.
  • estimatePoints is included in the CSV and Excel task exports and parsed by the Excel importer (task import is Excel-only).
  • In the GUI (PT-613), estimatePoints is editable as a first-class Story points field on the task create modal and inline in the task-detail Properties panel (the inline editor is manufacturer-gated, like the sibling Properties fields), and is shown read-only as the point badge on board (and cycle-board) cards. Leaving the field blank means "unestimated" (null — the task still counts toward the task line but is excluded from the points line and from the coverage numerator); a 0 is a real zero estimate that counts as estimated and contributes 0 points, consistent with the API and the burndown. The GUI rejects anything outside the NUMERIC(6,2) contract (max 9999.99, at most 2 decimals) before it reaches the API. Note the board card's badge is only rendered for points > 0 (a PT-627 display choice), so a 0-point task shows no badge even though it counts as estimated.