Skip to main content
Factory is the engine behind Receipt’s background runs. Give it a written goal and it breaks the goal into tasks, runs each one inside a disposable sandbox computer, integrates the results on an integration branch, and records the run as receipts you can replay afterwards. This page defines the vocabulary you see in the CLI’s output and the configuration file that shapes a run. The command reference covers how to drive it.
Factory is reachable only from the in-repo developer CLI. receipt factory requires a Receipt checkout, Bun and Postgres, and is absent from the released receipt binary. See The developer CLI for the difference between the two programs named receipt.

Objective

An objective is the top unit of Factory work: a durable, receipt-backed goal. Its receipts live on the stream factory/objectives/<objectiveId>, and its id starts with objective_. Commands accept either the bare id or the full stream name. An objective carries twelve statuses: planning, waiting_for_slot, collecting_evidence, evidence_ready, synthesizing, executing, integrating, promoting, completed, blocked, failed, canceled completed, failed and canceled are terminal. blocked also stops an objective, but it is deliberately kept out of the terminal set because an operator receipt can unblock or redirect it during replay. These are the transitions the objective reducer actually performs:
blocked, failed and canceled are reachable from any running status, not only from executing. Two of the twelve declared statuses — collecting_evidence and evidence_ready — are accepted everywhere an objective status is read but are never assigned to an objective by the reducer in this release; they surface as the task execution phase and as the objective’s phase detail instead.
Alongside the status the CLI surfaces two further vocabularies. Display state, the coarse label: Draft, Queued, Running, Awaiting Review, Stalled, Blocked, Completed, Archived, Failed, Canceled Phase detail, the finer one: draft, waiting_for_slot, waiting_for_computer_lease, waiting_for_control, waiting_for_synthesis, waiting_for_promotion, collecting_evidence, evidence_ready, synthesizing, integrating, promoting, cleaning_up, awaiting_review, stalled, completed, blocked, failed, canceled, archived The display state and the phase detail appear together in the header the CLI prints for an objective, alongside the status authority and the slot state:
Each phase also carries a fixed label and a fixed one-line summary, which the CLI prints as status=<label> and summary=<summary>. Six of the pairs:

Mode and severity

An objective has a mode, either delivery or investigation, and a severity, an integer from 1 to 5. Both default from the profile: the checked-in profile sets defaultObjectiveMode: investigation, and because it declares no default severity the resolved default is 1. Mode is not cosmetic — it changes whether checks run, whether the objective competes for the repository slot, whether the promotion gate runs at all, and whether auto-promotion is allowed.

The repository execution slot

Delivery objectives compete for a repository execution slot. Investigation objectives do not consume one at all, so they are admitted as soon as they are queued. How many delivery objectives may hold the slot at once is repoSlotConcurrency. It defaults to 20 and is overridden by RECEIPT_FACTORY_REPO_SLOT_CONCURRENCY; the CLI and the runtime resolve it the same way, and the value is coerced to at least 1. Anything beyond the limit waits. The board reflects that constraint in its five sections — Needs Attention, Active, Queued, Completed and Archived — two of which carry a description that says exactly what the slot means:
  • Active — Objectives currently holding the repo execution slot.
  • Queued — Objectives waiting for the repo execution slot.

Task and the task DAG

A task is a focused piece of an objective and a node in a directed graph: it has a nodeId and a dependsOn list of other node ids. Task statuses are: pending, ready, running, reviewing, approved, integrated, blocked, superseded Those statuses are bucketed for display and for scheduling:
A task also carries an execution phasecollecting_evidence, evidence_ready, synthesizing — and an evidence statusempty, partial, sufficient, final.

Seeding the graph

You seed the graph when you create the objective, with either form:
--initial-task is repeatable, takes title::prompt, and has the alias --parallel-task. The JSON form takes the whole graph at once:
Each entry accepts title, prompt (or task), and dependsOn, which is an array of strings.

Candidate

A candidate is one attempt at a task, numbered from 01 within that task. Candidate ids contain _candidate_ — for example task_01_candidate_01 — which is how receipt factory parse tells a candidate target from a task target. Candidate statuses are: planned, running, awaiting_review, changes_requested, approved, integrated, rejected, conflicted

Job

A job is the queued unit of work behind a task or a control action. Job ids start with job_, and job statuses are queued, leased, running, completed, failed and canceled. The Factory config accepts four lanes — chat, collect, steer and follow_up. See Jobs and durable execution for how the queue itself behaves. A run is an agent-loop run inside a stream whose name ends /runs/<runId>.

Check

