Skip to content

Storage & config

The wizard subsystem is DB-backed by a single framework table, gated by an opt-in config flag. This page covers enabling it, the table, configuration, encryption, and the cleanup sweep.

Enabling the subsystem

Wizards are core code, but the storage table is opt-in so apps that don't use wizards stay schema-clean.

ruby
# config/initializers/plutonium.rb
Plutonium.configure do |config|
  config.wizards.enabled = true            # false by default
  config.wizards.cleanup_after = 14.days   # global default idle TTL for the sweep
  config.wizards.encrypt_data = true       # encrypt every wizard's data at rest (needs AR encryption keys)
  config.wizards.attachment_backend = nil  # server-side attachment staging backend (nil = auto-detect)
end
bash
rails db:migrate
ConfigDefaultMeaning
config.wizards.enabledfalseThe subsystem's master switch. Registers the gem migration (so rails db:migrate creates the table) and draws wizard routes — both register_wizard and the resource-mounted wizard-macro actions. While false, register_wizard is a no-op (it logs a warning so a registered-but-disabled wizard isn't a silent 404) and no wizard routes are mounted. Required to use wizards.
config.wizards.cleanup_after14.daysGlobal default idle TTL for the abandonment sweep; overridable per wizard via cleanup_after.
config.wizards.database:primaryWhich database connection the wizard table lives on. v1 supports the primary database only — see below.
config.wizards.encrypt_datafalseEncrypt every wizard's staged data at rest by default. Off by default because it needs ActiveRecord encryption keys; a wizard still overrides it individually with encrypt_data / encrypt_data false. See Encryption.
config.wizards.attachment_backendnilStorage backend for server-side attachment staging (a plain as: :file field). nil auto-detects — active_shrine installed → :shrine, else :active_storage. Override per field with input …, backend:. Direct-upload fields ignore it (they arrive as a token).

Gem-shipped migration

The migration ships in the gem and Rails runs it in place — there is no copy-into-your-app step (unlike pu:rodauth/pu:invites, which are app-customized templates). Enabling config.wizards.enabled registers the gem migration path; rails db:migrate then runs it.

  • Once run, the table is dumped into your schema.rb / structure.sql like any other, so db:schema:load on fresh/CI databases recreates it normally.
  • Disable later → the path isn't registered; the existing table is left alone (never auto-dropped).
  • db:migrate:status shows the migration's file living in the gem (cosmetic; reads "file missing" if the gem is later removed) — standard for gem-shipped migrations.

v1 supports the primary database only

The wizard table lives on your app's primary database in v1. config.wizards.database is reserved for future use — multi-database routing for wizard sessions is a roadmap follow-up. Setting it to anything other than :primary (while wizards are enabled) raises at boot, rather than silently registering the migration on the primary database.

The table — plutonium_wizard_sessions

One framework-owned table serves everything; no changes to your models.

