# RD-13 — Fold org-template's action lifecycle and `pendingPublish` into one union Status: done Source: PLAN.md 1b#2a and 1b#6 ## Why `org-template.store.ts` repeats the pattern RD-12 removed from brief — an `actionState` signal set imperatively from 13 places — and adds the arc's **one genuine illegal-state pair**: ```ts private actionState = signal({ tag: 'Idle' }); // Idle | Busy | Failed readonly pendingPublish = signal(false); // independent boolean ``` Nothing prevents `pendingPublish === true` _and_ `busy === true` at the same time. That state is representable and meaningless: the UI would show the publish-impact confirmation while a publish is already in flight. Two independent signals cannot express "these are mutually exclusive"; one union can. ## Read first - `docs/project/readable-codebase/RD-12-brief-action-in-machine.md` — the same migration, already done and green for brief. Copy its shape. - `apps/ssp/src/app/brief/application/org-template.store.ts` — `actionState` at 50, `busy` at 51, `lastError` at 52, `saveState` at 56, `pendingPublish` at 59, and the publish flow at 174-193 - `apps/ssp/src/app/brief/domain/org-template.machine.ts` — the `Loaded` variant at 27-35 (PascalCase since RD-11) - `apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts:245,282` and `org-template.page.ts:59` — the render seam that must not change ## Decisions (pre-made, don't relitigate) 1. **One four-variant union on `OrgTemplateState.Loaded`:** ```ts action: { tag: 'Idle' } | { tag: 'ConfirmingPublish' } | { tag: 'Busy' } | { tag: 'Failed'; error: string } ``` `ConfirmingPublish` is the fourth variant that absorbs `pendingPublish`. This is the whole point of the ticket: after it, "confirming" and "busy" are mutually exclusive **by construction**, not by convention. 2. **`requestPublish` and `cancelPublish` become dispatches.** They are the only two commands in this store that do **not** guard on `loaded()` today — they just set the boolean. As messages (`PublishRequested`, `PublishCancelled`) they no-op outside `Loaded`, which is the correct behaviour and means you do not add a guard that changes anything. 3. **The other commands keep their existing `const s = this.loaded(); if (!s) return;` guards** — `confirmPublish` (181), `rollback` (197), and the two at 158 and 211. Do not remove them; they are stronger than brief's template gate and remain correct. 4. **`pendingPublish`, `busy` and `lastError` all stay as store members with byte-identical public signatures.** `pendingPublish` becomes `computed(() => this.action().tag === 'ConfirmingPublish')` rather than a `signal`. The render seam must not move: `org-template-editor.component.ts:282` takes `pendingPublish = input(false)`, `:245` renders on it, `org-template.page.ts:59` passes it, and two story args set it. **No file under `brief/ui/` may change.** 5. **`flushSave` sets both `saveState` and `actionState`** (lines 161-169). Convert only the `actionState` half. `saveState` must still number 5 occurrences. 6. **`action-state.ts` still exists after this ticket.** RD-14 moves `SaveState` into `debounced-save.ts` and deletes the file. Do not delete it here, and do not touch `SaveState`. 7. **Do not revisit `NO_SUBORGS`.** `org-template.store.ts:29,129` dispatches `LoadFailed` for what is semantically `Empty`. That is a real finding and it is optional RD-34, not this ticket. ## Files - `apps/ssp/src/app/brief/domain/org-template.machine.ts` (+ `.spec.ts`) - `apps/ssp/src/app/brief/application/org-template.store.ts` Not `action-state.ts` (RD-14). Not `brief.machine.ts` or `brief.store.ts` (RD-12, done). No UI files. ## Steps 1. Add the four-variant `action` field to `OrgTemplateState.Loaded` and the messages to `OrgTemplateMsg`: `PublishRequested`, `PublishCancelled`, `ActionStarted`, `ActionFinished`, `ActionFailed`. 2. Handle them in `reduce`, each a no-op outside `Loaded`. `DraftLoaded` resets `action` to `Idle`, matching RD-12's deliberate reset. 3. Add reducer spec cases (see Acceptance), including the mutual-exclusion case. 4. Replace the 13 `actionState.set(...)` and 4 `pendingPublish.set(...)` sites with dispatches. 5. Re-point `busy`, `lastError` and `pendingPublish` at `Loaded.action`, keeping signatures identical. 6. Run `npm run gen:behaviour-spec` — new spec titles otherwise fail the drift check. 7. Update this ticket's `Status:` to `done` and the README's RD-13 row to `done`. 8. Commit all of it together. ## Acceptance criteria Measured baselines, dry-run before handover. Commands are scoped to **this ticket's two files**, never to the `brief/` directory — `action-state.ts` and other files legitimately still reference these names. ```bash S=apps/ssp/src/app/brief/application/org-template.store.ts M=apps/ssp/src/app/brief/domain/org-template.machine.ts git grep -c "actionState" -- $S # was 13 -> MUST return nothing git grep -cw "ActionState" -- $S # MUST return nothing (word-anchored: a new # OrgTemplateActionState would contain the old name) git grep -c "pendingPublish" -- $S # was 4 (a signal) -> now exactly 1 (a computed) git grep -c "saveState" -- $S # unchanged: still 5 git grep -c "readonly busy\|readonly lastError" -- $S # unchanged: still 2 git grep -c "ConfirmingPublish" -- $M # >= 1 ``` The render seam did not move: ```bash git diff --name-only 8e5f48c | grep -c "brief/ui/" || true # MUST be 0 ``` New reducer cases, the third being the point of the ticket: ``` - PublishRequested moves a loaded template to ConfirmingPublish - PublishCancelled returns to Idle - ActionStarted from ConfirmingPublish goes to Busy, so confirming and busy cannot coexist - ActionFailed carries the error - DraftLoaded resets a stale action error to Idle - an action message is a no-op when the template is not loaded ``` ```bash npm run ci # exits 0 ``` ## Verification `npm run ci`. No story, no `.mdx`, no `libs/shared/src/ui/**`, so `--full` is not required. If you run the full gate anyway, pass `timeout: 600000` on the Bash call — it takes about 8 minutes, and the harness backgrounds anything over 120s, which would end your turn with the work uncommitted. If `dotnet test` fails with `SQLite Error 1: 'no such table: …'`, that is the stale `bigregister.db` artifact documented in this README's Troubleshooting section. It is unrelated to your change. ## Out of scope - `SaveState` and deleting `action-state.ts` — RD-14. - `NO_SUBORGS` becoming `Empty` — optional RD-34 (decision 7). - Any file under `brief/ui/`, and the four `busy = input(...)` components. - `brief.machine.ts` / `brief.store.ts` — RD-12 already did those. ## Risks - **The mutual-exclusion case is the acceptance test that matters.** If your reducer lets `ConfirmingPublish` and `Busy` coexist in any way, the ticket has not achieved its purpose even if every grep passes. - **`pendingPublish` changes from a `signal` to a `computed`.** Anything that _writes_ it must become a dispatch. A leftover `.set()` call will not compile, which is the desired outcome. - **Keep `busy`/`lastError`/`pendingPublish` signatures byte-identical.** All three are read from a page template; renaming or re-typing one turns a pure refactor into a UI change and breaks two stories. - **`behaviour-spec.mdx` drift** from the new spec titles. Run `gen:behaviour-spec` in the same commit.