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>
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.ts—actionStateat 50,busyat 51,lastErrorat 52,saveStateat 56,pendingPublishat 59, and the publish flow at 174-193apps/ssp/src/app/brief/domain/org-template.machine.ts— theLoadedvariant at 27-35 (PascalCase since RD-11)apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts:245,282andorg-template.page.ts:59— the render seam that must not change
Decisions (pre-made, don't relitigate)
-
One four-variant union on
OrgTemplateState.Loaded:action: { tag: 'Idle' } | { tag: 'ConfirmingPublish' } | { tag: 'Busy' } | { tag: 'Failed'; error: string }ConfirmingPublishis the fourth variant that absorbspendingPublish. This is the whole point of the ticket: after it, "confirming" and "busy" are mutually exclusive by construction, not by convention. -
requestPublishandcancelPublishbecome dispatches. They are the only two commands in this store that do not guard onloaded()today — they just set the boolean. As messages (PublishRequested,PublishCancelled) they no-op outsideLoaded, which is the correct behaviour and means you do not add a guard that changes anything. -
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. -
pendingPublish,busyandlastErrorall stay as store members with byte-identical public signatures.pendingPublishbecomescomputed(() => this.action().tag === 'ConfirmingPublish')rather than asignal. The render seam must not move:org-template-editor.component.ts:282takespendingPublish = input(false),:245renders on it,org-template.page.ts:59passes it, and two story args set it. No file underbrief/ui/may change. -
flushSavesets bothsaveStateandactionState(lines 161-169). Convert only theactionStatehalf.saveStatemust still number 5 occurrences. -
action-state.tsstill exists after this ticket. RD-14 movesSaveStateintodebounced-save.tsand deletes the file. Do not delete it here, and do not touchSaveState. -
Do not revisit
NO_SUBORGS.org-template.store.ts:29,129dispatchesLoadFailedfor what is semanticallyEmpty. 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
- Add the four-variant
actionfield toOrgTemplateState.Loadedand the messages toOrgTemplateMsg:PublishRequested,PublishCancelled,ActionStarted,ActionFinished,ActionFailed. - Handle them in
reduce, each a no-op outsideLoaded.DraftLoadedresetsactiontoIdle, matching RD-12's deliberate reset. - Add reducer spec cases (see Acceptance), including the mutual-exclusion case.
- Replace the 13
actionState.set(...)and 4pendingPublish.set(...)sites with dispatches. - Re-point
busy,lastErrorandpendingPublishatLoaded.action, keeping signatures identical. - Run
npm run gen:behaviour-spec— new spec titles otherwise fail the drift check. - Update this ticket's
Status:todoneand the README's RD-13 row todone. - 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
SaveStateand deletingaction-state.ts— RD-14.NO_SUBORGSbecomingEmpty— optional RD-34 (decision 7).- Any file under
brief/ui/, and the fourbusy = 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
ConfirmingPublishandBusycoexist in any way, the ticket has not achieved its purpose even if every grep passes. pendingPublishchanges from asignalto acomputed. Anything that writes it must become a dispatch. A leftover.set()call will not compile, which is the desired outcome.- Keep
busy/lastError/pendingPublishsignatures 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.mdxdrift from the new spec titles. Rungen:behaviour-specin the same commit.