A check is a shell validation command. The runtime runs the configured checks inside the sandbox computer twice: against a candidate’s workspace before that candidate can be delivered, and against the integration workspace during integration validation, before promotion. --check accepts comma- or newline-separated values and is repeatable. The runtime resolves the check list in this order:
  1. If checks was supplied at all — including as an empty array — that is an explicit contract and is used as given.
  2. Investigation objectives get [].
  3. Connected-system objectives get [] — that is, objectives that declare required capabilities, or whose capability context already has a selected Receipt Connect provider.
  4. A profile whose defaultValidationMode is none gets [].
  5. Otherwise the default list, which is ["bun run build"].
Two objective kinds run no checks at all: investigation objectives and connected-system objectives. If you create an objective in the default mode and expect a build to gate it, you will not get one — the shipped profile defaults new objectives to investigation.
The CLI applies a shorter version of the same rule before it sends the objective: explicit --check values win; --objective-mode investigation sends no checks; a profile with defaultValidationMode: none sends no checks; otherwise it sends defaultChecks from .receipt/config.json.

Promotion

Promotion publishes the approved integration branch. The runtime dispatches a publisher job that pushes the branch to the GitHub remote and opens a pull request against the source branch; nothing is merged for you. The integration status is one of: idle, queued, merging, validating, validated, ready_to_promote, promoting, promoted, conflicted receipt factory promote asserts that the profile allows the promote action, runs the promotion gate, and then requires both integration.status = ready_to_promote and an active candidate. Without both it fails with a 409 and the message objective is not ready to promote. The gate itself is skipped entirely for investigation objectives. When it does run, it refuses with one of exactly six messages:
  • Promotion gate blocked: planning receipt is missing.
  • Promotion gate blocked: <taskId> is still blocked.
  • Promotion gate blocked: no integrated task satisfied the objective.
  • Promotion gate blocked: <taskId> is missing its completion contract.
  • Promotion gate blocked: <taskId> did not record proof for the completed work.
  • Promotion gate blocked: <taskId> still reports remaining work.

Auto-promotion is on by default and off in practice

promotion.autoPromote defaults to true. The runtime then forces it to false for every investigation objective, and the checked-in profile defaults new objectives to investigation.
In a stock checkout the practical default is manual promotion: you create an objective, it runs, and you call receipt factory promote yourself. Do not plan around auto-promotion unless you are explicitly creating delivery objectives.

The profile

