Skip to content

Anchoring & resume

How a wizard binds to an existing record (anchoring), how a running wizard is identified, and how a user resumes where they left off.

Anchoring

An anchored wizard runs against an existing record — the analogue of attribute :resource on an interaction. The anchor is read-only context, available from any step (and condition:/on_submit/execute) via the anchor accessor.

ruby
class ConfigureCompanyWizard < Plutonium::Wizard::Base
  anchored with: Company           # operate on a Company

  step :branding, label: "Branding" do
    attribute :logo, :string
    input :logo
    on_submit { anchor.update!(logo: data.branding.logo) }   # mutate the anchor
  end

  def execute
    anchor.update!(configured_at: Time.current)
    succeed(anchor)
  end
end

Forms of anchored

DeclarationMeaning
anchored with: CompanyA single concrete type.
anchored with: [Company, Organization]Polymorphic — accepts any listed type.
anchored (no with:)Generic — the type binds at registration to whichever resource hosts it (shareable library wizard).
(omit anchored)No anchor — a pure data → create flow.
ruby
# A generic, shareable wizard — bound to a concrete type at registration.
class ArchiveWithReasonWizard < Plutonium::Wizard::Base
  anchored

  step :reason do
    attribute :reason, :string
    input :reason, as: :textarea
    validates :reason, presence: true
  end

  def execute
    anchor.update!(archived_at: Time.current, archive_reason: data.reason.reason)
    succeed(anchor)
  end
end

anchor raises when absent

ruby
anchor   # => the record, for an anchored wizard
         # => raises Plutonium::Wizard::NotAnchoredError, for a non-anchored one

anchor never returns nil. Anchored-vs-not is a static property of the wizard, so reaching for anchor when the wizard isn't anchored is a programming error, not a runtime condition to guard.

The anchor is not part of persistedpersisted holds only records the wizard creates. The anchor is an input the wizard was launched against.

Anchor resolution per surface

The wizard body never cares where the anchor came from; the launch surface resolves it:

  • Record action (wizard :configure, ... on a definition for a with:-anchored wizard) — auto-mounted as a member route (/companies/:id/wizards/configure/:step) on the resource controller. The anchor is resolved through that controller's scoped, policy-gated resource_record! — never an unscoped find_by, so a record outside the portal's authorized scope (or a non-existent id) 404s instead of leaking another tenant's record.
  • Context anchor (anchored via: :method) — mounted portal-level with register_wizard; the anchor is resolved by calling that method on the controller (e.g. via: :current_scoped_entity for the tenant). No URL :id, IDOR-safe (trusted context). An optional with: type-asserts the result.
  • Collection action / create flow — no anchor.

Anchored member routes are IDOR-safe by construction

Because the anchor comes from resource_record! (the same scoped lookup CRUD and interactive record actions use), a with:-anchored wizard can only ever operate on a record the current user is authorized to see in this portal. A via:-anchored wizard is IDOR-safe by trusting the resolved context.

Instance identity

Every running wizard has a deterministic instance key — a digest the session row is uniquely keyed by. There are two recipes, by identity axis (see Identity, concurrency & repeatability):

# concurrency_key set:
instance_key = SHA256(JSON([secret_key_base, "concurrency", wizard, serialized(concurrency_key)]))
# no concurrency_key:
instance_key = SHA256(JSON([secret_key_base, "tokened", wizard, wizard_token]))

The digest hashes the JSON of a structured array, not a flat-joined string, and is salted with the app's secret_key_base:

  • concurrency_key is serialized records → GID, scalars → string, arrays kept as a nested structure (not joined) — so two distinct keys can never collide into one row (["a", "b"]"a|b"). The tenant (current_scoped_entity) is folded in automatically, so the same user running the same keyed wizard in two tenant portals gets two distinct rows.
  • The secret_key_base salt makes the digest a MAC over otherwise-public identifiers (the wizard name + key GIDs), so a run's existence/state can't be probed by recomputing the digest off-app.
  • wizard_token is the per-run id for runs with no concurrency_key — a fresh, unguessable token per launch makes each run distinct and repeatable. Its source depends on the run identity: an authenticated repeatable run carries it in the URL :token segment (guarded by owner-scoping); a guest (anonymous) run keys off the Rails session (never the URL — no leak surface). It is not a pre-auth principal that survives login, and a wizard never crosses the auth boundary mid-flow.

