Skip to content

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.

Checked against config-schemas@v1.15.0, terraform-provider-resolve@82cb23c, cli@v4.9.1

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 with vulnetix config plan: it shows each object as create, update, delete or no change, with a planHash that apply then holds to. Apply writes in one transaction.
  • Manager. Each object records the manager that applied it (terraform, console or gitops:<repo>). --prune deletes 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: true it 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 FactoryTrigger and FactorySchedule own 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 .yaml and .yml file under the node’s path (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 sets prune: 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 with config_plan_changed and 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.

Edit this pageSources: config-schemas/schemas/FactoryWorkflow.schema.json, config-schemas/schemas/common.schema.json, config-schemas/examples/FactoryWorkflow/minimal.yaml, config-schemas/examples/FactoryWorkflow/advanced.yaml, config-schemas/examples/FactoryWorkflow/config-apply.yaml, config-schemas/examples/FactoryWorkflow/draft-unconfigured.yaml, terraform-provider-resolve/examples/resources/vulnetix_factory_workflow/minimal.tf, terraform-provider-resolve/examples/resources/vulnetix_factory_workflow/advanced.tf, vdb-site/docs/platform-iac-engine.md, vdb-site/api/internal/handler/config_engine_kinds.go, vdb-site/api/internal/config/factoryworkflow.go, vdb-site/api/internal/handler/factory_workflow_graph.go
esc
↑ ↓ move↵ openSearches in your browser. A key like autonomy.max or a code like threshold_required jumps straight to it.