Docs menu

Configure Processes

Processes

A process file defines one lane: which issues it admits, and the steps that those issues take. This page lists every field in the file, the conditions that you can write, and the rules that the parser enforces.

Three rules behind every field

  1. The orchestrator owns the state, and the file describes the behavior that you want. A step moves on because its postconditions hold in what Factorial sees on GitHub. It never moves on because an agent said that it finished. The reconciliation loop explains the cycle.
  2. Conditions are executable. Each when, preconditions and postconditions is a combination of predicates from a closed library. The file can use a predicate and cannot define one. An unknown name fails at parse time, and the error lists the allowed names.
  3. A ticket pins the file when it enters. Factorial stores the parsed config as a snapshot, identified by the commit that it came from. The snapshot governs that ticket until the ticket is done. A change to the file reaches new tickets only. Each fix that you make in the config applies to the next ticket.

The file has no version: field. A commit identifies a snapshot. A file that still has the key parses, and nothing reads the key.

Where the file lives

One process is one file, at .factorial/processes/process-<name>.yaml on the factorial branch of the repository. A sync with no ref reads that branch. A change reaches the branch through Commit and sync in the process editor, or through a pull request that a person merges. Sync refuses any other commit.

.factorial/
  processes/
    process-standard.yaml
    process-hotfix.yaml
  agents/<name>/agent.yaml
  mcp/<name>.yaml

The file name decides the process name. The stem without the process- prefix must equal the name: in the file. So process-hotfix.yaml declares name: hotfix, and an issue enters that lane with the label factorial:process:hotfix.

  • A name has lowercase letters, digits and dashes. It starts and ends with a letter or a digit.
  • default is not a valid name. The default lane is a project setting, so the repository cannot name it.
  • Anything in processes/ that is not process-*.yaml is an error. A typo in a file name then cannot hide a lane.
  • A project can have any number of lanes. One sync writes every lane at the same commit.

Two more rules apply to every file:

  • An unknown key is an error, at every level. on_success where you meant onSuccess fails the parse. It never falls back to a default.
  • The keys in a process file use camelCase, for example ticketLabel, maxAttempts, onSuccess, rerunStep and closeTicket. The agent files use snake_case. See Agents.

An older layout, one file at .factorial/process.yaml, still works. A repository cannot have both that file and a processes/ folder, and sync refuses it. The process editor refuses any edit to a repository that is still on the old file, and tells you the git mv that fixes it.

Which issues enter

Factorial chooses the lane before it evaluates any condition in the file.

Labels on the issueResult
No factorial:process: labelThe issue uses the default lane of the project.
factorial:process:hotfixThe issue uses hotfix, not the default.
Two factorial:process: labelsThe issue does not enter. Two labels choose nothing.
A label for a lane that the repository does not offerThe issue does not enter. It never falls back to the default.
No label, and no default laneThe issue does not enter. Each issue must then name its lane.

The default lane is a setting of the project, in Configuration. It is never in the repository, because an agent could then redirect every future ticket by renaming a file. A lane is available only while the last sync found its file. When you delete the file, the lane retires. Tickets already in it continue, and the lane returns when the file returns.

Then Factorial evaluates entry.when of the chosen lane. If it is false, the issue waits. It does not try another lane.

entry:
  when:
    all:
      - ticket_label("factorial:new")
      - ticket_has_assignee()
  step: plan

entry.when decides readiness. It never decides which lane an issue belongs to. Do not write conditions to keep two lanes apart. Identical entry conditions in two lanes are valid, because Factorial reads only the conditions of the chosen lane.

An issue that passes entry.when enters, pins the current snapshot, and starts at entry.step. That step must exist in steps. Admission happens once: Factorial never admits a finished ticket again.

Only three templates exist at admission: {ticket}, {repo} and {ticket_owner}. {ticket_owner} is the first assignee of the issue, and it stays the same for the life of the ticket. Do not write {slug} or {pr_url} in entry.when. Templates lists all of them. The labels that a lane uses must exist in the repository. See Connect a repository.

