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, a set of projects, or the whole organisation — exactly one, DB-enforced by a num_nonnulls(projectId, initiativeId, projectSetId) + isOrganisationScope = 1 constraint (migration 070 for the first two, PT-774; widened to three arms by migration 080, PT-938; the organisation arm by migration 093, PT-1104). Which arms an organisation may create is set by its cycle mode: by default an organisation runs one organisation-wide cycle track. 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, a project set, or the organisation itself (projectId XOR initiativeId XOR projectSetId XOR isOrganisationScope). 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_cycletool andpt create-cyclestill requiresequenceon the project and initiative arms (it is optional on the multi-projectprojectIds/--projectsarm, whose owning set may not exist yet; PT-951 lifts the rest) — see MCP tools &ptCLI below - state —
planned→active→completed. A cycle is bornplanned; it never reverts. - goal — optional theme / objective, stored as HTML (PT-1105): the create/edit modal authors it in the shared rich-text editor, and a markdown goal sent through REST, MCP or the CLI is converted at the write boundary exactly like a task or issue description. Goals stored as raw text before that — plain, or markdown an agent sent — are rendered as markdown on read (PT-1138): blank lines still split paragraphs and a single newline is still a line break, while raw HTML in such a goal is shown as escaped text, never as markup, an image is shown as a link to its source rather than loaded, and a link with a scheme other than
http(s)keeps only its text. Saving the goal from the edit modal stores it as HTML. - cadenceType —
weekly|biweekly|monthly|custom(defaultcustom) - isRecurring — when
true, closing this cycle auto-materialises its next-in-sequence successor (see Auto-recurrence below); defaultfalse - startDate / endDate — optional ISO-8601 datetimes; anchor the ideal burndown line
- targetPoints — optional, and the only one of the three that is enforced. It is a soft cap: a write that would take the cycle past it is refused with a
CYCLE_CAPACITY_EXCEEDED409, and goes through only when the caller supplies a written reason. See Capacity admission below - targetMergedTasks — optional delivery target: what the cycle aims to merge. Advisory — nothing gates on it, and the UI deliberately never renders it as a ratio against the number of tasks scoped
- reviewHoursBudget — optional, advisory, and currently unread: nothing in the schema measures committed review hours
- 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 theDELETEendpoints below is a soft delete — it setsdeletedAtand leavessourceCycleIdstill 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). Since PT-969 the brain is given a real cycle roster and proposes from it — see What the coordinator sees when planning.
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 MCPupdate_cycletool /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
cycleIdfirst — 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 aREMOVEDprovenance 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. - Concurrent assignment and carrier changes are serialised (PT-983). Joining an initiative-scoped cycle — from the task detail, the bulk "Add to cycle" action, or the automatic attachment on start — now locks the task's epic chain (its epic, its parent, and the parent's epic) for the rest of the write, and re-checks eligibility under that lock. So an assignment can no longer race a concurrent re-tag of that chain and leave the task in a cycle it no longer qualifies for. Whichever write lands second sees the other: an assignment that arrives after the re-tag is refused with the usual "cannot join this cycle" 400 (
CYCLE_INELIGIBLEon the bulk path), and a re-tag that arrives after the assignment detaches the task with aREMOVEDevent, as above. Moving a task out of a cycle that is being completed at the same moment is refused with the freeze's 400 instead of shrinking the scope the completion has just frozen. A few rare simultaneous combinations can still end in a database deadlock, which surfaces as an error the caller can retry rather than as a stranded membership (PT-1124).
- 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
- 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:
| Cycle | Cycles list row | Cycle detail stats strip |
|---|---|---|
planned / active | a 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 close | the frozen close figures | the frozen close figures |
completed, approximate close | bar + 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 zero | an 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.
Reading the retrospective from the cycle (PT-1106)
A completed cycle's detail page has a Retrospective button in its header that opens the retrospective in a pop-up dialog, without leaving Project Tracker. The content is not stored or copied in PT: the retrospective's canonical copy is its knowledge-base wiki page, which keeps feeding the KB unchanged. What PT stores is where it went — four columns on the cycle (migration 094): retrospective_status (pending → filed | failed), the retrospective_consult_id that names the page (synthesis-consult-<consult id>), the retrospective_kb_space_slug it was filed into (so moving COORDINATOR_KB_SPACE_SLUG later does not lose it; a retry that finds the same retrospective keeps the space already recorded), and retrospective_updated_at. Both close paths mark the cycle pending inside the close transaction when a retrospective is due (the same two gates as above), and the filing settles it: filed with the consult id when a retrospective exists, failed otherwise — including an auto-close whose job could not be enqueued. failed never overwrites filed, and a later successful retry turns failed into filed. All of it is bookkeeping in the same best-effort sense as the filing: it can neither fail nor roll back a close.
Reading the panel asks the wiki for that page with the viewer's own credentials, so the wiki's tenancy and classification rules decide whether it is readable. The panel always says which case it is in, never shows an empty body:
| Status | Meaning |
|---|---|
ready | The page, rendered, with an Open in wiki link to the canonical copy — shown only when the viewer's active organisation is the cycle's, since the wiki opens pages in the active organisation (a cross-org viewer gets a sentence saying to switch instead) |
generating | Due and not yet settled |
failed | The last attempt failed — a queued retry may still file it |
not_filed | A retrospective exists but the wiki has no page the viewer can read: the KB declined it as a near-duplicate, the initiative is classified (no KB page by design), the tenant has no KB space, the page is above the viewer's clearance, or it is still being filed |
wiki_unavailable | The wiki could not be reached, or returned a page Project Tracker cannot render |
none | Nothing recorded. Carries a reason naming the rule for the cycle's scope, not a proven cause: multi_project_cycle (a multi-project cycle is due one only when every project in its set belongs to the same initiative at close — PT-1112), organisation_cycle (no initiative, so none is due) or not_recorded (a project or initiative cycle, due one when it resolves an initiative at close; one closed before PT-1106 has no pointer until it is backfilled, PT-1113) |
Backfilling cycles closed before the pointer existed (operator)
A cycle completed before migration 094 that did file a retrospective records no pointer, so it reads none. A one-off, operator-run script (PT-1113) finds each one's retrospective consult — through findCycleRetrospectiveConsultIds in @constellation-platform/coordinator, since PT may not read coordinator.* itself — and records it as filed. It writes only a completed, not soft-deleted cycle whose retrospective_status is still NULL; pending and failed belong to the live job drain. The recorded KB space is the one the live filing records, so run it with the deployment's COORDINATOR_KB_SPACE_SLUG (unset means knowledge-base) — the dry run prints it.
# 1. DRY RUN (the default) — lists each backfillable cycle and the consult it will point at.
DIRECT_URL='…' npm run db:backfill-retrospective-pointers:dry-run -w @constellation/project-tracker -- --tenant <uuid>
# 2. APPLY — records the pointers, then re-reads each tenant and exits 1 if any remain.
DIRECT_URL='…' CONFIRM_APPLY=1 npm run db:backfill-retrospective-pointers -w @constellation/project-tracker -- --tenant <uuid>
Tenants are explicit --tenant arguments (repeatable), never discovered, and every tenant read — the dry run's included — records auditCritical entries (cycle_retrospective_pointer_backfill.started, then .scanned / .applied / .failed, and .verified on an apply). A second apply changes no cycle (it still records its audit entries). It needs DIRECT_URL and refuses to run under a role that cannot bypass RLS: consults are hidden from the runtime role, under which every cycle would read as having no retrospective.
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 asclosedApproximate(copying the end-date daily row, velocity withheld) or asclosedWithoutSnapshot(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 at0 * * * *; rollover only considerscompletedcycles. Closing at minute 55 — five minutes before the hour, not after it — means a recurring cycle closed by the cron is alreadycompletedwhen 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-
activerow 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-completedcycle 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 thehistoricalcount 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.customis different: it takes its length from the source window, so it needs BOTH bounds — acustomsource 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 (
completedAtis null) at the time the cron runs onto the successor, writingREMOVED(old) +ENTERED(new) membership events with aSYSTEMsource. Completed members stay on the closed cycle, so its frozen close snapshot and velocity are never disturbed. - The successor is born
plannedand inheritsisRecurring+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_idback-pointer plusON CONFLICT DO NOTHINGmeans 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.
The story-point scale (PT-967)
estimatePoints is not a free-form number. Each project carries a storyPointScale — a short list of whole numbers, defaulting to 1, 2, 3, 5, 7, 11 — and a value that is not on it is rejected with a 400 naming the allowed values. The coarseness is the feature: a scale that can express the difference between a 4 and a 5 buys nothing but arguments about it.
Three values are accepted whatever the scale says:
null— the only "unestimated" state.0— a real zero estimate the burndown counts toward coverage, not a synonym for unestimated. The API accepts it from any client, but the picker offers it only on a task that already holds one: a scale is a list of positive integers, so0is never on one. From the GUI an estimate of0can be kept, not newly set.- the value already stored on the task — so an estimate recorded before the scale existed can be re-submitted unchanged and does not block a client patching unrelated fields. A different off-scale value is still rejected.
Nothing is ever rewritten. Changing a project's scale does not migrate existing estimates; a task holding an off-scale value keeps it, keeps rendering it, and the task-detail picker offers it as its current value so opening the control cannot silently destroy it.
Where it is enforced. Server-side only, in the task service (covering the REST routes, the task tools, and the bulk-update path) and in the Excel importer, which writes its own rows. The MCP tools and the pt CLI keep their local shape checks and relay the server's message unchanged inside their own wrapper — the scale is per-project, so a client would have to fetch project settings before every write to check it locally, and would then hold a second copy that goes stale.
Import rejects; it never snaps. An off-scale cell is reported as an ordinary per-row error naming the row and the Estimated Points column, and the import does not persist while any row error stands. Rounding to the nearest scale value would replace an estimate a person wrote with a different one the system chose, silently and in bulk. In create mode — where the project is created by the import itself — the default scale applies.
Above the top of the scale means undecomposed. Split the ticket into children under an epic rather than inflating the estimate; an oversized ticket promoted up the hierarchy inflates the top until it means nothing.
Configure it under Project settings → Estimation, or PATCH /api/projects/{id} with storyPointScale. The list must be non-empty, strictly increasing, at most 12 whole numbers between 1 and 9999. An empty array is rejected rather than read as "enforcement off" — a project always has a scale.
Requiring an estimate for cycle work (PT-1068)
A project can require that tasks committed to cycle work carry a real estimate. The switch is requiresStoryPointsForCycles on the project, off by default. Turn it on under Project settings → Estimation → Require story points for cycle work, or with PATCH /api/projects/{id} and { "requiresStoryPointsForCycles": true }. The task-detail read (GET /api/projects/{id}/tasks/{taskId}) returns it under project.
It can only be turned on when the project is ready (PT-1083). Enabling runs a check, in the same transaction, over the project's tasks already in planned or active cycles. If any has no estimate, 0, or a value off the scale, the request is refused with a 409 CYCLE_ESTIMATE_POLICY_NOT_READY and nothing changes. Its details carry offendingCount, sampleTaskIds (up to 20; truncated says whether there are more) and the scale. The message names the count only. The same check guards the scale while the switch is on: removing a value that a task in a planned or active cycle still uses is refused the same way (reason: "SCALE_NARROWING"). Adding values, removing unused ones, and editing the scale with the switch off are never refused. A member that the rollover exception carried forward without an admissible estimate does not block a later scale edit either. Turning the switch off is never checked.
Once a project turns it on, a create or update that places one of its tasks in a planned or active cycle, or that changes the estimate of such a member, needs an estimate that is present, non-zero and on the project's scale. Otherwise the write is refused with a 400 CYCLE_ESTIMATE_REQUIRED that names the allowed values and, on an update, the task. Under the switch, null, 0 and a grandfathered off-scale value do not count. Everywhere else the three exceptions above still hold. Leaving a cycle is never refused, and neither is an edit that changes neither the cycle nor the estimate. Completed cycles are not affected. The switch that applies is always the one on the task's own project, so a multi-project cycle can mix governed and ungoverned tasks.
The eligibility read follows the same rule (PT-1080). A planned or active cycle the task could join, but for its estimate, is still returned, with a blocker of code ESTIMATE_REQUIRED that carries the allowed values and a readable message. The task-detail Cycle picker shows that cycle disabled with the reason rather than hiding it, and re-reads eligibility when the estimate changes, so setting one re-enables the cycle. The task's current cycle never carries a blocker, because staying put is not a commitment the write judges. With the switch off the read returns no blocker field at all. The bulk selection read POST /api/tasks/eligible-cycles does the same for a whole selection (PT-1091): a cycle the bulk write would refuse for any joining task, judged against that task's own project, is still returned with an ESTIMATE_REQUIRED blocker whose blockedTasks lists each such task with its current estimate and its own project's allowed values. In the bulk "Add to cycle" menu that cycle reads "Needs estimate" and stays pickable: picking it opens a dialog listing those tasks with an estimate picker each, and confirming saves the estimates and then adds the selection to the cycle. If a save fails, the estimates that did save are kept, nothing is added to the cycle, and the dialog stays open.
The bulk writers apply it to a whole selection at once (PT-1081). A bulk "Add to cycle" judges every task that is joining a planned or active cycle, each against its own project, and refuses the whole batch before writing anything if any of them lacks an admissible estimate. The refusal is a 400 whose details.reason is CYCLE_ESTIMATE_REQUIRED, in the same shape as the other bulk cycle rejections (ineligibleCount, selectedCount, ineligibleTaskIds), plus ineligibleTasks, which gives each task's key, current estimate and allowed values. A task already in the target cycle is not judged, and a task that fails the cycle's scope rule is reported as CYCLE_INELIGIBLE first. A bulk cross-project move that keeps a task's initiative or multi-project cycle membership treats that membership as a new commitment under the destination project's switch and scale, since the owning project changes; if the destination would refuse it, the move is refused whole with the same diagnostics. A membership the move clears is never judged. A coordinator assign_task_to_cycle runs through the ordinary task update, so it gets the single-task rule, judged on the estimate the action proposes.
The two automatic writers never refuse (PT-1082). When a task starts work and its own project requires an estimate it lacks (none, 0, or a value off the project's scale), the task is not attached, but the status change still succeeds. The PATCH, Kanban reorder and claim-next-ready responses then carry cycleAutoAttach: { "outcome": "estimate-required", "storyPointScale": [...] } inside data. The field is absent in every other case, and it names no cycle. The skip is also recorded in the audit log as cycle.auto-attach.estimate-required, with the cycle the task would have joined. A bulk status change runs the same rule per task; its response does not carry the field, so the audit entry is the record there. Recurring rollover carries every unfinished member into the successor as before, including members whose project would refuse them. That is the one sanctioned exception, and it is transaction-local and rollover-only. Its cycle.cron.rolled-over audit entry then adds estimatePolicyExceptionCount, a sample of up to 50 task ids and a truncation flag, only when there is at least one. An estimate edit on an unfinished member of a completed cycle waiting for rollover is not judged, because a completed cycle is not live; rollover records it instead.
The cycle page shows what the policy keeps out (PT-1072). A task skipped this way is in flight and in no cycle, so no member view can show it. The detail page of an active cycle therefore lists, under a warning that begins "N tasks are in progress outside this cycle", every task that is in an in-progress status, in no cycle, in a project that requires an estimate it lacks, and for which this cycle is the one automatic attachment would pick today. They appear in a table: Task, Project (when the tasks span projects), Assignee (when any task has one), Estimate and Points. The Estimate column names what blocks each task ("None", "0", or a value "off scale"), and the Points column offers the fix in place: pick points from the project's scale and the task gets that estimate and joins the cycle in one update, through the same checks as any other edit (a cycle over its points target asks for an override reason). You need permission to update tasks to see the picker. The task title still opens the task drawer. Setting the estimate from the drawer instead removes the row without adding the task, because attachment only happens on a status change, so add it from the drawer's Cycle field too. The list is derived from the tasks as they are now, so it also includes a task that started before the cycle was activated or before the switch was turned on, or that was taken out of the cycle by hand. It covers only projects you can read and says nothing about the others. The board read (GET …/cycles/{cycleId}/tasks, on all four scope arms) carries it as an optional startedOutside array, present only for an active cycle and only when non-empty. With the switch off everywhere in reach, the page and the payload are unchanged.
The database enforces it too (PT-1083). A trigger on tasks refuses the same writes when they bypass the application: a raw SQL update, or an Excel import's estimate change on a live member, which fails the whole import. The refusal is a foreign-key violation (SQLSTATE 23503) naming the constraint tasks_cycle_estimate_required; a Prisma model write surfaces it as a 409 FOREIGN_KEY_VIOLATION with that constraint. The one move it lets through is the rollover's: a task leaving a completed cycle for that cycle's planned successor, which no other application writer and no accidental raw write can do. A write that is judged while the switch or scale of its project is being changed is refused instead of waiting, naming tasks_cycle_estimate_policy_changing. It only happens during that change. A retry is judged against whatever the change committed, so an inadmissible write is then refused as tasks_cycle_estimate_required. This is what makes the readiness check exact for ordinary (READ COMMITTED) writes: nothing can join a live cycle of the project between the check and the moment the switch turns on. A raw write running at REPEATABLE READ or SERIALIZABLE reads the switch from its own snapshot and is outside that guarantee.
Every started task joins the cycle (PT-973)
Moving a task into an in_progress-category status attaches it to the resolved active cycle, automatically and silently. The one exception is a task whose own project requires an estimate for cycle work and that lacks an admissible estimate: it is not attached, and the status change still succeeds (PT-1082). There is no prompt and no confirmation, and no new control: the task detail still shows which cycle a task is in and still lets you change it. The attachment writes an ENTERED membership event with a SYSTEM source, in the same transaction as the status write.
This is the rule cycle tracking rests on. Without it a task can be started, worked, reviewed and merged without ever appearing in a cycle — and the cost is not that the work goes unmeasured, it is that it consumed the same reviewers and the same lanes as the planned work. Every capacity figure computed afterwards is then wrong in an unknown direction, which is worse than having no figures at all.
It never blocks the transition. No eligible cycle, an archived one, a resolution failure — the task still starts. The attachment runs inside its own savepoint, so if any part of it fails the transition commits anyway and only the attachment is discarded. This rule exists to observe work, not to gate it.
What counts as a start. A real status change whose destination resolves to the in_progress category — resolved from the project's own status rows, never from a hard-coded status name. Three consequences:
- A status re-sent unchanged (a whole-form save) is not a transition and attaches nothing.
- A move between two
in_progressstatuses — sayIN_PROGRESS → QA— does attach, when the task is still in no cycle. The rule is "this task is being worked and is in no cycle", so a later move repairs a task that was started before the rule applied. blocked,todo,doneandcancelleddestinations do not attach. Neither does creating a task directly in an in-progress status, nor a bulk move between projects, which normally preserves the status name and so carries no transition.
Which cycle, when several are eligible. A task can be eligible for one cycle of each kind at the same time — its own project's, a project-set cycle naming that project, and an initiative cycle reached through its epic chain. The choice is fixed and deterministic, never a prompt:
- the project cycle,
- then a project-set cycle,
- then an initiative cycle.
Most direct ownership first. Within one arm — a project named by two sets, or an epic chain reaching two initiatives — the tie-break is the same "nearest checkpoint" order the dashboard widget uses: soonest end date, then most recent start date, then cycle id.
Which cycles are candidates. Only active, non-archived ones — so a completed cycle is never attached to and the scope freeze is respected by not being reachable. An active cycle whose UTC end day has already passed is excluded too, for the same reason the dashboard widget excludes it: the auto-close cron that retires it can lag. An active cycle whose start date has not arrived is not excluded — nothing activates a cycle on a schedule, so that state is always a deliberate act rather than staleness. Undated cycles stay eligible throughout.
A task already in a cycle is never touched — no re-point, no second ENTERED event. The decision is made against the value read under the row lock, so two concurrent starts cannot both attach.
Telling the three provenances apart in the event log. ENTERED events now come from three places, and source alone does not separate them, because both automatic writers use SYSTEM:
| Provenance | source | reason |
|---|---|---|
| Planning-time membership (task detail, bulk patch, coordinator, import) | USER / AGENT / IMPORT | whatever the caller supplied |
| Automatic attachment on start | SYSTEM | begins auto-attach on work start |
| Rollover to a successor cycle | SYSTEM | auto-rollover on cycle recurrence |
The interruption count and the retrospective read that distinction.
Where it applies. Everywhere a status is written: the REST PATCH, MCP update_task, the pt CLI, the bulk patch, a kanban drag, and claim_next_ready_task — one implementation behind all six, rather than a copy per surface.
Composition with the cap below. An automatic attachment that takes the cycle over its targetPoints is recorded, not refused: it carries a system-supplied reason and its numeric overage, exactly as a human override would. An interruption forcing its way in is precisely the signal the capacity record exists to capture, so it is neither blocked nor admitted invisibly.
Capacity admission
targetPoints is a soft cap, and every breach of it is recorded.
A task write that would take a cycle past its targetPoints — joining the cycle, or raising the estimate of a task already in it — is refused with a 409 carrying code CYCLE_CAPACITY_EXCEEDED. The error's details name the overage, the committed and projected figures, and an ordered list of the cheapest cut candidates: members with a positive estimate, in a not-started status of their own project, blocking nothing — lowest priority and largest estimate first, so the suggested set is as short as possible. It is the shortest prefix that covers the overage, capped at ten, and it reports both what those candidates would free and any remainder they do not.
To proceed anyway, resend the identical request with a reason:
{
"cycleId": "…",
"estimatePoints": 8,
"cycleCapacityOverrideReason": "production incident, agreed with the PO"
}
The write then succeeds and the resulting membership event records the actor, the source, that reason, and the numeric overage. Proceeding without a reason is not possible: the database itself refuses a recorded overage that carries no reason.
Why soft. A hard block gets routed around invisibly by exactly the class of work most worth seeing — urgent unplanned work — and a control with no visible bypass gets switched off wholesale rather than skipped once. Making the breach cheap but permanently attributed turns what would have been an evasion into a measurable interruption-pressure signal.
Notes on scope:
- A cycle with no
targetPointsis not capped at all. - The cap applies to
plannedandactivecycles. Acompletedcycle already refuses joins outright (its membership is frozen). - Lowering an estimate, clearing it, or leaving a cycle are never gated — they free capacity.
- The bulk endpoint is gated, and can override. PT-950 landed
cycleIdin the bulk patch while the cap was in review, so a bulk "Add to cycle" can breach — the batch therefore carriescycleCapacityOverrideReasontoo, and its target-cycle lock was upgraded fromFOR SHAREtoFOR UPDATE. Under the share mode each task's admission was asking to UPGRADE a lock the batch already held, and two batches joining one cycle deadlocked (40P01) on two ordinary requests. Bulk estimate edits stay outside the patch, so a batch still touches exactly one cycle. - System paths are exempt: recurring-cycle rollover, archive detach, auto-close and the cross-project bulk move all move or detach existing members rather than admitting new work. A rollover can therefore produce a successor that is already over target; the committed-versus-target figures show that honestly, and it is not recorded as a discretionary over-capacity admission.
- Visibility is all-or-nothing, not per candidate. If the caller cannot read every project the cycle currently spans, the refusal still says which cycle refused it and that a reason gets it through, but withholds every figure — the committed and projected totals, the overage, and the candidate list entirely. Anything less leaks: a per-row redaction still exposes each hidden task's estimate through the totals it contributes to.
- The agent WIP cap is a different control and stays hard, with no override. Exceeding review capacity produces a queue; exceeding lane concurrency produces merge conflicts and wasted agent work with no upside.
The cycle detail header and the dashboard's current-cycle widget both show the commitment in the same words — for example Committed: 34 / 30 pts · 10 tasks scoped · target 9 merged · estimated 8 of 10. The points figure sums only the estimated members, which is why the coverage (estimated 8 of 10) always travels with it: an unestimated member contributes nothing to the total and is never counted as zero.
What the coordinator sees when planning (PT-969)
PT-797 gave the coordinator the ability to write cycle membership and nothing with which to decide. It could emit an assign_task_to_cycle action, but it was told in as many words that it had no cycle list — so the only usable cycle UUID was one a human had already pasted into the question. A planner proposing from no information produces plans that cost more to check than to write by hand, which at ~800 open tasks is the opposite of the point.
Every consult now carries a cycle-planning snapshot, loaded inside the same tenant-scoped transaction as the rest of its state:
- the cycles in scope, with state, dates, capacity targets, committed points and estimate coverage;
- the per-project agent WIP count against its configured cap;
- each project's story-point scale, so a proposed estimate uses the project's own vocabulary rather than a made-up number.
"In scope" is the consult's own portfolio, not the organisation's cycle list: the projects holding work tagged to the consult's initiative(s), through the same tagged-work graph the ready set already walks. Redaction follows the capacity-admission rules above rather than inventing its own — a cycle whose owning scope the caller cannot read is omitted entirely, and a visible cycle with any member in an unreadable project keeps its identity and targets but withholds its committed total, member count and coverage. Only project-scoped cycles are marked assignable, matching what the executor will actually accept.
Two properties are worth stating plainly, because they are what makes a machine-written plan checkable:
- A proposal that does not fit is never offered. Cycle suggestions are grouped by target cycle and the group's total admission delta is measured against the cycle's remaining capacity before the consult is persisted. A group that does not fit — or that names a cycle absent from the snapshot, a cycle that is not assignable, a candidate outside the ready set, or a candidate with no estimate and none proposed — is dropped whole, because a partially-kept group is a plan nobody authored. A cycle with no
targetPointsis exempt from the fit test only; every other rule still applies. This is not an authorization boundary: execution re-checks live capacity under the row lock, and that remains the gate. - A fabricated cycle id cannot reach a human. The prompt binds the model to the snapshot, and the server enforces it independently — prompt text is guidance, not a control. When no cycle is in scope the section still renders, says so explicitly, and the guard admits zero cycle assignments while leaving every unrelated suggestion untouched.
An assign_task_to_cycle proposal may also carry an optional estimatePoints, applied in the same ratified task update as the cycle edge — so estimating an unestimated candidate and admitting it are one act rather than two, and the cycle's committed total never passes through a state that understates what was approved. The estimate is validated against the project's configured scale on the write, by the same check every other write path uses.
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
countandpoints, so a client can toggle lines with no server round-trip - coverage — an
estimated N of Mindicator: tasks without anestimatePointsstill count toward the task line but are excluded from the points line - pace —
ahead|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.
Restated velocity (PT-1108)
A cycle whose tasks entered unestimated records a velocity close to zero, and estimating those tasks after the cycle closed does not change it: velocity is read from the frozen close snapshot, which holds aggregates only and is never rewritten. That is deliberate — the close row is the one measurement of the cycle's end, and retrospectives are filed from it — but it made past cycles impossible to compare once the team started estimating.
The summary read model therefore carries a second, separately labelled figure beside velocity: restatedVelocity, the work the cycle had completed at close, summed at the tasks' current estimates. It is derived when the list is read and written nowhere. The cycles list shows it under the recorded velocity as "Restated N pts".
- Which tasks count. Those whose last
ENTERED/REMOVEDmembership event for the cycle, at or before the close, isENTERED, and whosecompletedAtis at or before the close. Membership comes from the events, not the currentcycleId, so a completed task detached afterwards (by archive or a cross-project bulk move) still counts. - The close instant. A manual close's snapshot is stamped at the moment of closing. The auto-close cron stamps the cycle's
endDateinstead, which is usually the start of the window's last day. For those rows the close is taken as midnight UTC after that day, so work finished on the last day counts. - Coverage. A
0estimate counts as an estimate. A task that genuinely cannot be estimated can be recorded as such with the labelestimate:unestimable(with no estimate). It is reported inunestimableTaskCountand left out of the coverage denominator. Below four fifths coverage — the same floor the dashboard velocity uses — the points are withheld (completedPoints: null,pointsConfidence: "low") and the list shows the coverage instead. - Provenance.
postCloseEstimateTaskCountcounts the counted tasks whose estimate changed after the close, so a reader can see how much of the figure rests on estimates entered later. It isnullwhen a counted task has since left the cycle, because an estimate edit is recorded against the task's current cycle only. - Consistency.
consistentWithCloseis false when the rebuilt completed count differs from the close snapshot's. A task reopened or deleted since, or completed during a sweep's lag after midnight, makes them differ. The list marks such a figure "Approximate".
The figure reads present-day task data, so it is withheld for a caller who cannot read the project a counted task now sits in. On the project-scoped endpoint, only that project counts as readable.
restatedVelocity is null for planned and active cycles and for completed cycles without a usable, live close snapshot — including every approximate one, whose timestamp is when the backfill ran rather than when the cycle closed.
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_URL — DIRECT_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:
| Outcome | When | Effect |
|---|---|---|
closedApproximate | an end-date daily snapshot exists | its aggregates are copied into a close row marked approximate — burndown intact, velocity null |
closedWithoutSnapshot | no such daily row | closed with no close snapshot; the cycle id is listed under needsManualRepair |
closedWithExistingSnapshot | the cycle already holds a close row | that 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.
| Method | Path | Permission | Description |
|---|---|---|---|
GET | /api/cycles | full tasks.read + any projects.read | Cross-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}/cycles | tasks.read | List cycles (archived excluded unless ?includeArchived=true); ?summary=1 returns the enriched list read model |
POST | /api/projects/{id}/cycles | tasks.create | Create a cycle (born planned) |
GET | /api/projects/{id}/cycles/{cycleId} | tasks.read | Get a single cycle, with its scope block (below) |
PATCH | /api/projects/{id}/cycles/{cycleId} | tasks.update.any | Edit descriptive fields, or send { "state": "active" | "completed" } to transition |
DELETE | /api/projects/{id}/cycles/{cycleId} | tasks.delete.any | Soft-delete (archive) — detaches member tasks and frees the one-active / sequence slot |
GET | /api/projects/{id}/cycles/{cycleId}/burndown | tasks.read | Read the burndown (ideal + actual, coverage, pace) |
GET | /api/projects/{id}/cycles/{cycleId}/retrospective | tasks.read | The cycle's retrospective, rendered from its KB wiki page (PT-1106) — see Reading the retrospective from the cycle |
GET | /api/projects/{id}/cycles/{cycleId}/tasks | tasks.read | Read 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-cycles | tasks.read | The 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 |
POST | /api/tasks/eligible-cycles | tasks.update.any | The cycles a whole selection may join (PT-950) — the server-computed intersection over up to 100 task ids, plus a canClear flag and a frozen boolean saying whether an empty answer is empty BECAUSE a selected task sits in a completed cycle. POST because the selection is the input; it reads and mutates nothing (below) |
GET | /api/initiatives/{id}/cycles | full initiatives.read + initiative access | List 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}/cycles | tasks.create + initiative access | Create an initiative-scoped cycle (born planned) |
GET | /api/initiatives/{id}/cycles/{cycleId} | full initiatives.read + initiative access | Get a single initiative-scoped cycle. Hardened to FULL scope in PT-828 (A3b) |
PATCH | /api/initiatives/{id}/cycles/{cycleId} | tasks.update.any + initiative access | Edit descriptive fields, or { "state": … } to transition |
DELETE | /api/initiatives/{id}/cycles/{cycleId} | tasks.delete.any + initiative access | Soft-delete (archive) an initiative-scoped cycle |
GET | /api/initiatives/{id}/cycles/{cycleId}/burndown | full initiatives.read and full tasks.read + initiative access | Read 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}/retrospective | full initiatives.read and full tasks.read + initiative access | The cross-project cycle's retrospective (PT-1106), same gate as its burndown |
GET | /api/initiatives/{id}/cycles/{cycleId}/tasks | full initiatives.read and full tasks.read + initiative access | Read 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/cycles | tasks.create and full projects.read and INTERNAL member | Create 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 access | Get 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 access | Edit 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 access | Soft-delete (archive) a multi-project cycle — also how a project in the set is unblocked for deletion |
GET | /api/cycle-sets/{id}/cycles/{cycleId}/burndown | full projects.read and full tasks.read + set access | Read 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}/retrospective | full projects.read and full tasks.read + set access | The multi-project cycle's retrospective (PT-1106) — filed only when every project in the set shares one initiative at close (PT-1112) |
GET | /api/cycle-sets/{id}/cycles/{cycleId}/tasks | full projects.read and full tasks.read + set access | Read 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.
Organisation cycles and the cycle mode
An organisation cycle (PT-1104) is owned by the organisation itself (isOrganisationScope: true, every owner id null). It admits every task of the organisation, whatever its project — including projects created after the cycle, and archived ones. It is what a small team plans against: one cadence over all of its work.
The cycle mode decides which arms an organisation may create. It lives in projects.cycle_settings, one row per organisation:
| Mode | Creates | Default for |
|---|---|---|
single | organisation cycles only | every organisation that never chose — a new organisation runs one track |
multi | project, initiative and multi-project cycles; organisation cycles are refused | every organisation that already runs project, initiative or multi-project cycles, with or without a stored setting |
The mode is checked when a cycle is created, never retroactively: an existing cycle of the other kind can still be edited, activated, closed and archived. Switching mode (PUT /api/cycle-settings, org.settings.manage) is refused while a planned or active cycle of the kind the new mode forbids exists — the 409 says how many, and deliberately not which, since a settings admin need not be able to read them — so a mode never starts with two tracks running. A switch also clears isRecurring on the completed cycles of the forbidden kind, so their recurrence ends visibly rather than silently. The switch and its cycle.settings.updated audit entry commit together; re-sending the mode already stored changes and records nothing. GET /api/cycle-settings reads the mode of the organisation you are acting in; GET /api/projects/{id}/cycles?settings=1 reads the mode of that project's organisation, which is what the create dialog uses on a project page.
No overlap within a track. A dated cycle may not share a UTC calendar day with another planned or active cycle of the same track — the organisation track in single mode, the same project / initiative / project set in multi mode. Windows are inclusive days, so a cycle starting the day after another ends is fine (the rollover shape). A create or a date change that would overlap is refused with a 409 naming the existing cycle. An organisation cycle must have both dates. This is enforced by the service in this release, under a per-organisation lock; the database backstop follows in PT-1115.
Rollover. A recurring organisation cycle rolls over like the other arms. The sweep only rolls over a source the organisation's mode still allows, and if a planned or active cycle of the same track already covers the successor's window — someone created "the next cycle" by hand — it stops the chain instead (isRecurring cleared, audited as cycle.cron.recurrence-stopped).
Access is the multi-project arm's: acting in the organisation, an ACTIVE INTERNAL membership, and full-scope projects.read (plus tasks.read for the board and burndown). Create with POST /api/cycles and { "organisationScope": true, … }; read, edit, transition and archive at /api/organisation-cycles/{cycleId} (with /tasks, /burndown and /retrospective — PT-1106; an organisation cycle belongs to no initiative, so it reads none with reason organisation_cycle); the page is /organisation-cycles/{cycleId}. In the cross-scope list an organisation cycle's owner is { kind: "organisation", id: "<tenant-id>", name: "All projects" }. Auto-attach tries it last, after the more specific arms.
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), anUNKNOWNstatus, and anisCrossProjectmarker — 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
scopeto 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/statusCategoryrather 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), plus restatedVelocity for a completed cycle (see Restated velocity). 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, itsepicId, or its parent'sepicId(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
completedcycles 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 iscompletedgets 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/cyclesapplies. 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 someprojects.read— the same floorrequireCrossProjectListAccessputs in front of the collection: a cross-org collaborator sees only their project's own cycles until they switch, exactly as/api/cyclesreturns them an empty list — an active membership of the organisation for both, then full-scopeinitiatives.readand org-wideprojects.readrespectively. The membership check is explicit rather than inherited:verifyProjectAccessadmits 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/cyclesrefuses them. (The two currently resolve membership through different lookups — tenant-keyed here, organisation-keyed inrequireCrossProjectListAccess— 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.
Five behaviours are worth knowing:
- One write path. Set, change and clear are all the same
PATCH { cycleId }an agent issues, soENTERED/REMOVEDprovenance and theESTIMATE_CHANGEDbehaviour 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
completedthe 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.
- A cycle that needs an estimate is shown, not hidden. When the task's project requires an estimate for cycle work and the task lacks an admissible one, its planned and active cycles appear disabled, each with a line naming the allowed story-point values.
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.
Adding a selection to a cycle
PT-950. The tasks-page bulk bar (List and Board) carries an Add to cycle action, and POST /api/tasks/bulk-update accepts cycleId as a fifth patch field — null detaches.
cycleId is not a fifth field of the same kind as the other four. Those are properties of a task; membership is a relationship whose validity is decided per task against the cycle's scope arm. Three consequences shape the behaviour:
- All-or-nothing, with the offenders named. A preflight runs inside the batch transaction, after the row locks and before any write, and evaluates every selected task. A partially eligible selection is rejected whole — the response carries
details.reason(CYCLE_INELIGIBLE,CYCLE_FROZEN_JOINorCYCLE_FROZEN_LEAVE),ineligibleCount,selectedCountandineligibleTaskIds, so a caller that never saw the picker can fix its own selection instead of guessing. The two rejections about the TARGET carry a reason too —CYCLE_NOT_FOUNDandCYCLE_ARCHIVED— without counts, since no particular task is at fault.CYCLE_ARCHIVEDis exactly what a cycle archived between the read and the click produces, and it is permanent: refresh the options rather than retry. - Both freeze directions apply to the batch. A completed target rejects the join; a selected task already sitting in a completed cycle rejects the whole batch, because it cannot leave. A task already in the target cycle is an unchanged echo and does not count as a join — the service and the DB trigger both gate on "actually changing".
- Provenance is unchanged. The batch delegates each row to the same
updateTaskthe single-task PATCH uses, soENTERED/REMOVEDevents fire per task exactly as they always have — and every one of them, plus the per-task audit rows and the batch event, now carries the batch's own correlation id.
The menu is fed by POST /api/tasks/eligible-cycles, the intersection of what every selected task may join, so it does not offer a cycle the write would refuse as of that read. Eligibility is a fact about a moment: a cycle completed or re-scoped between the menu opening and the click is still rejected by the write, which revalidates under row locks and reports which tasks failed. A successful write is confirmed explicitly — every other bulk action changes a column the user is looking at, while this one writes a field no row renders, so silence would be indistinguishable from nothing having happened. The confirmation states the resulting STATE ("5 tasks now in this cycle") rather than a count of changes, because a selection may legitimately contain tasks already in the target — and a rejection names the offending tasks by key, capped at three. The action disables itself, with the reason shown, when the selection contains an issue (issues have no cycle), while the read is still loading, or when a selected task sits in a completed cycle (which blames the task, not the cycles — the two produce the same empty answer and only one is actionable), or when nothing fits the whole selection — never silently, and never by narrowing the selection behind the user's back. A failed read is the one unusable state that stays OPERABLE: it says "Cycles could not be loaded" and its menu offers a retry, because disabling it there would strand the selection until the user changed it or the page remounted, after a single transient error.
That read is gated at the write's bar (tasks.update.any), not the reading bar its per-task sibling uses: an affordance refused to a caller the write would accept is a menu permanently greyed out for someone allowed to use it. Callers without the org-wide grants get the initiative and project-set arms narrowed away rather than a 403.
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>, thencycle) 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. Since PT-1105 it seeds the owner's name from the item
GET'sscopeblock, so its read-only Scope line names the owner just as the other entry points do.
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/CANCELLEDare filtered out, because/api/cycleshides 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 isinitiatives.read-gated and active-org-scoped. Deliberately no archived-owner filter: unlike the project arm, the/api/cyclesinitiative 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.
The item GET carries the cycle's scope (PT-1105). On all three arms, GET …/cycles/{cycleId} adds data.scope = { kind, ownerName, projects }: the owner arm, its display name (the project, the initiative, or the set's name-built label), and the stored project scope — the owning project, or the set's member projects sorted by name. projects is null for an initiative-scoped cycle, whose membership follows the epic chain and has no stored project list. PATCH responses keep the bare cycle row.
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 · Namefor thecustomcadence, the plain name for calendar cadences), the state badge, the goal (rendered as rich text, PT-1105), date range, aDay X of Yline (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 readsEnds todayand an elapsed cycle readsN days overduerather than the ambiguous0 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 onlyendDateand so still prints a full label for those shapes:No end datefor a start-only cycle, and anEnds today/N days left/N days overduelabel 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 Edit and lifecycle actions sit together behind one gate ontasks.update.any— the permission the underlyingPATCHroutes enforce: 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 aplannedcycle (confirm →PATCH { state: active }, PT-947) and Close cycle for anactiveone (confirm →PATCH { state: completed }). Acompletedcycle 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. Beside that group, behind its own gate ontasks.delete.any(the permission theDELETEroutes enforce), sits Delete cycle (PT-1056), offered in every state: it confirms, sendsDELETEto the same owner-derived item URL (the soft delete described under API — member tasks are detached, not deleted), and on success replaces the page with the owner's cycles list — the project's own list for a project cycle, the cross-scope Cycles list for an initiative or multi-project one. A failed delete shows the server's message and re-reads the cycle, so a delete that committed despite a lost response lands on the page's not-found state rather than a stale header. 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 the modal's read-only Scope line names the owner, taken from the itemGET'sscopeblock (PT-1105). Saving refetches the cycle and the burndown — an edited window moves the ideal line and therefore the pace verdict — and the task board: a descriptive update cannot change cycle membership, but moving an active cycle's end date across today changes which tasks the page lists as in progress outside it. 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. -
Details panel (PT-1105) — part of the header, to the right of the goal and date line on wide screens (below them on narrow ones), and above the stats cards naming what kind of cycle this is and what it covers: Type (
Single project/Multi-project/Initiative), the Project(s) in scope as links, the Cadence, and the Dates (orNot scheduled). An initiative cycle also names its initiative, and lists its projects as derived from its tasks — the distinct projects of the rows the viewer can read, captioned as such — with a count of tasks in projects the viewer cannot see (never their names). With no tasks it saysNo tasks yet; if the task board cannot be read it says so rather than showing an empty list. -
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 leftlabel (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
…/burndownread. -
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 reviewhas 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'sscopeand every metric still count them, so a cancelled member disappears from the board whilescopestill 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). -
Assignees (PT-1013 / PT-1016) — one chip per person with work in the cycle, derived from the assignees of the tasks currently in it, with that person's task count. Clicking a chip narrows both task views to that person and survives the Board/List toggle; the stats strip above is deliberately never filtered, and a banner says so while a filter is applied. Sorted busiest-first, then by name, so the list does not reshuffle between renders of the same data. Two things it does not show, both deliberate: no load, utilisation or capacity figure — no per-person denominator exists anywhere in the schema, and a percentage of nothing gets planned against (the Workload view's fabricated
Load %was deleted for exactly this reason in PT-968) — and no unassigned bucket, because unassigned work is a fact about tasks and this section answers a question about people. Members in unreadable projects contribute to no chip and are reported only as the count the board already discloses, so the roster cannot name someone you are not authorised to see.A cycle has no human membership, and the section says so rather than pretending otherwise. Its members are TASKS (
cycle_membership_events), so there is no "add a person to this cycle" action and could not be one. An info popover beside the heading states the rule — People appear here when they're assigned work in this cycle — and, for a viewer who may create work on a single-project cycle, offers Assign work: it opens the ordinary new-task modal with that project and cycle already pinned, so the person appears as soon as the task is created. The three ways a person comes to be listed are therefore: assign them a task already in the cycle, move one of their tasks into it (the task's own cycle picker, or the bulk action on the tasks page), or start work on one of their tasks and let auto-attach place it. A completed cycle shows no roster at all: its figures are frozen at the close measurement and rollover may have moved the work, so current ownership would not describe who delivered it.
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 tool | CLI command | Notes |
|---|---|---|
list_cycles | pt list-cycles | --include-archived to include soft-deleted cycles; --all-scopes for the org-wide read |
create_cycle | pt create-cycle | --sequence required on the project / initiative arms (unique per scope), optional on --projects |
update_cycle | pt update-cycle | --state active|completed to transition; else descriptive |
delete_cycle | pt delete-cycle | Archives 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.
| Surface | MCP | CLI |
|---|---|---|
| create | projectIds (2–50) | --projects <hint,hint,…> |
| list | allScopes: true | --all-scopes |
| update, delete | projectSetId | --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) andpt create-task/pt update-task(--cycle-id,--estimate-points) acceptcycleIdandestimatePoints; on update,null(MCP) or""(CLI) clears either field.list_tasks(MCP) andpt list-tasks(--cycle-id) filter tasks by cycle.estimatePointsis included in the CSV and Excel task exports and parsed by the Excel importer (task import is Excel-only).- In the GUI (PT-613),
estimatePointsis 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); a0is a real zero estimate that counts as estimated and contributes 0 points, consistent with the API and the burndown. The GUI rejects anything outside theNUMERIC(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 a0-point task shows no badge even though it counts as estimated.