ADR-0081 — Eight-state Story workflow with rituals at every gate¶
Status: active
Date: 2026-05-03
Introduced by: CVN-N011-EA-S13 / PR (TBD)
Supersedes: amends ADR-0069 (Story state model section) ; supersedes the 5-state collapse documented in STORY_WORKFLOW.md §1 prior to this ADR.
Context¶
ADR-0069 made OpenProject the project orchestrator and declared that every Story flows through OP statuses. The original STORY_WORKFLOW.md (introduced 2026-04-24) collapsed OP's 14 native statuses into 5 happy-path states : New → Specified → In progress → In testing → Closed, with the rationale that "operator-side discipline replaces" the intermediate statuses (In specification, Confirmed, To be scheduled, Scheduled, Developed, Tested, Test failed).
In practice this collapse hid important state transitions and made the verdict at each gate ambiguous :
- No "In specification" : a Story moved straight from
NewtoSpecified. The plan-dossier-drafting +plan_reviewrunning phase had no representation in OP — the Story sat asNewfor hours/days while a real ceremony ran. Ambiguous to outside observers (was it picked? still unclaimed?). Sister problem on the closure side : ifplan_reviewreturnedINCONCLUSIVE, the Story was stuck back atNew(or hand-promoted toSpecifiedwithout verdict). - No "Developed" : a PR-open Story jumped straight from
In progresstoIn testing. The post-merge-pre-deploy phase (where the merge SHA is known but the K8s deploy hasn't yet succeeded) was indistinguishable from the pre-PR coding phase. - No "Tested" : a Story merged-deployed-validated stayed in
In testinguntil the operator manually flipped it toClosed. For ML/FTF Stories this hid the per-track gate decision phase ; for Infra Stories it hid the system-test-passed-but-runbook-not-yet-published phase.
The 2026-05-03 conversation that motivated this ADR surfaced 3 concrete instances where the collapse caused operator confusion :
- wp#104 (CVN-N014-EA-S03) created in status
In progressinstead ofNewbecause the importer mapped GHopen→In progressdirectly. Fixed in PR #814 (nowopen→New), but the deeper cause was the absentIn specificationstate — the importer had no obvious "intermediate landing" betweenNewandIn progressso it defaulted wrong. - The Track 14 closure dossier (PR #812) needed a
Testedpost-FTF-sweep state to capture "gate decision recorded, awaiting Console flip" — instead the operator had to useIn testingfor both the FTF run AND the post-decision waiting period. - Several
In progressStories on 2026-05-03 (11 of them) that had merged PRs but no recorded deployment status — they should have advanced toDeveloped(PR open + CI green pre-merge) →In testing(post-merge in K8s) →Tested(smoke + FTF gate) but the workflow had no way to express that progression.
The fix is to use all 8 states OP offers for the canonical Story flow (mapping directly to OP's native names), with explicit rituals + verdicts at every gate so the operator (and the auto-syncer / importer tooling) never has to guess what's next.
Decision¶
The canonical happy-path Story flow is 8 states, each with a defined entry trigger, ritual, verdict, and documentation artefact. Rituals are reusable across Story types ; verdicts are deterministic enough that the operator and the auto-syncer agree on the next state without manual judgment.
[ New ]
│
│ pick + plan dossier file created
▼
[ In specification ]
│
│ committee plan_review verdict=PASSED (waivable per ADR-68 with written justification)
▼
[ Specified ]
│
│ impl branch + first commit
▼
[ In progress ]
│
│ PR opened + local QA green + CI G1-G4 guardrails green + CR cycle complete (no actionable comments)
▼
[ Developed ]
│
│ squash merge to main
▼
[ In testing ]
│
│ Deploy K8s SUCCESS + smoke test passes (DAG / endpoint validated)
▼
[ Tested ]
│
│ for ML/FTF : per-track gate verdict recorded (lock/keep/abandon) ; for Infra : runbook published if applicable ; for ADR : adr/index.md updated
│ operator final-closure OP comment posted (PR # + commit SHA + verdict + artefact links)
▼
[ Closed ]
Plus 2 escape hatches : On hold (pause with ETA) and Rejected (committee blocker without resolution path) — accessible from any happy-path state.
Total OP statuses used : 8 of the 14 available (was 7 before this ADR — In specification, Developed, Tested newly used ; Confirmed, To be scheduled, Scheduled, Test failed still unused per operator-side discipline).
OP status name → workflow role mapping (always referenced by name, never numeric ID per ADR-0069 invariant on tooling) :
| OP status name (authoritative) | Workflow role |
|---|---|
New |
filed, not picked |
In specification |
picked, plan dossier + plan_review in flight |
Specified |
plan PASSED, impl-ready buffer |
In progress |
active dev (single-WIP) |
Developed |
PR open + CI/CR green, awaiting merge |
In testing |
merged + deployed in K8s, awaiting smoke + ML gate |
Tested |
system-validated, awaiting final closure verdict |
Closed |
done |
On hold |
escape — pause with ETA |
Rejected |
escape — committee blocker, work item closed without merge |
The 8-state flow is documented as the operational contract in documentation/process/STORY_WORKFLOW.md ; this ADR is the authority that workflow doc references.
Invariants¶
Invariant 1 — One ritual per gate, one verdict per ritual¶
Every state transition has exactly one ritual + verdict. The ritual is reusable across Story types (operator drafts a plan dossier the same way for an ML Story or an Infra Story) ; the verdict is deterministic (PASSED / REJECTED / INCONCLUSIVE for committee sessions ; green/red for CI ; lock/keep/abandon for ML gates).
| Gate | Ritual | Verdict | Artefact |
|---|---|---|---|
New → In specification |
operator picks Story + drafts plan dossier file under documentation/reviews/YYYY-MM-DD-<slug>-plan.md (or, for Docs/ADR Stories, the ADR draft file itself ; for trivial bug Stories, the GH issue body serves as the plan with explicit operator note) |
dossier file exists in working tree | documentation/reviews/<date>-<slug>-plan.md |
In specification → Specified |
committee plan_review (ADR-68) — python scripts/expert_committee.py --artifact <dossier> --question "..." --session-type plan_review --issue "#NNN" |
PASSED (or INCONCLUSIVE with written waiver in OP comment + explicit re-review deadline) |
committee/sessions/<session_id>_committee.json (auto-saved) |
Specified → In progress |
operator creates impl branch (feat/<cvn_id>-... or fix/<cvn_id>-...) + at least one commit |
branch exists with ≥ 1 commit pushed | branch on remote |
In progress → Developed |
operator opens PR ; local QA (make qa + mkdocs build --strict) green ; CI 14 checks green ; CI workflow guardrails (G1-G4) green ; CodeRabbit cycle complete with no actionable comments in the latest review (4-5 passes typical) ; if applicable, committee pr_review (ADR-68) verdict PASSED |
PR ready for merge (all checks ✅, CR clean, committee OK) | PR description with plan dossier + committee session ID + (ML) MLOps readiness link |
Developed → In testing |
operator squash-merges to main ; squash commit message ends with Closes #NNN |
merge SHA recorded | git log main shows the squash SHA ; GH issue auto-closed |
In testing → Tested |
Deploy K8s workflow SUCCESS (workflow .github/workflows/cvntrade-deploy-k8s.yml) ; smoke test = operator validates a representative DAG / API endpoint / Console page (depending on Story type) ; for ML/FTF : FTF sweep triggered + ≥ 90 % cell completion + per-track gate decision computed |
both green | smoke test note in OP comment ; for ML/FTF : documentation/missions/<area>/<date>-<slug>-results.md |
Tested → Closed |
for ML : per-track gate verdict (lock / keep available / abandon) recorded in results dossier ; for Infra : runbook published if alerting changed ; for ADR : adr/index.md updated + CLAUDE.md updated if invariant introduced ; operator posts final OP comment with PR # + commit SHA + verdict + artefact links |
OP comment posted | OP comment ; for ML : F1 plan §10 row updated → auto-syncer mirrors |
Invariant 2 — Single-WIP applies to In progress only¶
At most ONE Story is In progress per operator at any time (per CLAUDE.md §0). Other states have no WIP cap :
- Buffer of Specified Stories is unbounded (impl-ready queue).
- In specification can have multiple Stories (multiple plan dossiers being drafted in parallel ; rare but legal).
- Developed can have multiple Stories (multiple PRs awaiting merge ; common when CR cycles overlap).
- In testing can have multiple Stories (multiple PRs merged + deployed, awaiting smoke or ML gate).
- Tested can have multiple Stories (multiple closures awaiting operator wrap-up).
The single-WIP rule on In progress exists to enforce focus on one active dev task at a time ; it does NOT cap the upstream queue or downstream wrap-up.
Invariant 3 — Importer + auto-syncer respect the 8-state contract¶
- Importer (
scripts/openproject_import_gh.py) : new GH issues land in OP asNew, never a downstream state. Per the fix in PR #814,STATE_TO_STATUS["open"] = "New"is the locked default. This invariant prevents anIn specificationorIn progress"wrong default" regression. - Auto-syncer (
scripts/op_story_sync.py) : reads the F1 plan §10 outcomes table on every push to main, and patches OP Story status to mirror the §10 row's verdict semantics. The verdict semantics MUST map to the 8-state contract :
| F1 plan §10 row text | Target OP status |
|---|---|
| "not started" / "New" | New |
| "In specification" | In specification |
| "Specified" / "impl-ready" | Specified |
| "In progress" | In progress |
| "Developed" / "PR open, CR clean" | Developed |
| "In testing" / "merged, deployed" | In testing |
| "Tested" / "smoke OK" | Tested |
| "Closed LOCK" / "Closed KEEP_AVAILABLE" / "Closed ABANDON" / "Closed" | Closed |
| "On hold" | On hold |
| "Rejected" | Rejected |
The syncer code lives in scripts/op_story_sync.py and MUST recognise the 8 happy-path texts + 2 escapes. Adding a new mapping requires updating both this ADR and the syncer.
Invariant 4 — Documentation per state is mandatory¶
Every transition produces an artefact (or links one). The artefact is the audit trail of the verdict ; without it the verdict is not falsifiable. Tabulated in STORY_WORKFLOW.md §4 "Documentation per Story type" and reproduced here for invariant-locking :
| State entered | Mandatory artefact (any Story type) | Type-specific addition |
|---|---|---|
In specification |
plan dossier file under documentation/reviews/ |
ML : MLOps readiness draft begins |
Specified |
committee/sessions/<session_id>_committee.json |
ML : MLOps readiness signed off (per ADR-70) |
In progress |
impl branch on remote + first commit | Infra : design dossier under documentation/design/ if non-trivial |
Developed |
PR description with plan + committee + (ML) MLOps links | none beyond PR |
In testing |
squash commit SHA + GH issue auto-closed via Closes #NNN |
ML/FTF : results dossier draft begins |
Tested |
smoke-test note in OP comment | ML/FTF : results dossier complete with per-track gate ; Infra : runbook published if alerting changed |
Closed |
OP final closure comment with PR # + commit SHA + verdict + artefact links | ML : F1 plan §10 row updated → auto-syncer mirrors |
Invariant 5 — Escape hatch transitions preserve audit trail¶
On hold can be entered from any happy-path state. The OP comment MUST include : reason for hold, ETA reprise, link to blocker (if external). The Story is not automatically released from single-WIP if it was In progress — operator explicitly moves it to Specified (give up the slot) or stays in On hold (decision documented, slot consumed by a void).
Rejected can be entered from In specification (committee plan_review REJECTED + operator decides not to address blockers) OR from Developed (committee pr_review REJECTED blocking merge with no scope-fitting correction path). The OP comment MUST include : committee session ID + post-mortem dossier link under documentation/reviews/.
Both escapes are terminal-friendly — once Rejected, the Story is closed without merge ; once On hold for > 30 days without ETA update, the Story should be Rejected or returned to Specified.
Invariant 6 — Status drift detection runs on every main merge¶
The auto-syncer (scripts/op_story_sync.py) MUST run on every push to main (workflow .github/workflows/op-story-sync.yml) AND on an hourly cron safety net. Drift between F1 plan §10 and OP status auto-corrects within 1 hour. This invariant existed pre-ADR (per ADR-0079 invariant 10) ; this ADR extends it to the 8-state contract.
The syncer logs event=op_status_drift_corrected wp_id=N old=X new=Y on every correction. Drift > 5 corrections per day on the same wp triggers an OPERATIONS §17 entry (operator-side discipline failure pattern).
Alternatives rejected¶
- Keep the 5-state collapse + just rename
In testingto "post-merge" : doesn't fix the absentIn specification(where plan_review runs) orTested(where ML gate decision is recorded). The collapse hid 3 different operator phases insideIn testing; renaming wouldn't disambiguate them. - Use all 14 OP statuses :
Confirmed,To be scheduled,Scheduled,Test failedadd no operator value for a single-operator project — they're scaffolding for multi-team workflows. Keeping them unused is operator-side discipline ; using them would force ceremonies that don't exist in the project. - Add custom OP statuses (e.g.
Plan review running,CR cycle running) : modifying OP's status set is a one-way door (instance-wide change, affects all projects on the OP instance). Mapping to existing names is reversible and keeps the OP UI legible. - Use GH labels instead of OP statuses : duplicates the SSoT (per ADR-0076 OP wins). GH labels are useful for PR-level metadata (
guardrails-waiver,needs-rebase), not for Story-level state. The 8-state flow is OP-native ; GH PRs are an artefact of theIn progress → Developed → In testingwindow only.
Consequences¶
- Positive :
- Operator (and auto-syncer / importer) never have to guess what's next — every gate has one ritual + one verdict.
In specificationmakes the plan_review window visible : the operator sees at a glance which Stories are "in the queue" (committee running) vs "impl-ready" (plan PASSED).Developedseparates "code done, awaiting merge" from "code in progress" — useful for the operator to know they have N PRs ready to merge without re-checking each.Testedseparates "merged + deployed + validated" from "awaiting closure verdict" — particularly important for ML/FTF Stories where the per-track gate decision is the actual closure semantic.-
The status drift detection scope expands cleanly : auto-syncer maps F1 plan §10 row text → 8-state OP status, no hidden translation layer.
-
Negative :
- 3 more state transitions to remember (
New → In specification,In progress → Developed,In testing → Tested). Mitigated by the explicit ritual table in invariant 1 and theSTORY_WORKFLOW.mdcheat sheet. - The auto-syncer and importer need updating to recognise the new states. PR for this ADR ships those updates so the contract is enforced day 1.
-
Existing Stories in
In progress(11 of them as of 2026-05-03) are NOT migrated — they continue with the old workflow until their natural closure. New Stories opened post-ADR-merge follow the 8-state flow. Migration policy documented inSTORY_WORKFLOW.md§6. -
Neutral :
- The escape hatches (
On hold,Rejected) are unchanged from the 5-state version — operator behaviour around them stays identical. - The committee
plan_reviewandpr_reviewrituals are unchanged — they happen at the same gates, just with explicit OP status reflection. - CLAUDE.md §0 (mandatory dev process) gets a one-line cross-ref update ; the meat stays in
STORY_WORKFLOW.md.
Rollback¶
- Revert the syncer + importer commits (single PR scope). Tooling falls back to the 5-state mapping ; OP statuses persist on existing wps but new ones land in
Newas before. - This ADR is marked
superseded by ADR-XXXXin the file frontmatter ;STORY_WORKFLOW.mdis reverted to its pre-ADR-0081 form. - No data migration needed — OP statuses are display-level metadata, not schema. Existing
In specification/Developed/Testedwps stay valid (just without the ritual contract).
References¶
- Parent need :
CVN-N011(Pipeline Contract Hardening — workflow contracts are part of the pipeline contract surface) - Related ADRs : ADR-0068 (committee rituals), ADR-0069 (OP as orchestrator — amended by this ADR for the state model), ADR-0070 (MLOps readiness gate), ADR-0076 (OP SSoT), ADR-0079 (FTF closure 8-step workflow — the per-Story closure ritual nests inside the ADR-0081 Story state machine)
- PRs : (TBD — the PR that ships this ADR + the workflow doc update + the syncer update)
- Operational doc :
STORY_WORKFLOW.md(operational contract, references this ADR for authority) - Tooling :
scripts/openproject_import_gh.py—STATE_TO_STATUS["open"] = "New"(locked by PR #814 for ADR-0069 + ADR-0081 invariant 3)scripts/op_story_sync.py— F1 plan §10 → OP status mirror, MUST recognise 8 happy-path + 2 escape mappings per invariant 3.github/workflows/op-story-sync.yml— runs syncer on every push to main + hourly cron.github/workflows/pr-workflow-guardrails.yml— G1-G4 gate atIn progress → Developed- External : OpenProject status workflow docs (for instance admins changing status set ; not used here)