The skeleton

You must set six top-level keys, in any order: specVersion, kind, name, entry, steps and terminal. A file with only these keys parses:

specVersion: factorial/v1   # the schema version; adlc/v1 also parses, for older snapshots
kind: Process
name: my-process            # must equal the file name without "process-"

entry:
  when: ticket_label("factorial:new")
  step: plan

steps:                      # at least one step
  - name: plan
    kind: agent
    agent: plan-agent
    next: done
    workspace: { repo: "{repo}", base: main, branch: "spec/{ticket}-{slug}" }

terminal:
  step: done

Five more keys are optional: description (free text), roles (named lists for assign and approvers), defaults (timeout, retry and escalation), policy and runtime.

This excerpt is from the sample lane. It plans, waits for a spec review, and builds. The full lane adds a test step and a code review gate.

specVersion: factorial/v1
kind: Process
name: simple
description: Plan, spec review, build, test, code review, done (GitHub only)

# What the process expects of the repository. Factorial reports it and never enforces it.
policy:
  branchProtection: advisory

# The slug of a Runtime from the Runtime page. Leave it out for the deployment default.
runtime: claude-api

# Named lists. Use them in assign and approvers as "{roles.spec_reviewers}".
roles:
  spec_reviewers: ["{ticket_owner}"]
  code_reviewers: ["{ticket_owner}"]

# Applies to every agent step unless the step sets its own value.
defaults:
  timeout: 1h
  retry:
    maxAttempts: 2
    backoff: 10m
  onFailure:
    escalate:
      ticketLabel: "factorial:blocked"
      assign: ["{ticket_owner}"]
      comment: |
        ADLC {step} failed after {attempt} attempt(s): {failure_reason}.
        Fix the cause, then remove `factorial:blocked` to retry.

# An issue with this label and an assignee enters at the plan step.
entry:
  when:
    all:
      - ticket_label("factorial:new")
      - ticket_has_assignee()
  step: plan

steps:
  - name: plan
    kind: agent
    description: Turn the ticket into a reviewable contract (spec, plan, tasks)
    agent: plan-agent
    next: spec_review_gate
    workspace:
      repo: "{repo}"
      base: main
      branch: "spec/{ticket}-{slug}"   # the agent chooses {slug}
    preconditions:
      all:
        - ticket_has_assignee()
    inputs:
      - ticket_body
      - ticket_comments
    onStart:
      comment: "Plan agent starting (attempt {attempt})."
    # The step is done when all of these hold. The globs match any slug.
    postconditions:
      all:
        - branch_exists("spec/{ticket}-*")
        - file_exists("specs/{ticket}-*/spec.md", "spec/{ticket}-*")
        - pr_open("spec/{ticket}-*", "main")
    onSuccess:
      ticketLabel: "factorial:spec-review"
      assign: ["{roles.spec_reviewers}"]
      comment: "Spec ready for review: {pr_url}"

  - name: spec_review_gate
    kind: human_gate
    description: A reviewer approves the spec pull request
    next: build
    gate:
      type: pr_approval
      pr: { head: "spec/{ticket}-*", base: "main" }
      approvers: ["{roles.spec_reviewers}"]
      approverPermission: write        # anyone with write access can also decide
      waiting:
        ticketLabel: "factorial:spec-review"
        assign: ["{roles.spec_reviewers}"]
      onApprove:
        merge: true
        ticketLabel: "factorial:ready-for-build"
        assign: ["{ticket_owner}"]
      onReject:
        rerunStep: plan                # a rejection sends the ticket back to plan
        inputs:
          - pr_review_comments         # the agent reads the review
        ticketLabel: "factorial:spec-rework"
        assign: ["{ticket_owner}"]

  - name: build
    kind: agent
    description: Implement the approved spec
    agent: build-agent
    next: test
    workspace:
      repo: "{repo}"
      base: main
      branch: "impl/{ticket}-{slug}"
    preconditions:
      all:
        - pr_merged("spec/{ticket}-*", "main")   # wait for the spec to merge
    inputs:
      - ticket_body
      - file: "specs/{ticket}-*/spec.md"
    postconditions:
      all:
        - branch_exists("impl/{ticket}-*")
        - pr_open("impl/{ticket}-*", "main")
    onSuccess:
      ticketLabel: "factorial:testing"
      comment: "Implementation ready for testing: {pr_url}"

  # ... test (agent) and code_review_gate (human_gate) follow the same shapes.