The owner, anchor, and scope are also stored as plain polymorphic columns (owner_type/owner_id, etc.) for listing and querying — but identity is the digest.

Rotating secret_key_base invalidates in-progress runs

Because the salt is secret_key_base, rotating it changes every instance-key digest. In-progress runs become unresumable (their rows no longer match the recomputed key) and one-time gates re-open (the retained completed marker no longer matches). This only affects rows live at rotation time; new runs key off the new secret. Drain or accept the reset when rotating.

Resume

A concurrency_key-keyed wizard's in_progress row is the lock: a second launch at the same key resumes it instead of forking. Look up the row by instance_key; if one exists, the user continues where they left off. A tokened (no concurrency_key) wizard resumes via its per-run id (carried in the URL :token segment for an authenticated run, or the Rails session for a guest run) and starts a fresh run otherwise.

For a non-anonymous (authenticated) wizard, every resume is owner-scoped: a row may only be resumed by the user that owns it. A run id leaked in a URL can't be picked up by another logged-in user — the engine treats a foreign row as not-found (404). See Authentication.

Listing in-progress wizards

ruby
Plutonium::Wizard.in_progress_for(view_context, anchor: nil, wizard: nil)
#   → Array<Resume::Entry>, newest-first   (delegates to Resume.entries_for)

Like interactions, it takes the view_context and derives everything from it — the run owner (current_user), the tenant scope (current_scoped_entity when scoped_to_entity?, else nil), and the portal — returning that owner's in-progress runs for the current portal.

Each entry exposes: label, icon, current_step (+ current_step_label), updated_at, resume_url (or nil), resume_unresolved_reason, wizard_class, and the raw session row.

Portal scoping. A run is only ever listed (and linked) by the portal it was launched in — a non-scoped portal lists only unscoped runs; a scoped portal narrows to the current tenant. Two portals can share an entity scope, so the launching portal (the engine column) is recorded per-run because scope alone can't identify it.

