Skip to content

Runbook — OpenProject Story sync (ADR-0079 invariant 10, P3)

Severity : P3 (governance discipline ; no live trading impact) Owner : @dococeven Linked code : scripts/op_story_sync.py · .github/workflows/op-story-sync.yml · documentation/F1_BUY_BOOST_PLAN.md §10

This runbook covers the automated SSoT sync between F1 plan §10 tracking table (source-of-truth) and OpenProject Story status. Per ADR-0079 invariant 10, OP must mirror §10 within :

  • 5 min of any push to main that updates the §10 table (CI hook)
  • 1 hour for any out-of-band drift (operator UI edit, import script side-effect → cron safety net)

§1 — How the sync works

┌─────────────────────────────────────┐         ┌─────────────────────────┐
│ documentation/F1_BUY_BOOST_PLAN.md  │         │ OpenProject              │
│  §10 tracking table (markdown)      │  ─────▶ │  Story wp#NN status      │
│  (source-of-truth)                  │         │  (target / mirror)       │
└─────────────────────────────────────┘         └─────────────────────────┘
              │                                              ▲
              │ scripts/op_story_sync.py                     │
              │   1. parse markdown → StoryStatusRow[]       │
              │   2. for each row : GET wp#NN → diff state   │
              │   3. PATCH wp#NN if drift                    │
              ▼                                              │
   .github/workflows/op-story-sync.yml ─────── PATCH status ─┘
   (push to main + hourly cron)

Source-of-truth schema

The §10 markdown table MUST follow this shape — the parser depends on it :

| Track | Phase | GH issue / OP wp | Status |
|---|---|---|---|
| 9 — Per-regime threshold | quick-win 3 | OP [wp#42](https://...) | **Closed ABANDONED** ... |
| 1 — BTC features | quick-win 4 | OP [wp#43](https://...) | **In progress** — split-PR ... |

Load-bearing fields : wp#NN (extracted via regex) + the bold prefix of the Status column (one of the 7 OP states : Backlog / New / Specified / In progress / In testing / Closed / On hold / Rejected). Verdict qualifiers (ABANDONED / LOCKED / KEEP_AVAILABLE) are stripped before matching, so **Closed ABANDONED** resolves to OP state Closed.

Rows skipped : entries without an OP wp#NN reference (informational rows) AND entries whose Status column has no recognisable bold state (operator-managed → out of scope for auto-sync). Skipped rows surface a WARN to stderr.

§2 — Day-2 ops

Run a diff (preview, no API writes)

make op-story-diff
# → prints expected vs actual + which Stories drift

Apply the sync (write transitions)

make op-story-sync
# → PATCHes drifted Stories ; idempotent (re-run safe)

Get current state per Story

make op-story-status
# → prints which Stories the F1 plan §10 references + their current OP state

Trigger the CI workflow manually

gh workflow run op-story-sync.yml -f mode=diff   # preview
gh workflow run op-story-sync.yml -f mode=sync   # apply

§3 — Bootstrapping (one-shot)

The CI workflow needs OPENPROJECT_API_KEY as a GitHub repo secret. Without it, the workflow logs a warning and exits clean — invariant 10 is NOT enforced until the secret is set.

# Generate token : OpenProject UI → My account → Access tokens → Create
# Save the token, then:
gh secret set OPENPROJECT_API_KEY --body '<paste-token-here>'

# Verify the secret exists (value is masked):
gh secret list | grep OPENPROJECT_API_KEY

After the secret lands, the next push to main (or the next hourly cron tick) will exercise the syncer + apply any pending transitions.

§4 — Updating a Story status

Always update the F1 plan §10 first — the auto-sync mirrors it to OP within 5 min.

  1. Edit documentation/F1_BUY_BOOST_PLAN.md §10 → change the Status column for the relevant Story
  2. Open a small PR (CR + merge per CLAUDE.md workflow)
  3. The workflow fires on merge → OP transitions automatically
  4. Verify via make op-story-status or OP UI → Story → Activity log shows the auto-comment

Do NOT edit OP UI directly — the next sync will revert your change because §10 wins. If you need an OP state that's not yet in §10, edit §10 first OR the change will be overwritten within 1h by the cron.

§5 — Adding a new Story to the auto-sync

When a new Story (e.g., CVN-N002-EA-S01) gains an OP wp#NN, add a row under §10 using the load-bearing schema (§1). Same merge → auto-sync → done.

For non-F1 missions (e.g., CVN-N002 win-ratio mission), the syncer can be extended to read from a sibling tracking table — currently the parser is hardcoded to the §10 header Track | Phase | GH issue / OP wp | Status. Future ADR/PR can generalise to multiple tables.

§6 — Decommissioning a Story

Two paths :

  1. Closed — set Status = **Closed** + verdict (e.g., **Closed ABANDONED**). Auto-sync transitions OP to Closed.
  2. Rejected — set Status = **Rejected**. Auto-sync transitions OP to Rejected.

The Story stays in the §10 table indefinitely (audit trail). The OP wp#NN stays archived in OP. Neither is hard-deleted.

§7 — Troubleshooting

Symptom Likely cause Fix
Workflow run fails with "OPENPROJECT_API_KEY secret not set" warning Secret missing gh secret set OPENPROJECT_API_KEY --body '<token>' per §3
Workflow run fails with OP API ... → 401 Token revoked / expired Generate new token in OP UI → repeat §3
Workflow run fails with tracking table not found F1 plan §10 header changed Restore the header Track \| Phase \| GH issue / OP wp \| Status ; the parser is exact-match on the column names
Story stays drifted after a push to main Workflow filters skip the path (paths: ['documentation/F1_BUY_BOOST_PLAN.md', ...]) — no plan change in the PR Run make op-story-sync locally OR wait for the hourly cron OR gh workflow run op-story-sync.yml
make op-story-sync raises OP API ... → 422 on PATCH Status target name differs from what OP has (e.g., OP has "Doing" not "In progress") Verify OP UI → Settings → Statuses ; align the §10 status names with OP's Status admin OR rename in OP
A bold state in §10 doesn't transition (no warning, no transition) The bold token doesn't match a canonical state ; parser falls back to skip + WARN Check stderr of the run for the WARN line ; align the bold prefix with one of the 7 canonical states
50 Stories drift simultaneously after a manual mass-edit Operator edited many statuses in OP UI, syncer reverted them all Use the auto-sync workflow as the canonical channel ; if a mass edit is intentional, update §10 first then push

§8 — Why this exists (cross-reference to incident)

2026-05-01 incident : after Track 9 closure (PR #794 merge), 6 Stories (wp#44/46/47/48/49/50) were stuck In progress despite F1 plan §10 marking them New. The operator surfaced this as the n-th recurrence of OP-vs-plan drift.

Root cause : ADR-0076 + ADR-0079 invariants 1-9 documented the discipline but relied on the operator (or the AI assistant) to remember the manual transitions. Per feedback_no_discipline_workflows.md memory : "any cross-system sync that relies on the operator remembering = drift. Automate or don't ship."

Fix : ADR-0079 invariant 10 + this syncer + the CI hook = the automation that the discipline always required. Post-merge, drift is bounded to ≤ 5 min ; out-of-band drift (UI edits) bounded to ≤ 1h.

No more punch lists. If you ever see one in this repo for OP transitions, it's a regression — please flag.