terminal:
  step: done
  actions:
    closeTicket: true
    comment: "ADLC complete, merged to main."

For the reasoning behind the shape of a lane, see the example lane.

Steps

A step has one of four kinds, set by kind. Each step has a name (unique in the file), a kind and a next. These are the only keys that every kind needs. next names the step to move to, or the terminal step. Process lifecycle explains how the kinds fit together.

A step name becomes a label. When no outcome fires on a pass, Factorial puts factorial:<step name> on the ticket. Use a name that is safe in a label. A gate with a gate.waiting.ticketLabel shows that label while it waits. Every label that the file names must exist in the repository.

Any step except a gate can show a preview of its output. See Previews.

agent

The step starts an agent on a runtime and accepts the work when the postconditions hold.

KeyRequiredMeaning
name, kindyesThe unique name, and agent. The name is also the steady-state label factorial:<name>.
descriptionnoFree text.
agentyesA folder name under .factorial/agents/. The loader fails when no manifest matches.
nextyesThe step to move to on success. It can be the terminal step.
workspaceyesrepo, base and branch. You must set all three.
preconditionsnoMust hold before an attempt starts. When they do not hold, the step waits. It does not fail.
inputsnoWhat the agent receives. See Inputs.
onStartnoAn outcome block that applies when an attempt starts.
postconditionsnoThe acceptance gate. See Postconditions.
onSuccessnoAn outcome block that applies when the step completes.
timeout, retry, onFailurenoOverride defaults for this step. See Defaults, retries and escalation.
previewnoShows the output of each run on the ticket page.

onStart fires once for each attempt, when the adapter accepts the start. A retry announces itself again. Use it for a comment such as "the agent is working".

workspace.repo is owner/name or a template such as {repo}. A literal without a slash is an error. workspace.branch is the branch that the agent must push. Factorial also uses it to find the pull request for {pr_url}. So a step that pushes nothing still needs a branch that names its pull request. workspace.base is the ref that the checkout starts from. Never put {slug} in base. If the slug cannot resolve, base names a ref that does not exist. The ticket then stops with factorial:blocked on the first attempt.

The checkout at base does not hold the config of the run. An agent that must read .factorial/ reads the pinned commit: git show "$FACTORIAL_CONFIG_SHA":.factorial/<path>.

Unmet preconditions are silent. Factorial declines to start the step on every pass and writes no event. A ticket that waits on a condition that can never hold looks the same as a ticket that is early. Prefer preconditions that watch the finished work of an earlier step, such as pr_merged(...). Do not guard a step against its own earlier attempts. That guard blocks the rework of the step.

human_gate

The step waits for a person to approve or reject a pull request. All of its controls are under gate:. pr_approval is the only gate type.

- name: spec_review_gate
  kind: human_gate
  next: build
  gate:
    type: pr_approval
    pr: { head: "spec/{ticket}-*", base: "main" }   # both are globs
    approvers: ["{roles.spec_reviewers}"]           # optional
    approverPermission: write                       # optional
    waiting:                                        # optional; applies while the gate is open
      ticketLabel: "factorial:spec-review"
    onApprove:                                      # required
      merge: true
      ticketLabel: "factorial:ready-for-build"
    onReject:                                       # optional
      rerunStep: plan
      inputs: [pr_review_comments]
