Files
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

7.4 KiB

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:

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.tsactionState 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:

    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; guardsconfirmPublish (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.

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:

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
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.