FactoryWorkflow
A Software Factory workflow: the graph of steps a run walks, from a trigger through scans, decisions, crews, gates and people to its outputs. Fields, YAML, HCL and CLI samples, and how apply treats it.
A Software Factory workflow: the graph of steps a run walks, from a trigger through scans, decisions, crews, gates and people to its outputs. metadata.name is the workflow’s slug (at most 60 characters; slugs that start with sys- belong to the default workflows and cannot be declared). Workflows are versioned: a change to the graph never edits a published version, it saves a new version and, with publish true, publishes it. Triggers and schedules declared as FactoryTrigger and FactorySchedule documents add their own trigger nodes to the graph; those nodes are not part of this document and a graph change keeps them. Removing the document archives the workflow: it is switched off and its triggers removed, and its versions and run history are kept.
apiVersion |
vulnetix.com/v1 |
kind |
FactoryWorkflow |
| Group | Factory |
| Status | Served. The config engine plans and applies it. |
| Objects per tenant | many, keyed by metadata.name |
| Token and role | An owner or admin session, or a vmt_ config management token with the owner or admin role that lists FactoryWorkflow in its kinds. See Management tokens. |
| Terraform resource | vulnetix_factory_workflow, imported by name (see Terraform and OpenTofu) |
| CLI | vulnetix config apply, CLI v3.168.0 and later. |
| Pinned schema | v1.15.0/FactoryWorkflow.schema.json |
| Newest schema | latest/FactoryWorkflow.schema.json |
| First released in | v1.6.0 |
| Print the built-in copy | vulnetix config schema FactoryWorkflow |
Minimal
Only the fields the kind requires. From examples/FactoryWorkflow/minimal.yaml.
YAML
# yaml-language-server: $schema=https://github.com/Vulnetix/config-schemas/releases/latest/download/FactoryWorkflow.schema.json
apiVersion: vulnetix.com/v1
kind: FactoryWorkflow
metadata:
name: patch-on-demand
spec:
graph:
nodes:
- id: start
type: trigger
title: Run by hand
config:
kind: manual
HCL (resource vulnetix_factory_workflow)
# Only the arguments the kind requires. From config-schemas examples/FactoryWorkflow/minimal.yaml.
resource "vulnetix_factory_workflow" "patch_on_demand" {
name = "patch-on-demand"
graph = {
nodes = [
{
config = jsonencode({
kind = "manual"
})
id = "start"
title = "Run by hand"
type = "trigger"
},
]
}
}
CLI (applies it)
export VULNETIX_CONFIG_TOKEN=vmt_... # a config management token
vulnetix config apply -f - --manager gitops:acme/platform --auto-approve <<'EOF'
# yaml-language-server: $schema=https://github.com/Vulnetix/config-schemas/releases/latest/download/FactoryWorkflow.schema.json
apiVersion: vulnetix.com/v1
kind: FactoryWorkflow
metadata:
name: patch-on-demand
spec:
graph:
nodes:
- id: start
type: trigger
title: Run by hand
config:
kind: manual
EOF
Advanced
Every field the kind supports, with a when expression where it takes one. From examples/FactoryWorkflow/advanced.yaml.
YAML
# yaml-language-server: $schema=https://github.com/Vulnetix/config-schemas/releases/latest/download/FactoryWorkflow.schema.json
apiVersion: vulnetix.com/v1
kind: FactoryWorkflow
metadata:
name: security-patch-train
labels:
team: appsec
annotations:
owner: appsec@acme.com
spec:
displayName: Security patch train
description: Scan, patch with belai:security, hold the change to CI and the quality gate, then open a draft pull request.
publish: true
enabled: true
graph:
nodes:
- id: start
type: trigger
title: Run by hand
config:
kind: manual
position: { x: 0, y: 0 }
- id: scan
type: scan
title: Scanners
config:
source: posture
scanners: [sca, malscan, secrets]
failOn: [malware, secrets]
sbomFormat: cyclonedx-1.6
githubCalls: { max: 1 }
onQuotaLow: use_cached
position: { x: 240, y: 0 }
- id: patch
type: crew
title: Patch and verify
config:
crew: belai:security
mode: goal
codeTier: smart
testsPostEnd: goal
testsOnFail: fix
maxAttempts: 3
budget: { tokens: 300000 }
position: { x: 480, y: 0 }
- id: gate
type: gate
title: Quality gate and tests
config:
profile: org_default
releaseJail: enforce
onFail: return_to_crew
checkSource: mirror
maxGithubCalls: 2
onQuotaLow: use_cached
githubCalls: { max: 2 }
position: { x: 720, y: 0 }
- id: pr
type: output
title: Draft pull request
config:
kind: draft_pr
prState: draft
reviewers: codeowners
attach: cdx_slsa
githubCalls: { max: 2 }
position: { x: 960, y: 0 }
edges:
- { from: start, to: scan }
- { from: scan, to: patch, label: findings }
- { from: patch, to: gate, label: branch }
- { from: gate, to: pr, outcome: pass }
- { from: gate, to: patch, outcome: fail, loop: true }
limits:
runBudgetTokens: 400000
monthBudgetTokens: 6000000
concurrency: 2
githubCallsPerRun: { typical: 4, max: 8 }
stopTheLineAttempts: 3
agentCostUsd: 5
agentFinalizeAt: 0.8
agentMaxAttempts: 3
agentMaxContinuations: 2
agentMaxPasses: 20
agentMaxWallMinutes: 120
agentMaxIterations: 80
scope:
repos: [acme/payments, acme-labs/*]
orgs: [acme]
HCL (resource vulnetix_factory_workflow)
# Every argument the kind supports. From config-schemas examples/FactoryWorkflow/advanced.yaml.
resource "vulnetix_factory_workflow" "security_patch_train" {
name = "security-patch-train"
description = "Scan, patch with belai:security, hold the change to CI and the quality gate, then open a draft pull request."
display_name = "Security patch train"
enabled = true
graph = {
edges = [
{
from = "start"
to = "scan"
},
{
from = "scan"
label = "findings"
to = "patch"
},
{
from = "patch"
label = "branch"
to = "gate"
},
{
from = "gate"
outcome = "pass"
to = "pr"
},
{
from = "gate"
loop = true
outcome = "fail"
to = "patch"
},
]
limits = {
agent_cost_usd = 5
agent_finalize_at = 0.8
agent_max_attempts = 3
agent_max_continuations = 2
agent_max_iterations = 80
agent_max_passes = 20
agent_max_wall_minutes = 120
concurrency = 2
github_calls_per_run = {
max = 8
typical = 4
}
month_budget_tokens = 6000000
run_budget_tokens = 400000
stop_the_line_attempts = 3
}
nodes = [
{
config = jsonencode({
kind = "manual"
})
id = "start"
position = {
x = 0
y = 0
}
title = "Run by hand"
type = "trigger"
},
{
config = jsonencode({
failOn = ["malware", "secrets"]
sbomFormat = "cyclonedx-1.6"
scanners = ["sca", "malscan", "secrets"]
source = "posture"
})
github_calls = {
max = 1
}
id = "scan"
on_quota_low = "use_cached"
position = {
x = 240
y = 0
}
title = "Scanners"
type = "scan"
},
{
budget = {
tokens = 300000
}
config = jsonencode({
codeTier = "smart"
crew = "belai:security"
maxAttempts = 3
mode = "goal"
testsOnFail = "fix"
testsPostEnd = "goal"
})
id = "patch"
position = {
x = 480
y = 0
}
title = "Patch and verify"
type = "crew"
},
{
config = jsonencode({
checkSource = "mirror"
maxGithubCalls = 2
onFail = "return_to_crew"
onQuotaLow = "use_cached"
profile = "org_default"
releaseJail = "enforce"
})
github_calls = {
max = 2
}
id = "gate"
position = {
x = 720
y = 0
}
title = "Quality gate and tests"
type = "gate"
},
{
config = jsonencode({
attach = "cdx_slsa"
kind = "draft_pr"
prState = "draft"
reviewers = "codeowners"
})
github_calls = {
max = 2
}
id = "pr"
position = {
x = 960
y = 0
}
title = "Draft pull request"
type = "output"
},
]
scope = {
orgs = ["acme"]
repos = ["acme/payments", "acme-labs/*"]
}
}
publish = true
labels = {
team = "appsec"
}
annotations = {
owner = "appsec@acme.com"
}
}
CLI (applies it)
export VULNETIX_CONFIG_TOKEN=vmt_... # a config management token
vulnetix config apply -f - --manager gitops:acme/platform --auto-approve <<'EOF'
# yaml-language-server: $schema=https://github.com/Vulnetix/config-schemas/releases/latest/download/FactoryWorkflow.schema.json
apiVersion: vulnetix.com/v1
kind: FactoryWorkflow
metadata:
name: security-patch-train
labels:
team: appsec
annotations:
owner: appsec@acme.com
spec:
displayName: Security patch train
description: Scan, patch with belai:security, hold the change to CI and the quality gate, then open a draft pull request.
publish: true
enabled: true
graph:
nodes:
- id: start
type: trigger
title: Run by hand
config:
kind: manual
position: { x: 0, y: 0 }
- id: scan
type: scan
title: Scanners
config:
source: posture
scanners: [sca, malscan, secrets]
failOn: [malware, secrets]
sbomFormat: cyclonedx-1.6
githubCalls: { max: 1 }
onQuotaLow: use_cached
position: { x: 240, y: 0 }
- id: patch
type: crew
title: Patch and verify
config:
crew: belai:security
mode: goal
codeTier: smart
testsPostEnd: goal
testsOnFail: fix
maxAttempts: 3
budget: { tokens: 300000 }
position: { x: 480, y: 0 }
- id: gate
type: gate
title: Quality gate and tests
config:
profile: org_default
releaseJail: enforce
onFail: return_to_crew
checkSource: mirror
maxGithubCalls: 2
onQuotaLow: use_cached
githubCalls: { max: 2 }
position: { x: 720, y: 0 }
- id: pr
type: output
title: Draft pull request
config:
kind: draft_pr
prState: draft
reviewers: codeowners
attach: cdx_slsa
githubCalls: { max: 2 }
position: { x: 960, y: 0 }
edges:
- { from: start, to: scan }
- { from: scan, to: patch, label: findings }
- { from: patch, to: gate, label: branch }
- { from: gate, to: pr, outcome: pass }
- { from: gate, to: patch, outcome: fail, loop: true }
limits:
runBudgetTokens: 400000
monthBudgetTokens: 6000000
concurrency: 2
githubCallsPerRun: { typical: 4, max: 8 }
stopTheLineAttempts: 3
agentCostUsd: 5
agentFinalizeAt: 0.8
agentMaxAttempts: 3
agentMaxContinuations: 2
agentMaxPasses: 20
agentMaxWallMinutes: 120
agentMaxIterations: 80
scope:
repos: [acme/payments, acme-labs/*]
orgs: [acme]
EOF
Fields
Paths use dots for nesting and [] for the items of a list. Required means required whenever the parent object is present. A default is what the server uses when the field is left out.
| Field | Type | Required | Default | Allowed values | Description |
|---|---|---|---|---|---|
apiVersion |
string | yes | exactly vulnetix.com/v1 |
The document format version. Every kind in this release is vulnetix.com/v1. | |
kind |
string | yes | exactly FactoryWorkflow |
Always FactoryWorkflow. | |
metadata |
object | yes | no other keys | ||
metadata.name |
string | yes | pattern ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ |
The object’s name, unique per kind within a tenant. It is the natural key: plan, apply, export and the Terraform provider all address an object by kind and name. | |
metadata.org |
string | no | format uuid |
The tenant (organization UUID) the document belongs to. When set, plan and apply refuse to run against any other tenant. | |
metadata.labels |
object | no | map of string to string (length 0 to 256); keys match ^[a-z0-9]([a-z0-9._/-]{0,61}[a-z0-9])?$ |
Free key/value labels. Stored with the object, never interpreted by the server. | |
metadata.annotations |
object | no | map of string to string (length 0 to 4096) | Free key/value notes for people and tools, such as a ticket link. | |
spec |
object | yes | no other keys | ||
spec.displayName |
string | no | length 1 to 120 | The name the console shows. Left out, the workflow is shown under its metadata.name. | |
spec.description |
string | no | length 0 to 2000 | What the workflow is for, shown under its name in the console. | |
spec.publish |
boolean | no | true |
true, or left out: the graph is the workflow’s published version, and new runs use it; a changed graph is saved as the next version and published, which needs a graph that passes the full publish checks (a trigger, every step reachable, no cycle, every decision outcome routed, and every step but config-apply configured, not left with an empty config). false: the graph is kept as the workflow’s draft and runs keep using the last published version. Apply refuses to publish over a draft someone left unpublished in the console unless the draft already holds this graph. A draft may hold steps that are not configured yet (config: {}). | |
spec.enabled |
boolean | no | true |
Whether the factory starts runs of the workflow. Switching a workflow off also switches off its event triggers; a run already going is left to finish. A workflow that has never been published cannot be switched on. | |
spec.graph |
object | yes | no other keys | The workflow graph, in the shape the console’s workflow builder saves and the workflow API takes. | |
spec.graph.nodes |
array of object | yes | 0 to 60 items | The steps, at most 60. | |
spec.graph.nodes[].id |
string | yes | pattern ^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$ |
The step’s id, unique in the graph: 1 to 40 letters, digits, dashes or underscores. Edges name steps by it. | |
spec.graph.nodes[].type |
string | yes | trigger, scan, decision, crew, gate, human, output, state, config-apply |
What the step does. trigger: what starts a run. scan: run scanners or read their results. decision: ask a model a question and route on the answer. crew: hand the work to a Belai crew or agent. gate: hold the run to CI checks, the quality gate and the release jail, and ask a person to approve a config-apply plan. human: ask a person to approve. output: open a pull request, comment, publish to TEA, notify or write VEX. state: read or write a stored object. config-apply: plan, or apply an approved plan of, the Platform IaC documents in the repository’s .vulnetix/config/ directory at the pushed commit (config: phase plan or apply, and prune). | |
spec.graph.nodes[].title |
string | yes | length 0 to 200 | The step’s title in the builder and the run view. Publishing needs a title that is not empty on every step. | |
spec.graph.nodes[].config |
object | no | The step’s settings, which depend on its type. A published workflow needs at least one on every step except config-apply, such as a trigger’s kind or a gate’s profile; a draft (publish: false) may leave them empty (config: {}), and so may a config-apply step, whose settings all have defaults. They are the settings the workflow builder edits, under the same names; the server checks them with the builder’s own rules, so a step the builder would refuse is refused here too. A trigger step’s config is the same as a FactoryTrigger or FactorySchedule spec, with the type under kind (kind: cron for a schedule, with cron, tz and runsOn). | ||
spec.graph.nodes[].budget |
object | no | no other keys | The step’s token budget. | |
spec.graph.nodes[].budget.tokens |
integer | no | >= 0 | Tokens the step may spend in one run; 0 for no limit of its own. | |
spec.graph.nodes[].githubCalls |
object | no | no other keys | The step’s GitHub API call limit. | |
spec.graph.nodes[].githubCalls.max |
integer | no | >= 0 | GitHub API calls the step may make in one run. | |
spec.graph.nodes[].onQuotaLow |
string | no | use_cached, defer, escalate |
What the step does when the GitHub quota is low: carry on with cached data, wait for the quota window, or ask a person. | |
spec.graph.nodes[].position |
object | no | no other keys | Where the builder draws the step. It has no effect on runs. | |
spec.graph.nodes[].position.x |
number | no | |||
spec.graph.nodes[].position.y |
number | no | |||
spec.graph.edges |
array of object | no | 0 to 120 items | The connections between steps, at most 120. A run moves along an edge when the step it leaves finishes. | |
spec.graph.edges[].from |
string | yes | length 1 to any | The id of the step the edge leaves. | |
spec.graph.edges[].to |
string | yes | length 1 to any | The id of the step the edge enters. Nothing may lead into a trigger. | |
spec.graph.edges[].label |
string | no | length 0 to 200 | The artifact that travels along the edge, shown on the canvas. | |
spec.graph.edges[].outcome |
string | no | length 0 to 41 | On an edge leaving a decision: the outcome that takes it, or $below_threshold or $unknown. On an edge leaving a gate: pass, or fail for the loop back to a crew. | |
spec.graph.edges[].loop |
boolean | no | Marks the one kind of edge allowed to go back: a gate or decision returning work to a crew, bounded by limits.stopTheLineAttempts. | ||
spec.graph.limits |
object | no | no other keys | Run limits. A value left out, or 0, keeps the tenant’s setting. | |
spec.graph.limits.runBudgetTokens |
integer | no | >= 0 | Tokens one run may spend. At most monthBudgetTokens when both are set. | |
spec.graph.limits.monthBudgetTokens |
integer | no | >= 0 | Tokens the workflow may spend in a calendar month (UTC). | |
spec.graph.limits.concurrency |
integer | no | 0 to 50 | Runs of the workflow at once, 1 to 50. | |
spec.graph.limits.githubCallsPerRun |
object | no | no other keys | The GitHub API calls a run is expected to make, for the estimate the builder shows. | |
spec.graph.limits.githubCallsPerRun.typical |
integer | no | >= 0 | ||
spec.graph.limits.githubCallsPerRun.max |
integer | no | >= 0 | ||
spec.graph.limits.stopTheLineAttempts |
integer | no | 0 to 10 | Failed attempts after which a loop back to a crew stops the run, 1 to 10. | |
spec.graph.limits.agentCostUsd |
number | no | 0 to 10000 | The cost limit, in US dollars, of each card a worker this workflow starts takes on, up to 10000. | |
spec.graph.limits.agentFinalizeAt |
number | no | 0 to 0.95 | The share of a limit (0 to 0.95) at which a worker starts wrapping up. | |
spec.graph.limits.agentMaxAttempts |
integer | no | 0 to 10 | Failed attempts a worker may make on one card, up to 10. | |
spec.graph.limits.agentMaxContinuations |
integer | no | 0 to 50 | How often a card may resume after its budget ran out, up to 50. | |
spec.graph.limits.agentMaxPasses |
integer | no | 0 to 1000 | Passes a worker may make on one card, up to 1000. | |
spec.graph.limits.agentMaxWallMinutes |
integer | no | 0 to 10080 | Minutes a worker may spend on one card, up to 10080 (a week). | |
spec.graph.limits.agentMaxIterations |
integer | no | 0 to 400 | Tool rounds a worker may take in one pass, up to 400. | |
spec.graph.scope |
object | no | no other keys | The repositories the workflow may run on. | |
spec.graph.scope.repos |
array of string | no | each item: pattern ^(\*|[A-Za-z0-9._-]{1,100}/(\*|[A-Za-z0-9._-]{1,100}))$ |
Repositories as owner/name, owner/* for every repository of an owner, or * for every managed repository. Names are compared without case. | |
spec.graph.scope.orgs |
array of string | no | each item: length 1 to 100 | GitHub organisations whose managed repositories the workflow may run on. |
Applying this kind
- Command.
vulnetix config apply -f <file>; the CLI line above says from which release. Plan first withvulnetix config plan: it shows each object as create, update, delete or no change, with aplanHashthat apply then holds to. Apply writes in one transaction. - Manager. Each object records the manager that applied it (
terraform,consoleorgitops:<repo>).--prunedeletes only objects its own manager owns. See The manager and the config engine. - Status. Applied, Unchanged or Deleted, with no other status fields. The phases the engine reports are explained under status phases.
How apply treats it
- The graph is checked by the console’s graph validator.
- A change saves the next version. With
publish: trueit also publishes that version through the console’s publish, which reconciles the trigger rows and their schedules in the apply transaction. A published version is never edited. - The trigger nodes that
FactoryTriggerandFactoryScheduleown are left out of the workflow’s document, and kept by its graph changes.
GitOps from a repository
A workflow node, config-apply, lets a config repository manage the tenant. Trigger it on a push webhook to the repository’s default branch.
- It reads every
.yamland.ymlfile under the node’spath(default.vulnetix/config) at the pushed commit, through the forge’s git API (at most 4 MiB). - It plans them as manager
gitops:<owner/repo>, with prune on unless the node setsprune: false. The plan is the step’s artifact. - Blocking conditions fail the step with
config_plan_blocked. No changes: the step succeeds and applies nothing. - Otherwise, unless
approval: none, the step asks for a person through the gate node’s human approval, carrying the plan hash. Approved, it applies with that hash. A plan that moved since fails withconfig_plan_changedand applies nothing. Rejected or expired, the step fails. - External objects that fail in the second phase fail the step with their count. Plan again to see what is left.
Terraform and OpenTofu
The resource is vulnetix_factory_workflow in the vulnetix/resolve provider. Its arguments are the spec in snake case, beside name, labels and annotations from metadata. The Minimal and Advanced HCL are above. The provider’s own reference is vulnetix_factory_workflow.
Import an object that already exists by its name:
# Import an existing FactoryWorkflow by its name (metadata.name).
terraform import vulnetix_factory_workflow.patch_on_demand patch-on-demand
Install, authentication and how state is read: Terraform and OpenTofu.
More examples
examples/FactoryWorkflow/config-apply.yaml from Vulnetix/config-schemas:
# A published config-apply step may have an empty config: the phase, path, prune and approval all have defaults.
apiVersion: vulnetix.com/v1
kind: FactoryWorkflow
metadata:
name: apply-config-on-push
spec:
graph:
nodes:
- id: start
type: trigger
title: On a push to the default branch
config:
kind: webhook
event: push
- id: apply
type: config-apply
title: Plan and apply the config
config: {}
edges:
- from: start
to: apply
examples/FactoryWorkflow/draft-unconfigured.yaml from Vulnetix/config-schemas:
# A draft keeps its steps as the console saved them, including steps that are not configured yet: config: {} is accepted while publish is false.
apiVersion: vulnetix.com/v1
kind: FactoryWorkflow
metadata:
name: triage-sketch
spec:
publish: false
graph:
nodes:
- id: start
type: trigger
title: When a finding arrives
config: {}
- id: scan
type: scan
title: Scan
config: {}
edges:
- from: start
to: scan
Check a file with vulnetix config validate -f <file>. See Validate documents. In an editor, start the file with the schema comment.