A profile lives at <profileRoot>/profiles/<id>/PROFILE.md, optionally with a SOUL.md beside it. The repository ships exactly one: profiles/receipt/, whose canonical id is receipt and whose label is Beetle. Its frontmatter declares:
  • actionPolicy.allowedDispatchActions: create, react, promote, cancel, cleanup, archive
  • actionPolicy.allowedCreateModes: delivery, investigation
  • defaultObjectiveMode: investigation, defaultValidationMode: repo_profile, defaultTaskExecutionMode: worktree, maxParallelChildren: 5, allowObjectiveCreation: true
  • an orchestration block: executionMode: supervisor, discoveryBudget: 2, finalWhileChildRunning: reject, childDedupe: by_run_and_prompt
  • thirteen checked-in skills/*/SKILL.md paths
Every objective mutation asserts the action against allowedDispatchActions, so a profile that omits an action rejects it. When a manifest omits a field, the resolved defaults are allowedWorkerTypes: ["codex","infra","agent"], defaultWorkerType: "codex", defaultTaskExecutionMode: "worktree", defaultValidationMode: "repo_profile", defaultObjectiveMode: "investigation", defaultSeverity: 1, maxParallelChildren: 5, allowObjectiveCreation: true. Profile-root resolution walks candidates until one contains a profiles/ directory: the requested root, then repoRoot, then RECEIPT_REPO_ROOT, then the installed package root. The errors are no factory profiles found under <root>/profiles, factory profile '<id>' is not installed under <root>/profiles, and — as a 403 — profile '<id>' is not allowed to create Factory objectives.

Execution: one path, one target

The execution path has exactly one legal value, computer, and the only computer execution target is opensandbox. Neither is a choice you make per objective; the flags exist to name the one value each accepts. Anything else fails with --execution-path must be 'computer', --computer-provider must be one of: opensandbox, or --execution-target must be one of: opensandbox — the last from --execution-target, the shorthand the CLI’s own help advertises, which sets both the path and the provider. Three environment variables control the lane: Sandbox configuration defaults:
The host idle value is still parsed into the sandbox configuration, but nothing stops the host on it any more. The shared host is used by every runtime role, so one worker’s idleness cannot authorise shutting it down: stopping the host is an explicit operator action in this release.
The default worker image is local-only, and two guards refuse to use it remotely. Set OPEN_SANDBOX_IMAGE to a published image before you run anything remotely. The computer lane section of Factory engine quotes both refusal messages in full.
Starting the host runs a readiness health check. When it fails the error is OpenSandbox host is not healthy after readiness check for <protocol>://<domain>.

The task packet

Everything a worker reads and writes for one task lives under receipt/current/ in the task workspace:
Integration packets add receipt/current/output/<candidateId>.integration.json, .integration.stdout.log and .integration.stderr.log. The whole receipt/current/ directory is git-ignored, as is .receipt/data/. The checked-in helper receipt/bin/receipt-workbench exposes three commands:
  • path prints the absolute receipt/current path.
  • show materializes a repository projection into receipt/current/ when none is mounted, then prints Receipt workbench: <path>, the objective and task titles when it has them, and Read receipt/current/context.md and receipt/current/receipt-cli.md.
  • finalize prints a JSON object with two keys, resultPath (receipt/current/output/result.json) and summaryPath (receipt/current/output/summary.md), both as absolute paths.
Anything else exits 2 with Unknown receipt-workbench command: <cmd> on stderr.

Prerequisites

The message a missing config produces is:
Every variable above can come from an environment file: the CLI loads them before any command dispatches, later files overriding earlier ones but never overriding a value already exported in your shell. The developer CLI lists the ten locations in order.

.receipt/config.json

receipt factory init writes this file; every command that touches an objective loads it. Discovery walks up from the current directory looking for <dir>/.receipt/config.json, and --repo-root or RECEIPT_REPO_ROOT pins it. init writes repoRoot, dataDir, codexBin, defaultChecks (the validation commands it detected, which you can accept or replace at the prompt) and defaultPolicy. Two further keys are optional and read only when present. A file containing every key the loader understands looks like this:
How each top-level field resolves:
string
default:"."
--repo-root or RECEIPT_REPO_ROOT, then the stored value, then "." — resolved against the directory that contains .receipt/.
string
default:".receipt/data"
RECEIPT_DATA_DIR, then DATA_DIR, then the stored value, then .receipt/data. The stored value and the default are resolved against the directory that contains .receipt/; the two environment values are resolved against your current directory instead.
string
default:"codex"
RECEIPT_CODEX_BIN, then the stored value, then codex.
number
default:"20"
RECEIPT_FACTORY_REPO_SLOT_CONCURRENCY, then the stored value, then 20. Coerced to at least 1.
string[]
Trimmed and de-duplicated.
object[]
Normalized into heartbeat specs. Entries with enabled: false are skipped. agentId is required, intervalMs must be at least 1000, and payload must be an object. lane defaults to collect and accepts chat, collect, steer or follow_up; sessionKey defaults to schedule:<id>; singletonMode defaults to cancel and accepts allow, cancel or steer; maxAttempts defaults to 1 and is clamped to 1–8; id defaults to schedule:<agentId>:<index+1>.

defaultPolicy — the four groups that exist

The policy decoder reads exactly four groups, containing six keys in total. Values are clamped after decoding, and a numeric string such as "3" is coerced to the number 3.
Every other key is read and silently discarded. There is no mutation group, no budgets.maxReconciliationTasks and no throttles.mutationCooldownMs — the policy type has exactly the four groups above.The repository’s own checked-in .receipt/config.json still contains budgets.maxReconciliationTasks: 8, throttles.mutationCooldownMs: 15000 and a mutation: { aggressiveness: "balanced" } block. All three are parsed and thrown away. Copying that file verbatim into your own repository teaches you three settings that do nothing.

--policy-file

--policy-file <path> reads a JSON file through the same decoder, so its errors are worded Factory config policy file must be an object, Factory config policy file.budgets must be an object, and so on. The parsed result is shallow-merged over defaultPolicy one group at a time, and any group that is not concurrency, budgets, throttles or promotion is dropped in the merge as well.

Parse errors

The loader throws on a malformed file rather than falling back to defaults:
  • Factory config must be a JSON object
  • Factory config defaultChecks must be an array
  • Factory config defaultChecks at index <i> must be a string
  • Factory config defaultPolicy must be an object, and the same wording for defaultPolicy.concurrency, defaultPolicy.budgets, defaultPolicy.throttles and defaultPolicy.promotion
  • Factory config schedules must be an array
  • Factory config schedule at index <i> must be an object
  • Factory config schedule at index <i> requires agentId
  • Factory config schedule '<agentId>' must set intervalMs >= 1000
  • Factory config schedule '<agentId>' requires payload to be an object
  • Factory config has duplicate schedule id '<id>'
  • Factory config already exists at <path> — from init without --force
Next step: read the receipt factory command reference.