approvers, approverPermission
A filter. One qualifying approval advances the gate. A review qualifies when its author is in approvers. It also qualifies when the author holds approverPermission or a stronger role (write, then maintain, then admin). With neither key, any person qualifies. With only approverPermission, only people with that role qualify. A bot counts only when approvers names it. The App never counts. A review that does not qualify decides nothing. The ticket gets one comment that gives the reason.
waiting.assign
Decides whom Factorial asks to review. approvers does not.
onApprove.merge
The one action that a gate takes itself. It merges the approved commit. If GitHub refuses the merge, for example because a required check failed, the gate keeps waiting and tries again on every pass. The ticket gets one comment with the reason from GitHub. Fix the cause on GitHub.
onReject
Fires when the latest review of a qualifying reviewer asks for changes, and when someone closes the pull request without a merge. It starts rerunStep again. inputs adds to the inputs of that step, and pr_review_comments is what makes the agent read the review. Without onReject, a rejection escalates the ticket. Factorial applies defaults.onFailure.escalate, if the file has it, and marks the ticket as blocked. A review that asks for changes fires onReject one time. After the rework, the gate waits for a new review.

An approval counts only for the commit that it approves. A commit that someone pushes afterward needs a new approval. When an approval and a request for changes from another person both stand, the approval wins.

Factorial accepts the attempt that follows a rejection only when two things hold. Its postconditions hold, and the rejected pull request is no longer open at the rejected commit. In practice, the agent must push to the branch. If the rework needs no change, push a commit to the pull request yourself.

On the dashboard, Request changes rejects the gate in the same way. See Tickets and gates.

A gate has no timeout, retry or onFailure, because it waits for a person. defaults does not apply to it. onReject.rerunStep must name a real step. Do not point it at a check step. The rework completes at once, the old review rejects again on the same pass, and the loop never ends. The lint reports this as check-rerun.

action

The step runs an action that Factorial registers. It needs no agent, no container and no prompt. Its keys are name, kind, action, next, and optionally description, onSuccess, retry, onFailure and preview.

action is a bare name, and an action takes no arguments from the config. The list of actions is closed, and an unknown name fails at parse time. An action step uses retry and onFailure. It has no timeout and no workspace. No sample lane uses an action step.

check

The step completes when its conditions hold in what Factorial sees on GitHub. It needs no agent, no action and no person. Use it to wait for work that Factorial can only watch, such as CI.

- name: validate_ci
  kind: check
  description: CI must be green on the implementation PR before review
  next: code_review_gate
  conditions:
    all:
      - ci_green("impl/{ticket}-*")
      - pr_open("impl/{ticket}-*", "spec/{ticket}-*")
  pr: { head: "impl/{ticket}-*", base: "spec/{ticket}-*" }
  timeout: 30m
  onSuccess:
    ticketLabel: "factorial:code-review"
    comment: "CI green, ready for review: {pr_url}"
KeyRequiredMeaning
conditionsyesThe same combinations as postconditions. Factorial evaluates them on every pass.
prnoThe pull request that the step is about, as a {head, base} pair of globs. It only resolves {pr_url} in the outcomes.
timeout, retry, onFailurenoOverride defaults.
onSuccessnoAn outcome block that applies when the conditions hold.
previewnoAs on an agent step.

While the conditions are false and the timeout has not passed, the wait uses no attempts. The timeout is what ends the wait, because ci_green cannot tell a red run from a run that is still going. At the deadline, Factorial records the attempt as a timeout, with the conditions that failed as the reason. Then the normal retry and escalation steps apply. Set the timeout to the time that the watched work takes, not to the one-hour default, which suits agents.

A check step is better than the alternatives. Factorial evaluates ci_green in the postconditions of the producing step once, when the run ends. Slow CI then fails the attempt and runs the whole agent again. An agent step that only watches uses a container run to wait.

Outcomes

Seven places take the same block of three keys: onStart, onSuccess, onFailure.escalate, gate.waiting, gate.onApprove, gate.onReject and terminal.actions.

KeyEffect
ticketLabelThe factorial: label that the ticket carries after this outcome.
commentOne comment on the issue.
assignReplaces the assignees of the issue. It does not add to them.

