Files
atomic-design-poc/docs/project/readable-codebase/RD-13-org-template-action.md
T
ehoandClaude Sonnet 5 c599fee8e2 refactor: fold org-template's action lifecycle + pendingPublish into one union (RD-13)
Before this change, org-template.store.ts held the action lifecycle in an
actionState signal and the publish impact-confirm gate in an independent
pendingPublish signal. The two were representable in combination, so
pendingPublish === true and busy === true could both hold at once. That
state was meaningless: the UI would show the publish-impact confirmation
while a publish was already in flight.

OrgTemplateState.Loaded now carries one action field, a four-variant union
(Idle | ConfirmingPublish | Busy | Failed). ActionStarted overwrites the
field straight to Busy from any prior tag, so ConfirmingPublish and Busy
can never coexist — not by convention, but because one field can only
hold one tag. requestPublish and cancelPublish become dispatches
(PublishRequested/PublishCancelled); as the reducer already no-ops
outside Loaded, this changes no behaviour. The other four commands
(confirmPublish, rollback, proefbrief, flushSave) keep their existing
loaded() guards. busy, lastError and pendingPublish stay on the store as
computed values reading the new union, with byte-identical public
signatures — no file under brief/ui/ changes.

Ran gen:behaviour-spec for the six new reducer cases.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 18:38:23 +02:00

167 lines
7.4 KiB
Markdown

# 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<ActionState>({ 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.