- **CW-v1 WP-2 tasks 2.1–2.3: `node` opts into revisionable storage; per-bundle `new_revision` knob wired (#1920).** `Node` gains a `revision_id` `ContentEntityKeys` key and a discoverable `workflow_state` field (`FieldStorage::Data` — already persisted per-revision via the generic `_data` blob, so this only makes it visible to `SchemaPresenter`/JSON:API discovery, not a schema change); `NodeServiceProvider` registers the `node` `EntityType` with `revisionable: true, revisionDefault: true` (every ordinary save creates a new revision, Drupal parity). A new shape-guarded, additive migration (`packages/node/migrations/2026_07_06_000001_node_revision_schema.php`) retrofits the `revision_id`/`published_revision_id` pointer columns and the `node_revision` table onto existing installs (schema only — legacy-row `workflow_state` backfill is binding-scoped, task 2.7's CLI). `Node` declares the legacy `RevisionableInterface` so `EntityRepository::shouldCreateRevision()`'s per-entity override branch is live for nodes; new `Listener\NodeRevisionDefaultListener` (`PRE_SAVE`) forwards the bundle's `NodeType::isNewRevision()` onto the node whenever no earlier actor has already decided (an explicit `setNewRevision()` call always wins). `NodeType`'s `new_revision` default flips `false → true` (opt-OUT semantics, design decision 1) — a bundle that never explicitly sets the key must keep revisioning on; the earlier `false` default would have silently disabled it for every standard bundle.
- **CW-v1 WP-2 task 2.4: `BeforeRevisionPointerMoveEvent` — the revision pointer-move bypass choke point (#1920).** WP-1's `WorkflowStateGuard` only validates state changes made through `EntityRepository::save()`; `rollback()`, `setCurrentRevision()`, `setPublishedRevision()`, and the `saveTranslationRevision()`/`saveTranslationRevisions()`/`saveTranslation()` trio move the revision pointer (or write a new revision) WITHOUT going through the save pipeline, so none of them was previously observable to a save-time guard. New `Waaseyaa\EntityStorage\Event\BeforeRevisionPointerMoveEvent` (extends Symfony `Event`, mirrors `WorkflowTransitionEvent`'s shape) is now dispatched by all six methods, by FQCN, before any backend write, carrying `entityTypeId`/`entityId`/`operation` (`'rollback'|'revert'|'publish'|'translation_save'`)/`fromRevisionId`/`toRevisionId`/`actorUid`/the target revision's raw values array. A subscriber denies by throwing `AbortOperationException` — same convention as `BeforeSaveEvent` — leaving storage completely untouched (including rolling back any earlier write already attempted in the same transaction, for the transactional multi-write paths). Task 2.5 wires a workflow pointer-move guard onto this event; this PR only adds the choke point itself.
- **CW-v1 WP-2 task 2.5: `WorkflowPointerMoveGuard` closes the pointer-move bypass; cache/audit ride along (#1920).** `Waaseyaa\Workflows\Listener\WorkflowPointerMoveGuard` subscribes to `BeforeRevisionPointerMoveEvent` (task 2.4) and validates the implied state change exactly like a transition — edge must exist, permission required only when an acting account context exists, null context checks edge-legality only — denying by `TransitionDeniedException` before any write. `translation_save` is a deliberate, unconditional pass-through: v1 `workflow_state` is per revision, not per revision-translation, so a translation write implies no state change. Adjacent gap closed in the same task (deliberate side-scope): pointer moves previously invalidated zero cache tags (`EntityCacheSubscriber` now also subscribes `RevisionPointerMovedEvent` + the legacy `EntityEvents::REVISION_REVERTED`) and `rollback()` was invisible to audit (`PublishPointerAuditListener` only covers `setCurrentRevision()`/`setPublishedRevision()`; new `Waaseyaa\Audit\Listener\RollbackAuditListener` — and new `AuditEventKind::RevisionRollback` — gives it its own audit trail by arming on `BeforeRevisionPointerMoveEvent(operation: 'rollback')`, the pre-write signal only `rollback()` dispatches, and consuming the armed slot on the following `REVISION_REVERTED` for the same entity — every pointer operation's pre-event re-sets the slot, so an aborted rollback or a save-then-publish of the same revision can never fabricate a rollback record).
- **CW-v1 WP-2 task 2.6: forward-draft mechanics land in `TransitionService` — the two-pointer status semantics, in full (#1920).** `TransitionService::transition()`'s Apply step now implements decision 2 completely: every transition-service save forces a new revision regardless of the bundle's `new_revision` default (a bundle opted out of revisioning would otherwise clobber whatever revision happens to be loaded — often the published one); a target `default_revision: true` state saves the new revision, moves the published pointer (`setPublishedRevision()`, which may deny via the task 2.5 guard), THEN flips `status` — pointer-move-before-status-flip, so a denial never leaves `status` lying about a live state the pointer doesn't back; a `default_revision: false` target on an entity with an existing published pointer (a forward draft) leaves the pointer and `status` alone (copied from the published revision), so the live version keeps serving until the draft is promoted. `WorkflowStateGuard::applyState()` mirrors the rule for raw saves: once a published pointer exists, `status` NEVER derives from the state a save is entering — it always derives from the published-pointer revision's own state (falling back to a stored-status copy only for pre-backfill rows whose state is unknown to the workflow) — and any state-changing save on a pointered entity unconditionally forces a new revision, so an opt-out bundle's raw save into `published`/`archived` can never corrupt the live row in place; raw saves consequently never enact pointer moves — only `TransitionService` promotes a `default_revision: true` state. `WorkflowPointerMoveGuard` gains a two-branch reconciliation for its edge check: a SAME-state pointer move (e.g. promoting a forward draft, or a rollback landing on another same-state revision) needs the permission of *any* transition targeting that state; a DIFFERENT-state move needs the real edge's own permission, no exceptions — closing a bypass an earlier draft of this fix briefly reopened (an account holding only `publish` could otherwise resurrect an `archived`-pointer revision with no `archived → published` edge). `DefaultWorkflows::EDITORIAL` gains `restore_to_published` (archived → published, mirroring Drupal's "Restore") so archived-content republishing has a real edge on the shipped workflow. **Amended by the WP-2 rework (below):** the `revise` (published → draft) edge originally described in this bullet was pulled from the shipped workflow before merge — the whole-branch review found no read path is pointer-aware, so `EDITORIAL` ships with `restore_to_published` only; the forward-draft mechanic described above is otherwise unchanged and lives on as dormant engine substrate, reachable via custom workflows and the archived→restore→publish round trip.
- **CW-v1 WP-2 task 2.7: `workflows:backfill-state` CLI + binding-activation runbook (#1920).** New `bin/waaseyaa workflows:backfill-state <entity_type> <workflow_id> [--bundle=] [--dry-run]` (`Waaseyaa\CLI\Handler\WorkflowsBackfillStateHandler`, registered by the new `WorkflowsServiceProvider`, mirroring `revisions:enable`'s registration) stamps a `workflow_state` onto every existing row missing one, deriving the target from each row's own `status` column (`status = 1` → the workflow's published `default_revision: true` state; otherwise → `initial_state`) since that column is the only truthful signal legacy rows carry. Idempotent (rows with a non-empty state are skipped), `--dry-run` performs zero writes, and it reports examined/backfilled/skipped/failed counts — broken down per target state in both real and dry-run output — exiting nonzero on any partial failure (the R16 fail-fast lesson). It also fails fast (nonzero exit, zero writes, dry-run included) on a workflow that defines no published+`default_revision: true` state while published (`status = 1`) rows still need backfilling — a shape `WorkflowValidator` accepts, on which the status-derived routing would otherwise silently stamp published content with `initial_state`; with no such rows it proceeds with an explicit notice (adversarial-panel critical fix). Deliberately binding-agnostic: it does not read `workflows.assignments` and does not require a binding to exist — the whole point is to run BEFORE the binding is added (see the new "Playbook H" in `docs/specs/operations-playbooks.md`), so `WorkflowBindingResolver`/`WorkflowStateGuard` are not yet live for the type/bundle being backfilled. Each save explicitly disables new-revision creation (`setNewRevision(false)`) regardless of `revisionDefault` — a bulk state stamp must not spawn a revision per row. The runbook documents the full activation sequence (`migrate:up` → `revisions:enable node` → `workflows:backfill-state` → only then the binding), both hard-mis-ordering failure modes (binding before backfill silently defaults legacy rows to `draft`; binding a non-revisionable type hard-throws loudly), which rows the backfill writes (base row + current/tip revision row; older revisions untouched — safe via the guard's stored-status fallback), the legacy archived-content collapse (`status = 0` covers both never-published drafts AND deliberately-retired content, and both backfill to `initial_state` — operators who used unpublishing as archiving must pre-stamp retired rows `archived` first; template `json_set` SQL provided, and pre-stamped rows are skipped so it is idempotent-friendly), the pre-WP-2 `NodeType.new_revision` legacy-default ambiguity with concrete check/fix SQL, and the explicit WP-2 revision-pruning stance (`RevisionPruningPolicy`/`EntityRepository::pruneRevisions()` are callable today; no CLI/scheduled pruning ships in WP-2 — unbounded revision growth is the documented default).
- **CW-v1 WP-2 task 2.8 (final): integration spine on real `node` + docs truth-up (#1920).** New `packages/workflows/tests/Integration/ForwardDraftFlowTest.php` — the WP-2 required spine, on the REAL `node`/`node_type` entity types (not a synthetic fixture): create (guard forces `draft`, unpublished) → publish (pointer + `status=1`) → forward-draft edit via a `revise` edge (live content untouched) → submit for review → publish the draft revision (promotion) → archive (pointer moves, `status=0`) → rollback DENIED without permission (persisted state proven byte-for-byte unchanged) → rollback WITH permission (succeeds, produces the `RevisionRollback` audit record via the real `RollbackAuditListener`). New `ImportSaveContextGuardTest.php` verifies — rather than assumes — that `SaveContext::isImport` saves are validated by `WorkflowStateGuard` identically to any other create/update (the guard's subscribed event carries no `SaveContext` at all, so the flag has zero effect; no guard change needed, matching the WP-2 plan's own conclusion). **Known gap, deliberately deferred, not silently dropped:** `WorkflowVisibility`'s read-side precedence (raw `workflow_state` over `status`) predates forward drafts and now mis-reports a still-published node mid-forward-draft-edit as unpublished — concretely confirmed to de-index still-live content via `Waaseyaa\AI\Vector\EntityEmbeddingListener`; a real fix touches a pinned unit test and six consumer packages, so it is tracked as a WP-2 follow-up rather than rushed here (`docs/specs/content-workflow.md` "Visibility (read side)"). Spec-drift resolved: `docs/specs/entity-system.md` (pointer-move cross-reference to the canonical `revision-system-unified.md` §4a), `docs/specs/infrastructure.md` (cache-listener table now reflects the task 2.5 pointer-move/revert subscriptions), and `docs/specs/ocap-audit-log.md` (22 → 23 `AuditEventKind` cases, `RevisionRollback` taxonomy + listener-catalogue + actor-source entries) all updated for real; `tools/drift-detector.sh`'s `PATTERN_TO_SPEC` gains a `packages/node/` → `docs/specs/revision-system-unified.md` entry (previously unmapped). `docs/specs/content-workflow.md`'s WP-1/WP-2 status-table rows flip to `landed`. **Amended by the WP-2 rework (below):** the spine originally ran bound to the boot()-seeded shipped `editorial` workflow via its own `revise` edge; it now runs against a test-local `editorial_forward` workflow (the descoped `EDITORIAL` shape plus a `revise` transition, persisted directly by the test) since the shipped workflow no longer ships that edge — the spine proves the engine mechanics still hold, not that the shipped workflow exposes them. The WP-2 status-table row is likewise further amended: substrate landed, forward drafts on the shipped workflow deferred to the option-1 follow-up.
- **CW-v1 WP-2 rework task 1: `revise` pulled from the shipped `editorial` workflow — forward drafts descoped (#1920).** The final whole-branch review found the forward-draft mechanic unshippable as exposed on `editorial` (reads are not pointer-aware). `DefaultWorkflows::EDITORIAL` (`packages/workflows/src/DefaultWorkflows.php`) no longer defines the `revise` (published → draft) transition; `restore_to_published` (archived → published) ships unchanged. The engine mechanic itself is untouched — `TransitionService`/`WorkflowStateGuard`/`WorkflowPointerMoveGuard` keep full forward-draft support for any workflow that defines a published → draft edge. Engine-coverage tests (`ForwardDraftFlowTest`, `ForwardDraftIntegrationTest`, `GuardWiringTest`, `WorkflowStateGuardTest`) are re-anchored on a test-local `editorial_forward` workflow (the descoped `EDITORIAL` shape plus its own `revise` transition, persisted directly by the tests) instead of the boot-seeded shipped workflow. New `DefaultWorkflowsTest` pins the shipped `EDITORIAL` const to have no transition whose `from` includes `published` and whose `to` is a non-default-revision state — i.e., no shipped forward-draft entry edge. `packages/workflows/README.md` rewritten around the deferral (see `docs/specs/content-workflow.md`, "Deferred: forward drafts on the shipped workflow").
- **CW-v1 WP-2 rework task 2: boot seed additively tops up a persisted `editorial` workflow — upgraded installs gain `restore_to_published` (#1920).** `WorkflowServiceProvider::seedDefaultEditorialWorkflow()` (`packages/workflows/src/WorkflowServiceProvider.php`) previously returned early whenever `editorial` already existed, so any install that first booted before `restore_to_published` shipped (alpha.256 and earlier — dev skeleton, staging, minoo.live) kept the stale shape forever, leaving archived content permanently unrecoverable there. The seed now additively merges: any state/transition present in `DefaultWorkflows::EDITORIAL` but absent BY MACHINE NAME from the persisted entity is added; nothing already present is ever modified or removed (an operator-customized `permission` string survives untouched), and a steady-state install (nothing missing) performs no save at all. Validated with `WorkflowValidator` before saving — log-and-skip on violation (`workflows.default_seed_topup_invalid`), never boot-crash; success logs the added ids (`workflows.default_seed_topup`). Deleting a shipped state/transition from the persisted entity is re-added at the next boot — this is the floor, not a ceiling; operators customize by editing existing entries or binding their own workflow id instead.
- **CW-v1 WP-2 rework task 3: `workflows:backfill-state` establishes the published pointer for published rows (#1920).** Following the pre-rework Playbook H exactly left every legacy published row `status=1, workflow_state='published', published_revision_id=NULL` forever, with no sanctioned code path able to set the pointer afterward — pointer-dependent semantics silently degraded to never-published. `WorkflowsBackfillStateHandler` (`packages/cli/src/Handler/WorkflowsBackfillStateHandler.php`) gains a pointer-establishment phase, gated on the entity type being revisionable AND the workflow resolving a published `default_revision: true` state: for every examined row (newly stamped this run, or already carrying that state from a prior run — the WP-1 tail) whose `loadPublishedRevision()` is still null, it calls `EntityRepository::setPublishedRevision()` to point the published pointer at the row's current revision id — the sanctioned pointer door, so `WorkflowPointerMoveGuard` sees this backfill too. Idempotent (an already-set pointer is left alone); a row with no revision history yet is counted and reported separately rather than failing the command; `--dry-run` reports the would-establish count with zero writes, the real run reports the established count and folds per-row failures into the existing nonzero-exit accounting.
- **CW-v1 WP-2 rework task 4: `TransitionDeniedException` maps to 403/422 JSON:API error documents, never 500 (#1920).** `WorkflowStateGuard` throws `TransitionDeniedException` from PRE_SAVE inside `EntityRepository::save()`; `JsonApiController` (`packages/api/src/JsonApiController.php`) previously caught only `UniqueConstraintViolationException`/`RevisionConflictException`/`EntityValidationException`/`\LogicException`, so a routine editorial mistake (e.g. an illegal `workflow_state` PATCH) surfaced as an uncaught HTTP 500. All three save sites (`create()`, `update()`'s plain save, `saveWithExpectation()`) now catch `TransitionDeniedException` and route through a shared `workflowTransitionDeniedError()` mapper: `REASON_PERMISSION` → 403 Forbidden, every other reason (`illegal_edge`, `unknown_transition`, `unbound`) → 422 Unprocessable Entity, both carrying `code: 'WORKFLOW_TRANSITION_DENIED'` and `meta: ['reason' => …]` (mirrors the `REVISION_CONFLICT` code/meta pattern). `waaseyaa/workflows` is a declared runtime `require` of `waaseyaa/api` (L4 importing L3 is layer-legal), so the catch is a real, always-resolvable dependency rather than an FQCN-string guess.
- **CW-v1 WP-2 rework task 5: the published revision is immortal in `pruneRevisions()`/`deleteRevision()` (#1920).** Both `EntityRepository::pruneRevisions()` and `RevisionableStorageDriver::deleteRevision()` (`packages/entity-storage/src/EntityRepository.php`, `packages/entity-storage/src/Driver/RevisionableStorageDriver.php`) previously immortalized only the current/default revision; the published revision was a deletable pruning/delete candidate, which would silently flip an entity to never-published semantics once published ≠ current became the routine forward-draft state. Both paths now also exclude the published pointer (`published_revision_id`) — `pruneRevisions()` inline in its executor loop (without changing `RevisionPruningPolicy::candidateExcluded()`'s `@api` signature), `deleteRevision()` via a `SELECT *` read that tolerates pre-WP-2 base tables lacking the column entirely (behaves exactly as before: only the current-revision guard applies, no SQL error). `docs/specs/revision-system-unified.md`'s pruning/deletion contract documents the extended FR-038 rule.
- **CW-v1 WP-2 rework task 6: runbook + spec + CHANGELOG truth-up for the substrate descope (#1920).** `docs/specs/operations-playbooks.md` Playbook H is retitled/reframed as the WP-2 SUBSTRATE activation runbook: step 5 no longer instructs binding `node` → `editorial` in production (forward drafts are deferred — non-production/evaluation environments may still bind to exercise the engine); a new Failure mode 3 documents that running `revisions:enable` before (or without) a successful `migrate:up` silently folds the pointer columns into the `_data` blob instead of erroring, with a verification-gate SQL query inserted between steps 2 and 3; step 4's "What gets written" section documents task 3's pointer-establishment phase; the revision-pruning stance paragraph notes task 5's published-revision exclusion. `docs/specs/content-workflow.md`'s WP-2 status-table row, config sample, and `revise` paragraph are rewritten around the descope, the integration-spine paragraph now names the test-local `editorial_forward` workflow, and a new "Deferred: forward drafts on the shipped workflow" subsection lists what the option-1 follow-up owns (read-side pointer awareness, write-side field allowlist, rollback/`setCurrentRevision()` stale snapshot writeback, first-publish edge rule on review-required workflows). CHANGELOG entries for tasks 2.6 and 2.8 amended in place rather than rewritten, per house style.
- **CW-v1 WP-2 rework fix wave: `rollback()`/`setCurrentRevision()` no longer clobber the live published pointer or status (#1920).** The fresh-context final review (`.superpowers/sdd/rework-final-review.md`, finding I-1) found the rework's own task-3 backfill supplied the missing precondition (a populated `published_revision_id`) for a pre-existing containment gap: both `EntityRepository::rollback()` and `EntityRepository::setCurrentRevision()` wrote the target revision's frozen `published_revision_id`/`status` snapshot verbatim onto the base row, silently overwriting the live pointer/status with no guard and no audit trail — reachable via the shipped MCP tools `entity.rollback`/`entity.set_current_revision` with no workflow binding required. Both methods now reuse the base row already read for the pre-write event and, after dispatching `BeforeRevisionPointerMoveEvent`, overwrite `published_revision_id`/`status` in the outgoing row with the live base values (unset when the base row lacks the column, preserving legacy-schema behavior exactly) — restoring CONTENT only, never the pointer, per the invariant that moving the pointer/flipping status is exclusively `TransitionService`'s job. Docs disclosure: `operations-playbooks.md` Playbook H names the invariant near step 4's pointer-establishment text and adds an evaluation-binding caveat for the still-deferred write-side field allowlist (finding I-2); `content-workflow.md`'s Purpose section caveats the forward-draft parity claim, and its "Deferred" subsection moves the rollback/`setCurrentRevision()` bullet from owned-by-follow-up to fixed.