Skip to content

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 New to Specified. The plan-dossier-drafting + plan_review running phase had no representation in OP — the Story sat as New for hours/days while a real ceremony ran. Ambiguous to outside observers (was it picked? still unclaimed?). Sister problem on the closure side : if plan_review returned INCONCLUSIVE, the Story was stuck back at New (or hand-promoted to Specified without verdict).
  • No "Developed" : a PR-open Story jumped straight from In progress to In 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 testing until the operator manually flipped it to Closed. 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 :

  1. wp#104 (CVN-N014-EA-S03) created in status In progress instead of New because the importer mapped GH openIn progress directly. Fixed in PR #814 (now openNew), but the deeper cause was the absent In specification state — the importer had no obvious "intermediate landing" between New and In progress so it defaulted wrong.
  2. The Track 14 closure dossier (PR #812) needed a Tested post-FTF-sweep state to capture "gate decision recorded, awaiting Console flip" — instead the operator had to use In testing for both the FTF run AND the post-decision waiting period.
  3. Several In progress Stories on 2026-05-03 (11 of them) that had merged PRs but no recorded deployment status — they should have advanced to Developed (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 as New, never a downstream state. Per the fix in PR #814, STATE_TO_STATUS["open"] = "New" is the locked default. This invariant prevents an In specification or In 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 testing to "post-merge" : doesn't fix the absent In specification (where plan_review runs) or Tested (where ML gate decision is recorded). The collapse hid 3 different operator phases inside In testing ; renaming wouldn't disambiguate them.
  • Use all 14 OP statuses : Confirmed, To be scheduled, Scheduled, Test failed add 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 the In progress → Developed → In testing window only.

Consequences

  • Positive :
  • Operator (and auto-syncer / importer) never have to guess what's next — every gate has one ritual + one verdict.
  • In specification makes the plan_review window visible : the operator sees at a glance which Stories are "in the queue" (committee running) vs "impl-ready" (plan PASSED).
  • Developed separates "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.
  • Tested separates "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 the STORY_WORKFLOW.md cheat 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 in STORY_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_review and pr_review rituals 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 New as before.
  • This ADR is marked superseded by ADR-XXXX in the file frontmatter ; STORY_WORKFLOW.md is reverted to its pre-ADR-0081 form.
  • No data migration needed — OP statuses are display-level metadata, not schema. Existing In specification / Developed / Tested wps 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.pySTATE_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 at In progress → Developed
  • External : OpenProject status workflow docs (for instance admins changing status set ; not used here)