assign replaces the assignees so that the ticket always sits with the person who must act next. An entry is a login, a template such as {ticket_owner}, or a role reference such as {roles.spec_reviewers}. A role reference must be the whole entry: "{roles.x}-bot" is an error. A role can name other roles, but a cycle is an error.

Each block has its own templates. Templates has the table.

Defaults, retries and escalation

defaults:
  timeout: 1h
  retry:
    maxAttempts: 2
    backoff: 10m
  onFailure:
    escalate:
      ticketLabel: "factorial:blocked"
      assign: ["{ticket_owner}"]
      comment: "ADLC {step} failed after {attempt} attempt(s): {failure_reason}."

A duration is a whole number and one unit: 30s, 10m or 1h. Nothing else parses.

defaults applies to agent steps (all three keys) and to action steps (retry and onFailure). A step overrides any of them. If a key is not set, the step inherits it. If you set no timeout anywhere, the limit is one hour. That equals the life of the token of a run and the default limit of an agent.

On an interactive runtime, the timeout counts only the time that the session works, not the time that it waits at its prompt. Interactive runs in the container explains interactive sessions.

A retry starts when backoff has passed since the failed attempt ended. To start it sooner, push Retry now and confirm. The button is in the activity of the ticket page, in the header of the attempt that books the retry. That block shows Retry scheduled. The button shows only while the backoff runs.

When a step uses all of its maxAttempts, Factorial applies onFailure.escalate and marks the ticket as blocked. If the block names no label, the ticket gets factorial:blocked. A person removes that label to try again.

Always configure a retry policy. With neither step.retry nor defaults.retry, the file parses, but a failed step can never start another attempt. A person who removes factorial:blocked gets a comment that refuses the retry. The pinned config has nothing to retry with, and a retry: that you add later helps only new tickets.

Policy

policy describes what the process expects of the repository. This differs from defaults, which configures how steps run. Factorial reports on policy and never enforces it.

policy:
  branchProtection: required      # required | advisory | ignored. Default: advisory

policy has one key. branchProtection changes what the Branch protection row on the Configuration page says about the default branch. The row measures the branch against one required approval, review by the code owners, the verify check and dismissal of stale approvals.

ValueA repository that meets the profileOne that does notOne that Factorial cannot read
requiredPassesRedRed
advisory (default)PassesA warning"Not checked"
ignoredNo rowNo rowNo row

Reading branch protection needs the administration: read permission of the GitHub App, and the default install does not grant it. With required and without that permission, Factorial cannot prove protection. The row says so, in red, instead of a neutral "not checked".

Write ignored, never off. YAML reads a bare off as the boolean false. The parser detects it and says so, but the value to write is ignored.

None of the three gate anything

required reports. It does not block activation, admission or a merge. Branch protection on GitHub enforces the rule. See Security.

Runtime

runtime: claude-api      # the slug of a Runtime from the Runtime page

The file selects a Runtime by its slug. The operator defines the Runtime, with its credentials, on the dashboard. A slug has lowercase letters, digits and dashes, starts with a letter or a digit, and has at most 63 characters. Without runtime, the lane uses the default of the deployment. Runtimes explains how a lane selects a Runtime and what sync checks.

Conditions

A condition is one predicate call, or a combination of conditions. The reconciliation loop explains how Factorial uses them.

postconditions:
  all:                                   # every child must hold
    - branch_exists("spec/{ticket}-*")
    - any:                               # at least one child must hold
        - pr_open("spec/{ticket}-*", "main")
        - pr_merged("spec/{ticket}-*", "main")
    - not:                               # the opposite of one child
        ticket_label("factorial:blocked")

A call has the form name("arg", "arg"), with quoted string literals only. A bare number, an unquoted word or trailing text is a parse error that names the call.

The predicate library

Config cannot add to the library. A new predicate is a code change.

