Accepted
ADR 0041 — Site-wide restore workflow
The RestoreVersion workflow rolls the whole site back to a chosen timestamp by minting new versions forward, never rewriting history
Site-wide time travel turns a chosen timestamp into live state: roll back this morning's bad publish, or the whole site to last Tuesday. The RestoreVersion workflow walks every versioned entity, reads its state at the target instant, and writes that state back. Because version history is insert-only, restore cannot rewrite the past — so it works by minting new versions forward.
Restore semantics — forward-mint, not history-rewrite
For each in-scope entity at target timestamp T, the workflow reads state-at-T through the entity-versions repository's findAt primitive, then:
- If state-at-T differs from the live row, mint a new version carrying that state through versionedWrite(), tagged restore:{runId}:{T}.
- If the live row already matches state-at-T after canonicalisation, no-op. Replaying the same restore produces only no-op writes.
- If the entity did not exist at T but exists now, delete it through the type's versioned delete path when one exists; types without a delete primitive are left untouched and counted in unsupported_delete_skips so the operator can see what was not removed.
The restore is itself a versioned write, so the version-history UI shows it as a regular entry. Insert-only history is preserved end to end, and the idempotency makes a retried restore safe.
Safe defaults for sensitive entities
Five audit-trail types — user, role, permission, user_role, api_token — default into the skip set. Restoring them is destructive: a token revoked between T and now would be re-minted with its old hash. The admin surfaces them as unchecked with a warning, so a deployer can opt in to a deliberate permission rollback but never trips into resurrecting revoked credentials by accident. The opt-out is a workflow parameter, which keeps the policy testable and replayable independent of the UI. plugin_config is deliberately not in the default skip set, because "roll back this morning's bad plugin reconfiguration" is a common reason to restore — though as shipped it is one of the three deferred types without a restore adapter yet, so in v1 it is skipped regardless of the skip-set choice.
Atomicity, cursor, and concurrency
Each per-entity versioned write is the atomic unit. After each write the workflow advances a cursor (last processed type and id) in a restore_runs row; on retry it resumes from the cursor. The restore is not all-or-nothing — Cloudflare Workflows cannot hold a transaction open across minutes and many entity types — so a failed run leaves a consistent partial restore recorded in restore_runs, resumable by re-kicking from the admin. The idempotency check closes the narrow window where an entity is written but the cursor update fails: the replay observes the matching state and skips.
Site-wide restore is a singleton. A partial unique index on restore_runs where status is running blocks a second concurrent restore; the admin route maps the conflict to 409 RESTORE_ALREADY_RUNNING. A deployer wanting a partial restore (roll back documents while keeping today's settings) passes the unwanted types in the skip set against the same singleton workflow. restore_runs is an operational table, not versioned.
Walk order and spillover
The forward-mint pass walks entity types in foreign-key-parent-before-child order — users and roles before permissions, collections and field definitions before documents — so creates and updates never violate a constraint. A second, reverse-order pass handles deletes, because children must go before parents. R2 spillover needs no special handling: the read primitive rehydrates spillover blobs and the write primitive owns the spillover decision, both already governed by the versioned-write contract.
Consequences
- Positive — site-wide time travel becomes an invokable operation; the admin wires a "restore site to this point" affordance to it.
- Positive — insert-only history is preserved; the restore is a normal version entry, and the cursor-resume pattern reuses an established precedent.
- Negative — the restore is not all-or-nothing; a partial failure leaves a recorded, resumable partial restore.
- Negative — restoring auth entities does not invalidate active sessions; the admin's own session may run against a stale permission set until re-login.
Rejected alternatives: history-rewriting restore (breaks the insert-only invariant and loses the audit trail), an all-or-nothing transaction (impossible to hold across the workflow's span), per-entity step granularity (explodes the workflow step namespace), and always including audit-trail entities (the dangerous default).