Flow Interface Builder
The Flow Interface Builder is a visual designer for a flow's run form. Authors compose a React Flow graph of section and field nodes connected by semantic edges; operators never see the graph — they see a normal, sectioned form rendered from a compiled contract on Start Run and Save as Schedule.
Migration required: apply database revision
20260607_016_run_interface_inputsbefore enabling this feature in an environment. Run the standard migration task:
task db:migrateThe builder persists submitted/resolved interface input provenance on runs, and those audit columns are added by this migration.
Authoring model
Open a flow in the editor and switch to the Form tab.
Sections group fields into cards. Sections and fields are laid out by canvas position: nodes sharing a horizontal band form one row (split evenly), rows read top-to-bottom, left-to-right.
Fields declare a
field_id(the{{ inputs.<id> }}name), a type, a label, optional default/description, and validation rules (AND/OR/NOT rule trees with per-rule or per-group message overrides).Edges carry one or more semantic relations:
Relation Effect options_filterSource field's value filters the target's options (cascading selects) visible_whenTarget is shown only when the source condition holds enabled_whenTarget is editable only when the source condition holds required_whenTarget becomes required when the source condition holds Only these four relation types exist. Stored graphs containing retired types still load — the compiler ignores those relations and reports an
UNSUPPORTED_EDGE_RELATIONwarning.
Built-in field types and option sources
Field types: core.text, core.number (int/float subtypes), core.bool, core.enum, core.multi_enum, and inventory.device.
inventory.device fields are device targets: their submitted value routes to the run's related_targets (the device-targeting path) instead of inputs, and each device field owns a target role (defaulting to its field id). Roles derived from the form win over settings-authored roles of the same name.
Option sources:
| Kind | Options come from | Filter keys (cascading or fixed) |
|---|---|---|
core.static | a hard-coded list on the field | — |
inventory.devices | inventory devices (searchable) | site_id, provider_id, platform, maintenance_state, sync_status, tag.* |
inventory.sites | inventory sites (searchable) | parent_id, provider_id, tag.* |
inventory.providers | configured inventory providers | provider_type, enabled_only |
inventory.objects | synced inventory objects of one type - prefixes, VLANs, whatever the installed plugins register (searchable) | provider_id, sync_status, attr.<attribute> |
attachment.csv | a CSV-style file attached to the flow | any CSV column name |
Filter values may be lists (a multi-select or multi-device parent): they match any of the submitted entries (OR semantics). inventory.providers lists only enabled providers unless a fixed enabled_only: false filter opts in, and it always offers the built-in local provider alongside configured ones.
maintenance_state accepts active, maintenance or disabled, so a form can offer only devices the source of truth still considers in service. It is a filter on the picker, not a gate on the run: nothing stops a run whose targets were chosen some other way.
Both registries are extensible by Python packages via the hegemony.flow_interface entry-point group; a plugin exposes register(registry) and may add field types and option sources.
attachment.csv — options from a file
A select/multi-select field can draw its options from a delimited file uploaded to the flow (Attachments tab). The field's option_source.config names the file and the columns:
"option_source": {
"kind": "attachment.csv",
"config": {
"filename": "data/locations.csv",
"value_column": "code", // optional; defaults to the first column
"label_column": "datacenter", // optional; defaults to value_column
"description_column": "region", // optional
"delimiter": ",", // optional; defaults to ","
"has_header": true // optional; defaults to true
}
}Rows are de-duplicated by value (so a column reused as a parent select yields distinct choices), then filtered by search and the limit. The file is read version-consistently: a run pinned to a committed flow version reads that version's attachment snapshot, the live designer reads the current draft. Files are UTF-8 text capped at 1 MB (the attachment limit).
Authoring-time checks warn (never block) when the config is incomplete or stale: a missing filename at compile time (OPTION_SOURCE_CONFIG), and — via POST /flows/{id}/interface/validate — a file that doesn't exist on the flow (ATTACHMENT_NOT_FOUND), configured columns absent from its header (CSV_COLUMN_UNKNOWN), or a cascading/fixed filter key that isn't a column (CSV_FILTER_UNKNOWN — such a cascade would filter every row out).
inventory.objects - options from synced inventory objects
Any object type an inventory provider syncs can back a select, so a form can offer the VLANs or prefixes that exist in NetBox rather than free text. The field's option_source.config names the type and, optionally, what a picked option submits:
{
"kind": "inventory.objects",
"config": { "object_type": "vlan", "value_field": "attr.vid" }
}object_type(required) - an object type id. The builder offers the types this instance registers. A type nothing here registers still compiles and still offers whatever rows a provider synced under that id, because a registered type is what the builder reads attributes and search fields from, not a gate on what a provider may sync; the validate endpoint reports it as anOBJECT_TYPE_UNKNOWNwarning, since usually nothing synced it either.value_field- what the run receives:id(default, the object's UUID),external_id,name, orattr.<attribute>for a manifest attribute, so a step can use the value as is ({{ inputs.vlan }}is the VID, not a UUID). Rows sharing a value - the same VLAN reported by two providers - become one option.
Options are the type's rows with sync_status: active; a fixed sync_status filter widens that (stale, or a list). provider_id narrows to one or more providers, and attr.<attribute> compares an attribute as text (numbers welcome), which is how a picked VRF or site can cascade into the prefixes under it. Free text matches the name, display name, external id and the manifest's search fields. Membership is enforced at run creation like every dynamic source: a value that is not among the current options is rejected, by name when the value is an id.
Fixed (static) filters
Beyond cascading edges, a field can declare always-on filter values in its option_source.filters config; the compiler turns them into OptionFilter.static_value entries:
"option_source": {
"kind": "inventory.sites",
"filters": {
"provider_id": { "static_value": "netbox:prod" },
"parent_id": { "static_value": null } // null = explicitly unset → root sites only
}
}The null sentinel works wherever the underlying column is nullable: parent_id on inventory.sites (root sites only) and site_id on inventory.devices (devices not assigned to any site).
In the builder these are the Fixed filters rows on a dynamic source (an empty value means "unset"). An options_filter edge with the same key overrides the fixed value (the cascade refines the default). Filter keys starting with __ are reserved and ignored with a RESERVED_FILTER_KEY warning; two edges feeding the same key raise DUPLICATE_FILTER_KEY.
Cascading selects (options_filter)
An options_filter edge makes one field's value narrow another field's options. The edge's relation value is the target source's filter key; the source field's current value is passed under that key to the target source. Examples:
| Cascade | Source → target | Edge relation value |
|---|---|---|
| Site → Device | inventory.sites → inventory.devices | site_id |
| Provider → Device | inventory.providers → inventory.devices | provider_id |
| Provider → VLAN | inventory.providers → inventory.objects | provider_id |
| VRF → Prefix (an attribute) | inventory.objects → inventory.objects | attr.vrf (compared as text) |
| Parent site → Child site | inventory.sites → inventory.sites | parent_id |
| Region → Datacenter (one CSV) | attachment.csv → attachment.csv | region (a CSV column) |
| Env → App → Version | chained attachment.csv fields | each next CSV column |
If the relation carries no value, the source field's own id is used as the filter key. Circular options_filter chains are rejected at compile time (CIRCULAR_OPTIONS_DEPENDENCY). At runtime the form re-fetches a field's options whenever one of its parent values changes, and prunes a now-invalid selection (entry-by-entry for multi-selects) unless the source sets allow_clear: false. Pruning only ever runs against a complete, unfiltered result for the current request — it is skipped while a server-side search narrows the list or when the response is truncated by the fetch limit, since absence from a partial view says nothing about validity. Dynamic selects are searchable comboboxes: typing sends the query to the options endpoint, and when the result is capped a "showing N of total" hint appears.
Authoring in the builder. On the Form tab, a select/multi-select field's inspector has an Option source picker (Static list, File attachment (CSV), Inventory sites/providers/devices); choosing File attachment (CSV) reveals the file + column configuration: the file is picked from the flow's attachments, the value/label/description columns from the file's header, and an Options preview shows the first choices the operator will see. To make a cascade, connect two fields and add a Filter options relation to the edge, setting its filter key to the target source's key — the input suggests the source's known keys (for CSV targets, the file's real column names). Fixed filters rows get the same key suggestions, and for CSV sources the value input suggests the chosen column's distinct values.
Versioning, storage, and export
The authoring graph lives in definition_json["interface_graph"] — nowhere else. It is therefore:
- versioned with the flow: form edits mark the flow dirty, are saved with the draft, committed with versions, and restored by revert/restore;
- exported/imported with the flow in YAML bundles and git sync;
- never stale: the runtime contract is compiled on demand from the graph (the compiler is a pure function), so a pinned version always resolves against its own snapshot.
Run-time resolution (authoritative)
The frontend is never trusted. At run creation the API:
- compiles the contract from the version's definition (compile errors reject the run with HTTP 400);
- routes device-target field values into
related_targets; - evaluates visibility/enablement/requirement conditions — values submitted for hidden or disabled fields are dropped from inputs (a field's last value may still participate in other fields' conditions; the client and server evaluate conditions against the same form state, so they always agree on which fields show);
- applies defaults, coerces types, and runs validation rules (values that still contain Jinja templates pass through; their checks are downgraded to warnings and re-evaluated on the worker after rendering);
- enforces option membership for every select — static lists and dynamic sources (
attachment.csv,inventory.*) alike. The submitted value must be one the source would actually offer given the submitted sibling values, so cascade constraints hold too (anemearegion cannot smuggle in anamerdatacenter). Because every entry point — API, schedules, webhooks, nested runs — resolves through this pipeline, the check covers them all. Template values are exempted (worker-resolved), and plugin sources opt in by implementingOptionSource.contains()(sources without it are skipped, never broken); - applies the same membership rule to device-target fields. They route to
related_targetsrather than inputs, so they are checked separately — a device field narrowed by a cascade (e.g. "devices at the chosen site") or a fixed filter rejects a device the form would not have offered, instead of leaving that constraint to the picker UI. Rejections are reported by name —Target field 'Routers': device fra-edge-01 (09a48b03-…) is not one of the allowed targets— since a bare UUID tells the operator nothing about which device they picked. Sources whose values are opaque ids supply the names viaOptionSource.describe_many(); sources whose values are already readable (CSV columns, static lists) leave it unimplemented and the value is shown as-is; - persists the raw submission (
submitted_inputs_json) and a provenance snapshot (resolved_inputs_snapshot_json) on the run for audit.
An options_filter edge into a device field therefore constrains it on both sides: the run form's device picker opens pre-filtered (its Site / Inventory source / Platform / tag controls locked to the cascade value), and run creation rejects anything outside that set. Only filter keys the device search understands are pushed into the picker (site_id, provider_id, platform, tag.*); a multi-valued parent has no single-key equivalent there, so the picker stays unfiltered while the server-side check still applies.
API
| Endpoint | Purpose |
|---|---|
GET /flow-interface/field-types | Registered field type manifests |
GET /flow-interface/option-sources | Registered option source manifests |
GET /flows/{id}/interface | Graph + on-demand compiled contract |
POST /flows/{id}/interface/validate | Validate a provided (unsaved) or the persisted graph |
POST /flows/{id}/interface/options | Dynamic options for a field (cascading, search) |
POST /flows/{id}/interface/preview-resolve | Dry-run resolution of form values |
The graph itself is saved through the regular flow draft endpoints (PUT /flows/{id} with interface_graph).
The interface graph is the sole source of truth for form fields and device targeting - there is no separate inputs or target-roles mechanism.