resume_url is built through the current portal's routes:

  • wizard-macro anchored mount → resource_url_for(record, wizard:, step:).
  • register_wizard mount → the named route (with the scope segment, and the :token for tokened runs).
  • Unresolvable here (e.g. a non-anchored wizard-macro run, whose resource identity isn't on the row) → resume_url: nil + a resume_unresolved_reason string. Render those without a link rather than guessing.

anchor: / wizard: filters (for a per-record resume widget — "does this record have an unfinished draft of wizard X?"):

  • They narrow in the query, before enrichment, so discarded rows are never resume-URL-resolved or anchor-loaded — cheaper than filtering the returned array.
  • They compose, and the wizard + anchor pair is index-covered: in_progress_for(vc, wizard: ConfigureCompanyWizard, anchor: record).first. wizard: takes the wizard class.
  • For ad-hoc post-filtering the array still works (select { |e| e.wizard_class == X } is free — the class is on each entry). Avoid filtering on e.session.anchor (a polymorphic load per row) — use anchor:.

See the guide for a worked dashboard example.

The implied anchored key

An anchored wizard with no explicit concurrency_key is keyed by default — { [anchor, current_user] }, with the tenant folded in. So an anchored wizard, out of the box, is one in-progress draft per user per record: re-launching it for the same record resumes your draft, and two users editing the same record get independent runs (no collision).

This is the right default because anchoring without keying is a footgun in both directions: a tokened anchored wizard forks a new run on every launch, while { anchor } (record-only) keys across users — so a second user editing the same record collides with the first and is owner-scoped out (a 404). The implied key threads the needle. The anchor's GlobalID is already globally unique (and pins the tenant for a tenant-scoped record), so [anchor, current_user] is the full identity; the auto-folded tenant is redundant there but load-bearing for non-anchor keys.

Override when you want different semantics:

  • concurrency_key { anchor } — a true singleton: one run per record, any user ("configure this once, by anyone"). A concurrent second user is blocked (owner-scoped) until the first finishes.
  • concurrency_key { wizard_token } — make the anchored wizard repeatable (a fresh run per launch).

Anonymous (guest) anchored wizards are exempt — a guest has no real user to key by, so they stay session-tokened.

Relaunching a tokened wizard

A keyed wizard auto-resumes (its keyed row is the lock), so a bare launch always continues the single in-progress run. A tokened wizard has no such single run — each bare launch could mint a fresh one. By default it doesn't silently fork: it prompts. Opt out for flows that should always start clean:

ruby
on_relaunch :new

With the default (on_relaunch :prompt), a bare launch (e.g. GET /onboarding) checks the user's pending runs (owner- and tenant-scoped, via the same listing as above). If any exist, it renders a "resume or start new" page (each pending run with a Resume link, plus a Start new button) instead of silently discarding that in-progress work. With no pending runs it starts fresh as usual, and Start new (the bare launch URL with ?new=1) always forces a fresh run. Because the chooser only appears when a pending run exists, :prompt is a safe superset of :new. Use on_relaunch :new to opt out (always fork a fresh run) for flows meant to be run repeatedly from scratch.

This only applies to authenticated tokened wizards: keyed wizards already auto-resume, and anonymous (guest) runs are session-keyed to a single run — on_relaunch is a no-op for both.

On resume the engine:

  • Restores the step cursor and data (typed snapshot rehydrated from the JSON column).
  • Re-renders the current step's form seeded from staged data — including repeater rows (a structured_input ..., repeat: step re-renders the right number of filled rows, not one blank row).
  • Lazily rehydrates persisted[:key] from stored GlobalIDs on first access (memoized per request), so a per-step on_submit create flow returning later still sees records made by earlier steps — without paying a GlobalID.locate on requests that never read persisted.

Navigation never loses data: Back moves the cursor without validating and never discards data. A step whose answer is later un-chosen (its condition: flips false) leaves the visible path and is fully pruned: its staged data is dropped, and if its on_submit had persisted records (save-as-you-go) those records are rolled back: its on_rollback runs first if declared (additive side-effect cleanup), then the engine always destroys them, so nothing is orphaned. The step's persisted / data / visited state is cleared, so re-entering that branch re-runs its on_submit from scratch. Pruning fires as soon as the branch is hidden (during the advance that flips it) and again as a safety net at finalize.

Authentication

Wizards require authentication by default. Entry without a current_user is rejected (redirect to login / 401). A wizard never crosses the auth boundary mid-flow.

  • Default (authenticated). current_user is required throughout. Every session lookup/resume is owner-scoped (where(owner: current_user) + an owner check), so a run id leaked in a URL can't be resumed by another logged-in user. current_user comes from the host: a portal mount inherits the portal's auth concern, while an authenticated main-app mount requires an app-defined ::WizardsController carrying the auth concern (see the override hook).
  • anonymous (guest). Opt in with the anonymous macro. The wizard may run with no current_user; its identity is the server-minted, unguessable wizard_token held in the Rails session (session["plutonium_wizards"][<wizard_key>]): not a cookie, no TTL (the row's cleanup_after → sweep is the authoritative lifetime; the session id is just a pointer). Session storage gives browser-close ephemerality, auto-clear on login/logout (Rodauth's clear_sessionreset_session), and clearing on completion; the id never appears in a URL. It guards only the user's own in-progress data. Its terminal execute may authenticate (e.g. a signup flow that creates the account and logs in); that login goes through Rodauth, which rotates the Rails session. There is no mid-flow owner-stamping, token-survives-login, or instance_key rekey.
ruby
class GuestSignupWizard < Plutonium::Wizard::Base
  anonymous                       # may run pre-login

  step :account do
    attribute :email, :string
    input :email, as: :email
    validates :email, presence: true
  end
  review label: "Review"

  def execute                     # the ONE boundary a guest wizard may cross
    account = Account.create!(email: data.account.email)
    # sign the account in here (the host calls Rodauth) — no special framework handling
    succeed(account)
  end
end

An anonymous wizard must be mounted on a public route.

Released under the MIT License.