Runtime & designer
This page covers what happens when a wizard runs: how the definition is stored, how a step’s SQL is resolved and executed, the HTTP surface the runner calls, and the visual designer that authors it.
Storage and lifecycle
Section titled “Storage and lifecycle”A wizard definition is a WizardForm row in the Wizard.Forms table — Name, Slug,
Label, Schema, the full FlowXml, a VersionHash, and the storage fields below.
Submitted runs go into one physical table per wizard, Wizard.Resp{Name} — not a shared table.
Each row holds the collected values as JSON in ResponseData, plus a real, indexable column for every
field the author marked Indexed.
Wizards are not part of the model-migration pipeline. The build does not copy *.wizard.xml
into bin/models; instead you import the XML through the designer (or POST /save) and it is
persisted to the database. Keep the XML in source control as the reference copy.
The response table
Section titled “The response table”Field on Wizard.Forms |
Meaning |
|---|---|
ResponseTable |
The physical table name, e.g. RespNewProductRequest. Derived from Name on first save and then frozen. |
SchemaHash |
Short (6 lowercase hex) hash of the table’s shape — its field set and indexes. |
ProvisionedSchemaHash |
The shape last successfully applied to the database. |
ProvisionState |
Pending | Ready | Failed. |
ProvisionError |
Why the last attempt failed, or null. |
Because the table holds submitted data, its name is never changed once set — and therefore
Name becomes immutable as soon as the table exists. A save that tries to change it is rejected
with an explanatory error. Label and Slug stay freely editable; Label is what users see.
Deleting a wizard is a soft delete, and the wizard keeps its table and the responses in it. Saving
or importing a wizard with the same Name again, with no Id, revives that deleted wizard with its
original response table, so earlier submissions come back with it. A different wizard whose name happens
to produce the same table name gets a numeric suffix instead (RespPMChecklist2).
Ids come from a single shared sequence, Wizard.ResponseIds, so a response id identifies exactly one
row across every table — which is what lets a workflow instance keep pointing at a response by id alone.
Provisioning
Section titled “Provisioning”Applying a definition’s shape to the database is queued, not inline: a designer save returns
immediately and a background worker (WizardStorageWorker) does the DDL, recording the outcome in
ProvisionState. Creating the table and adding its projected columns is metadata-only and effectively
instant; building an index over a table that already holds millions of rows is not, which is exactly why
a save must not block on it.
The same worker sweeps every wizard once at startup, so the response tables and their generated views are reconciled on boot as well. It appears on the hosted-services dashboard as Wizard Storage Worker, with how many wizards each cycle applied and how many failed, and it starts only once model migration has finished.
DDL is gated on SchemaHash, so an unchanged wizard costs nothing: moving a node on the canvas or
editing a label mints a new VersionHash but leaves SchemaHash alone, and no DDL runs.
One thing is applied outside that gate: the IsSyncable flag and trigger that
warehousing reads by. They are not part of the definition, so
a wizard whose fields never change would otherwise never receive them. They are tracked separately in
Genie.ExecutedScripts as Wizard.Resp{Name}.Warehousing, which makes an already-installed table one
row read per boot — and lets the engine redeploy the trigger body across every wizard without disturbing
a single shape hash. Until that record exists, the table is not offered to the warehousing sweep at all,
so the two background workers racing at startup cannot produce a read against a column that is seconds
from existing.
| Dialect | Indexed field is stored as |
|---|---|
| SQL Server | a non-persisted computed column, JSON_VALUE(ResponseData, '$.Field'), with an index on it |
| PostgreSQL | an expression index directly on the jsonb payload — no extra column |
The asymmetry is forced by the platforms: SQL Server cannot index an expression, so it needs the computed column; PostgreSQL can, and going that way avoids rewriting the table on every schema change.
Versioning
Section titled “Versioning”Two hashes, answering two different questions:
VersionHash— which definition produced a response. A fresh 6-char hash is minted on every save that changes theFlowXmlat all, and the XML is snapshotted into the sharedWorkflow.FlowVersionshistory (viaIFlowVersionService). The designer’s History button lists versions and can load an old one read-only.SchemaHash— which storage shape captured it. Derived only from the field set and the declared indexes, so it is stable across cosmetic edits. This is the DDL re-run trigger.
Every submitted response is stamped with both.
The generated response view
Section titled “The generated response view”Once a wizard’s table is provisioned, Genie registers a Genie table view over it, named after the
table (Resp{Name}), so submissions are browsable at /table/Resp{Name} with no hand-authored view
and no extra endpoint. Going through an object view rather than a raw SQL view is what earns the response
grid the whole object stack — RBAC verbs, ParameterSanitizer, row-level security, session parameters,
filtering, sorting, paging and export.
The grid projects the indexed fields plus the response metadata (status, reference, both hashes, submitted/updated by and when), shows only the latest response in each edit lineage, and is scoped to the active company. Only indexed fields are searchable — a search over anything else would be a table scan, which is what declaring an index is meant to avoid.
Execution
Section titled “Execution”The runner drives the node graph and calls back to the server whenever a step needs data or SQL.
WizardExecutionService is the backend engine; it locates the relevant block in the FlowXml,
substitutes variables, and runs the SQL.
Variable resolution
Section titled “Variable resolution”Field values arrive keyed as NodeId__FieldName. Before executing any SQL, the service
(PrepareVariablesForSql) builds the substitution scope:
- copies the supplied variables;
- adds a bare
FieldNamealias for eachNodeId__FieldName(so@FieldNameworks); - seeds every declared field/Startup variable that wasn’t supplied as
DBNull(→ SQLNULL); - injects session parameters (
@SessionUserId,@SessionCompanyId,@SessionCartId).
Substitution replaces each @Token with a formatted literal (strings single-quote-escaped and
quoted, booleans as 1/0, null/DBNull as NULL), longest key first so @SessionCompanyId
resolves before @Session, matched on word boundaries so @Email won’t match inside
@EmailAddress. See the substitution rules in Authoring a wizard.
Submission
Section titled “Submission”POST /{id}/submit (WizardExecutionService.SubmitAsync) does the following, in order:
- moves any
Attachmenttemp uploads to permanent storage; - resolves the wizard’s
ResponseTable(rejecting the submit if provisioning hasFailed); - transforms
NodeId__FieldNamevariables to bareFieldNameand inserts a row intoWizard.Resp{Name}(JSONResponseData,Statusfrom the Terminal’sSubmissionStatus, and both the currentVersionHashandSchemaHash). Unlike the authored SQL blocks, this write binds real parameters rather than substituting text; - runs
<TerminalSql>if present; - workflow side — mutually exclusive:
- new run → if the Terminal declares a
WorkflowName, starts that workflow viaIWorkflowEngine.StartAsync(workflowName, "WizardFormResponse", responseId, formData); - continue → if the submission carries a
FlowId(edit/resubmit), fires the resubmission transition on the existing instance viaTransitionAsyncand repoints it at the new response.
- new run → if the Terminal declares a
In both workflow cases the instance also records EntityTable — the schema-qualified table its
EntityId points into. EntityType alone no longer identifies a table now that each wizard has its
own, and it is this column that lets the task grids resolve the originating wizard in one hop.
Edit mode and lineage
Section titled “Edit mode and lineage”Loading a prior response for edit is gated: only the latest response in a lineage
(UpdatedTo == null) may be edited; superseded ones return 404. On resubmit, the prior response’s
UpdatedTo is pointed forward to the new row, forming an immutable edit chain — guarded inside the
UPDATE itself, so two concurrent edits cannot both claim to supersede the same response. A FlowId
requires an UpdatedForResponseId, must reference an active instance bound to that
WizardFormResponse, or the submit is rejected.
Because each wizard owns its table, a response id belonging to another wizard simply is not found — the ownership check is now structural rather than an explicit comparison.
API surface
Section titled “API surface”All endpoints are under /api/v1/genie/wizard and require authentication ([Authorize]).
| Method + route | Purpose |
|---|---|
GET /list |
List every wizard as a lightweight summary (id / name / slug / label). |
GET /{key} |
Load a wizard by id, Name, or Slug (the runner’s entry call). |
GET /{id}/response/{responseId} |
Load a prior response for edit mode (latest-in-lineage only). |
POST /save |
Create or update a wizard from { Id?, Name, Slug?, Label, Schema?, FlowXml, Notes? } (upserts by Name). |
GET /{id}/versions |
List saved versions, newest first (History picker). |
GET /{id}/versions/{versionHash} |
Fetch one historical version’s XML (read-only load). |
DELETE /{id} |
Soft-delete a wizard. |
POST /{id}/options/{fieldName} |
Resolve a Select/Radio field’s OptionsSql (body = current variables). |
POST /{id}/field-dataset/{fieldName} |
Searchable dataset for a ModalSelector/Select2 field ({ SearchText?, Variables? }). |
POST /{id}/execute/{nodeId} |
Run an ExecuteSQL action node’s SQL (auto-advance). |
POST /{id}/set-variable/{nodeId} |
Run a SetVariable SQL action and return the scalar. |
POST /{id}/submit |
Submit the wizard: save the response, run terminal SQL, start/continue the workflow. |
Responses use the standard SuccessDataResult<T> envelope. Field names are unique across the
wizard, so the options/dataset/execute endpoints address a field or node without ambiguity.
The runner (UI)
Section titled “The runner (UI)”GenieWizardRunner (driven by the useWizardRuntime hook) executes a wizard for an end user at
/form/{slug} (a wizard is a multi-step form; legacy /wizard/{slug} still resolves). It:
- loads the definition via
GET /{key}, parses theFlowXml, and finds the start node; - walks the graph in an auto-advance driver — Startup, Decision and non-alert Action nodes run
silently (calling
execute/set-variableas needed); the loop stops on an Input step, anAlert, or a Terminal; - renders each Input step with the shared form field controls and layout (GenieField + the form layout engine), pointed at the wizard’s options/dataset endpoints, with a progress bar and Required-field validation;
- shows a read-only summary of every visited step for review, then submits;
- supports edit mode via
responseId(supersede a prior response) andflowId(continue an existing workflow instance), re-walking only the branches the original submission took.
The designer (UI)
Section titled “The designer (UI)”GenieWizardDesigner (route /wizard-designer) is the visual node-graph editor. It provides a
drag-and-drop canvas with the five node types (Startup, Input, Decision, Action, Terminal), port-to-port
link drawing, per-node property panels (fields, variables, action config, terminal handoff), and a
raw-XML code editor — the model round-trips losslessly between the canvas and the <Wizard> XML.
Saving posts to POST /save; the History icon button (next to the XML icon button) opens the version list. Saved wizards are
listed by the registered object view at /object/wizard-definitions.
A toolbar search box finds any node or field by its name or label: matching is debounced, the matched text is highlighted in yellow, matching nodes get a highlight ring so they stand out when zoomed out, and a live match count sits beside the box. Press Enter to hop through the matches one at a time (Shift+Enter for the previous) — the canvas centres on each hit and marks the current one in a distinct colour. The canvas is tuned to stay responsive on large flows (hundreds of nodes) — drag and pan re-render only the affected nodes and links, and zoom goes down to 5% so a big flow fits on screen.
A selection-mode toggle sits beside the zoom controls: turn it on to drag a marquee over the empty canvas and multi-select nodes (Shift+click adds/removes one), then grab any selected node to move the whole group together; Delete removes them all. In selection mode the mouse wheel pans the canvas (Figma-style — Shift scrolls horizontally, Ctrl/⌘+wheel still zooms); outside it, the wheel zooms. Dragging with the middle mouse button grabs and pans the whole frame from anywhere (in either mode).
Property pane
Section titled “Property pane”The right-hand pane (drag its left edge to resize it) is the same property grid as the report
designer’s. A chip at the top names the selection — WIZARD for the settings shown when nothing is
selected, LINK, the node’s type (STARTUP, INPUT, DECISION, ACTION, TERMINAL), or N NODES
for a multi-selection — and every property is a two-column row under an uppercase category, all in one
bordered box:
- Wizard — Identity: Name, Slug, Label, Schema.
- Link — Condition: Expression.
- Node — Identity: Id (read-only), Label, Type; then per type — Input: Form Name, Submit SQL, and the Fields and Layout sections; Action: Action Type plus that type’s rows (Subject and Message, SQL, Target / Value Source / Static Value or SQL, or Custom XML); Terminal: Submission, Workflow handoff, SQL and Message rows, plus Storage & indexes and Indexes; Startup: Variables; Decision: Branch conditions (one row per outgoing link).
Each field in an Input node is a collapsible card whose body is its own grid — Identity (Name, Label,
Type), Behaviour (Required, Disabled, Readonly and, for indexable types, Indexed), Value (Default, Mask),
then Select (Control Type, Options SQL, static options) or Attachment (Max Size, Max Files, Accept).
Flags are — (unset) / true / false selects; unset and false both leave the attribute out of the
XML. List sections add rows from the Add button in their header.
A footer under the pane explains the focused property, starting on the selection’s first one. A full-width Delete button below the box removes the selected node, link or nodes. While viewing an old version from History, every editor is disabled and the add, remove and delete buttons are hidden.
Ctrl+Z steps back through your edits; Ctrl+Shift+Z or Ctrl+Y replays them. The toolbar carries the same two buttons, and each tooltip names what it would reverse. A drag is one step, a run of typing in one property is one step, and loading XML (the XML dialog, or an assistant Apply) is one step. Ctrl+Z inside a text box or the XML editor belongs to that editor. A dot on Save marks unsaved changes, and leaving the page with any asks first. Opening an old version from History starts a fresh stack, so an old version can never be undone into the live wizard.
Assistant-assisted authoring
Section titled “Assistant-assisted authoring”When the AI Assistant module is on, attach the wizard from the composer’s + menu (Current wizard) and two tools scoped to this page become available:
wizard_get_definitionreads the saved wizard under your own permissions, together with a condensed reference of the<Wizard>elements and attributes, so the model writes from the contract rather than guessing from what the open wizard happens to use.wizard_apply_changeproposes a complete rewritten<Wizard>document as an Apply card in the chat.
Clicking Apply loads the proposal onto the canvas as one undoable step; Ctrl+Z reverses it. From there it is an ordinary unsaved edit — review it, then Save.
Apply keeps your layout. Nodes that already exist stay exactly where they are on your canvas
(position and size), and links keep their connection sides. A new node is placed beside the node that
links into it, clear of every other node. A field added to a step that has an authored form
<Layout> is appended to that layout as a full-width item, so it actually appears in the runner
(a step with no <Layout> shows every field anyway). The flip side: asking the assistant to move
existing nodes or tidy the canvas does nothing, so arrange nodes by hand. The XML dialog’s Load is
not adjusted; it loads exactly what you paste.
- The assistant never writes. Apply fills the canvas; saving stays your action. Each tool
re-checks
Updateon theWizardDefinitionsresource itself. - A proposal is validated before you see it. It must be a well-formed
<Wizard>with aName, known node types, unique node ids, links between existing nodes, and field names that are valid identifiers unique across the whole wizard — the same identifier rules provisioning enforces. Renaming a wizard whose response table exists is refused, as it is on Save. When the model gets it wrong, the validator’s message goes back to it and it corrects itself. - There is no data preview. Wizard SQL runs with runtime values substituted in, and some of it
writes, so the model probes tables with
execute_queryinstead of executing your SQL. - Apply needs this page open, and is refused while you are viewing an old version.
- “Reply in chat” — or “don’t change the wizard” — answers in the conversation and withholds the apply tool for that turn.
The assistant reads the saved definition, so Save before asking if the canvas has changes you want it to see.