PredicateArgumentsHolds when
branch_exists(name)1Some branch matches name.
file_exists(path, ref)2Some branch that matches ref holds a path that matches path.
pr_open(head, base)2A matching pull request is open and not merged.
pr_merged(head, base)2Someone merged a matching pull request.
pr_approved(head, base, by?)2 or 3A matching pull request that is still open has an approval at its current head, by by if you give it.
pr_changes_requested(head)1A matching pull request that is still open has a reviewer whose latest review asks for changes.
ci_green(ref)1Every branch that matches ref has check runs, and all of them completed successfully.
ticket_label(name)1The issue carries that label. The match is exact.
ticket_has_assignee()0The issue has at least one assignee.
issue_comment_contains(marker)1The issue has a comment by the bot login of the App that contains marker.
pr_comment_contains(head, marker)2A matching pull request has such a comment. A change request that Factorial posts from the dashboard does not count.
  • Zero check runs is not green. ci_green is false for a branch where CI never ran.
  • pr_approved ignores merged and closed pull requests, and approvals of a commit that is no longer the head. An old approval must not approve the present.
  • The comment predicates count only comments by the bot login of the App, so that a third party cannot forge the marker. A marker is a literal string that is not empty.
  • There is no predicate for the content of a comment, and none for the number of an attempt.

Globs

Branch names, the head and base of pull requests, and file paths accept *. Label names do not: ticket_label compares exactly. A marker is a literal substring.

Factorial compares a pattern without * exactly. A pattern with * also gives ? and [...] a meaning, so do not use those characters. * crosses /. So specs/12-*/spec.md also matches specs/12-a/nested/spec.md. The predicate still needs a real file at a real ref.

The base of pr_open globs in the same way as its head. So pr_open("impl/{ticket}-*", "spec/{ticket}-*") matches whatever slug an earlier agent chose.

Templates

Factorial recognizes only {name} and {roles.<name>}. All other text is literal, so a string in the config can hold braces such as JSON safely.

PlaceholderAvailable inValue
{ticket}EverywhereThe issue number.
{repo}EverywhereThe target repository, owner/name.
{ticket_owner}EverywhereThe assignee at admission. It does not change when someone reassigns the issue.
{slug}Directives onlyThe part of a branch name that the agent chooses.
{pr_url}onSuccess, gate.waiting, gate.onApprove, gate.onRejectThe pull request of the step, found in what Factorial sees on GitHub.
{step}, {attempt}onStart, onFailure.escalateThe step and the attempt.
{failure_reason}onFailure.escalateWhy the step failed.
{step}terminal.actionsThe name of the terminal step.
{roles.<name>}assign, approversThe members of that role, with nested roles expanded.

onStart and onFailure.escalate get no {pr_url}. At onStart the agent has delivered nothing yet.

{slug}

An agent chooses part of its branch name when it runs, so the config cannot know it in advance. Factorial treats {slug} in two ways:

Directives
workspace.branch and the arguments of predicates. {slug} becomes the concrete name once exactly one candidate is on record. Until then it stays literal.
Matching
The lookups that Factorial makes for pull requests and gates. They always read {slug} as *, resolved or not. A match must only widen against what happened.

Only the newest run that delivered something for a branch template counts. So a rerun on a different slug replaces the run before it. Two templates that disagree, for example a build slug that is not the plan slug, stay ambiguous. A single run that reports two names under one template is ambiguous too. In both cases {slug} stays literal.

Factorial works out the slug again on every pass, so it can go back to literal. Write predicates that hold both ways. Keep a template and its predicate consistent: branch: "spec/{ticket}-{slug}" pairs with branch_exists("spec/{ticket}-*"), never with a literal name.

Postconditions

Postconditions are the acceptance gate of an agent step. Factorial trusts them and never trusts the report of the agent. The reconciliation loop explains why.

They also tell the runtime what to look for after the run. Four predicates imply a delivery:

PredicateExpected delivery
branch_exists(glob)A branch that matches the glob.
pr_open(head, base)A pull request whose head matches. Factorial ignores the base.
issue_comment_contains(marker)A comment on the issue that carries the marker.
pr_comment_contains(head, marker)A comment on a matching pull request.