ColumnPurpose
wizardThe wizard class name.
statusin_progress | completing | completed (see the note below).
current_stepThe step cursor.
instance_key (unique)The deterministic identity digest (see Anchoring & resume).
owner_type / owner_idThe user (nullable — null for an anonymous/guest run). Authenticated lookups are owner-scoped against this.
anchor_type / anchor_idThe anchor record (nullable).
scope_type / scope_idThe portal scoping entity / tenant (nullable).
engineThe portal (engine class name) the run was launched in, e.g. "OrgPortal::Engine". The "continue where you left off" listing only shows (and links) runs whose engine matches the portal being viewed (two portals can share an entity scope, so scope alone can't identify the portal).
tokenThe per-run id for guest/tokened (no-concurrency_key) instances (nullable).
dataStaged field values (JSON; jsonb on PostgreSQL).
tracked_recordsGlobalIDs of records registered via persist, by step key. Exposed to authors as persisted[:key].
visitedVisited step keys.
expires_atConcrete expiry, stamped now + cleanup_after on every write (nil = :never).
completed_atCompletion timestamp.

What the single table powers:

  • Resume — look up the in_progress row by instance_key.
  • One-time check — does a completed row exist for (wizard, owner) or (wizard, anchor).
  • In-progress listing — by owner, portal (engine), and tenant scope, so a run is only ever listed by the portal it was launched in.
  • Multi-tenancy — the portal scoping entity is folded into instance_key and stored as scope_*, so the same user's same non-anchored wizard doesn't collide across tenants.
  • Sweep — idle in_progress/completing rows past expires_at.

The persisted / tracked_records naming

The column is tracked_records, not persisted — an AR attribute named persisted collides with ActiveRecord::Persistence#persisted?. The author-facing accessor stays persisted[:key]; the store maps it to the column.

Encryption

A wizard may opt into encrypting its staged field values at rest, for flows that stage PII:

ruby
class CheckoutWizard < Plutonium::Wizard::Base
  encrypt_data
  # ...
end

This encrypts the data column (the staged step values) — off by default. The tracked_records column (record GlobalIDs only) and the queried owner/anchor/scope/token columns stay plaintext.

Encrypt everything by default. Once your app has ActiveRecord encryption keys, you can flip encryption on for all wizards with one global flag, then override per wizard:

ruby
config.wizards.encrypt_data = true   # every wizard's `data` is encrypted at rest
ruby
class PublicSurveyWizard < Plutonium::Wizard::Base
  encrypt_data false   # explicit opt-OUT, even when the global default is on
end

Resolution: an explicit encrypt_data / encrypt_data false on the wizard always wins; a wizard that declares neither inherits config.wizards.encrypt_data (off unless you set it). It stays opt-in globally because it requires keys — see the warning below.

How it works. Because data is one shared jsonb column across all wizards (some opting in, some not), a static model-level encrypts :data doesn't fit (it would encrypt every row, and fights the jsonb type). Instead, the store encrypts at write time using ActiveRecord's configured encryptor (ActiveRecord::Encryption.encryptor, the same keys as encrypts) and stores a self-describing envelope inside the column:

json
{ "_enc": "<ciphertext>" }

A row therefore decrypts based on its own shape, independent of the wizard's current encrypt_data? — so toggling the flag never strands existing runs.

Requires ActiveRecord encryption keys

encrypt_data reuses your app's ActiveRecord encryption keys (active_record.encryption.primary_key / deterministic_key / key_derivation_salt, typically via credentials). If a wizard declares encrypt_data but no keys are configured, the first write raises a Configuration error naming the wizard — rather than ActiveRecord's later, context-free failure. Set the keys (bin/rails db:encryption:init) before enabling it.

Files

A file can't sit in the JSON data column, so a wizard stages only the backend's upload token (an ActiveStorage signed_id, or Shrine cached-file data) and execute assigns it to the model's attachment. This works for both server-side and direct uploads, ActiveStorage and active_shrine. See DSL › Attachment fields and the guide for the full surface (backend:, multiple:, direct upload).

A staged-then-abandoned upload is an unattached blob / cached Shrine file. Each storage backend's own unattached-cache cleanup reaps it — the wizard SweepJob does not (it only tracks records registered via persist). Ensure that backend cleanup runs.

Cleanup & the SweepJob

cleanup_after stamps a concrete expires_at (now + ttl) on every write, so an actively-progressing wizard keeps pushing its expiry forward. A later change to the wizard's TTL never retroactively shifts existing rows. cleanup_after :never stores a null expires_at, opting out of sweeping (partial records persist by design).

Plutonium::Wizard::SweepJob reaps idle in_progress / completing rows past expires_at: for each it runs the wizard's cleanup (each step's on_rollback if declared, then always destroy every tracked record, in reverse order) and deletes the row. Completed rows are never touched. The job is idempotent and safe to re-run.

The completing state and its grace window

A healthy finalize flips the row to completing and runs execute outside the completion lock (so a long execute doesn't block other requests), without bumping expires_at. To avoid sweeping a finalize that's still running, the sweep only reaps a completing row once it's been idle for a 15-minute grace window — long enough that a still-completing row past it is a crashed finalize, not a slow one. Keep individual executes well under 15 minutes (offload long work to a job).

SweepJob is load-bearing for save-as-you-go

Schedule the SweepJob

  • For execute-only wizards, an unscheduled sweep merely leaves stale session rows (harmless).
  • For on_submit (save-as-you-go) wizards, the sweep is the only thing that cleans up abandoned real domain records. Without it, partial records accumulate forever.

Schedule Plutonium::Wizard::SweepJob as a recurring job (e.g. via your scheduler / cron / solid_queue recurring tasks) for any app that uses on_submit.

ruby
# e.g. a recurring job
Plutonium::Wizard::SweepJob.perform_later

On completion of a one-time wizard, the row is kept as the durable marker but its data / tracked_records are nulled out (privacy + size).

Released under the MIT License.