This holds only in a position that all of the conditions share: the condition itself, or a child of all. A call under any is one choice among several, and a call under not states the opposite. Neither expects a delivery. The postcondition still evaluates in full. The other predicates never imply a delivery.

So put the predicate that names the delivery of the step at the top level of all, not inside any.

Inputs

inputs:
  - ticket_body
  - ticket_comments
  - file: "specs/{ticket}-*/spec.md"

Three bare names exist: ticket_body, ticket_comments and pr_review_comments. Anything else must be a {file: <path>} entry, and its path accepts globs.

A file input passes the path, not the content. Every run holds a token for the repository, so the agent reads the file itself. The input tells it what to open.

Only the ticket owner can give comment inputs. A comment becomes input only when its author is the owner at admission. An agent acts on its inputs without supervision, and a comment from a third party would be a way to inject instructions. One exception exists: gate.onReject.inputs also trusts the reviewers that the gate lets decide, those in approvers or with approverPermission. A gate with neither trusts no review text.

pr_review_comments carries every review of such a reviewer: the body and the inline comments, with the file and line of each. A standalone inline comment counts as a review. Each digest is at most 16,000 bytes. Factorial cuts it from the oldest end, so the newest comments stay whole.

An image in ticket_body reaches the first turn of the agent as an image, and as a file whose path the prompt names. So does an image in a comment or review that passes these filters. A comment that the filters drop, or that the cap cuts, brings no image. A run gets at most 20 images of at most 5 MB each. The prompt marks an image that the run cannot get as not delivered, for example an image from another repository.

Put pr_review_comments in gate.onReject.inputs. There it makes a rework attempt read the review that it answers. In the inputs of a step it costs an extra pull request lookup, so do not declare it where nothing uses it.

Terminal steps

terminal:
  step: done
  actions:
    closeTicket: true
    comment: "ADLC complete, merged to main."

The terminal step is virtual. It must not also appear in steps, and the parser reports it if it does. actions is an outcome block plus closeTicket. If it names no ticketLabel, the ticket gets factorial:done. See how a ticket ends.

What the parser checks

After the fields parse, the parser checks the whole graph of steps. It reports every problem in one error, not only the first.

  1. Step names are unique.
  2. The name of the terminal step does not equal the name of a real step.
  3. entry.step names a real step.
  4. Every next names a real step or the terminal step.
  5. Every gate.onReject.rerunStep names a real step.
  6. entry.step reaches the terminal step, along next and the rework edges of gates.
  7. No step is unreachable from entry.step.

Rule 7 catches a half-finished edit. A step that you renamed and did not point at is an orphan, and the file does not load.

Authoring checklist

  1. Read the CI workflows of the target repository first. The process must agree with them. The sample graph is not right for every repository, and a pull request with the wrong base can still show green.
  2. Give every agent step a workspace.branch whose {slug} a * glob in its own postconditions matches.
  3. Put the delivery predicate of each step at the top level of postconditions.all.
  4. Set defaults.retry and defaults.onFailure.escalate, so that a failure is both retryable and visible.
  5. Give every outcome an assign, so that the ticket never sits with nobody.
  6. Do not guard a step against its own earlier attempts.
  7. Check that every agent: name has a folder under .factorial/agents/ with a matching agent.yaml.
  8. If you set runtime, check that the slug exists on the Runtime page and that its family matches the runtime.kind of each agent.
  9. Press Commit and sync, or merge the change into factorial by pull request and sync. Tickets in flight keep the commit that they pinned. The next ticket gets the new one.

Check that it parses

The process editor does this check for you. It validates the text each time you change it. A label next to Commit and sync reads Valid config, Valid, 1 warning or Invalid config. Commit and sync writes only valid config.

The label is the check for a process file. If it reads Invalid config, correct the file until it changes. Then press Commit and sync, which writes only valid config, or sync the commit as Connect a repository describes. A sync refuses a file that the parser refuses.