Objective
An objective is the top unit of Factory work: a durable, receipt-backed goal. Its receipts live on the streamfactory/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.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:
status=<label> and summary=<summary>. Six of the pairs:
Mode and severity
An objective has a mode, eitherdelivery 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 isrepoSlotConcurrency. 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 anodeId 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:
collecting_evidence, evidence_ready, synthesizing — and an evidence status — empty, 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:
title, prompt (or task), and dependsOn, which is an array of strings.
Candidate
A candidate is one attempt at a task, numbered from01 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 withjob_, 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:
- If
checkswas supplied at all — including as an empty array — that is an explicit contract and is used as given. - Investigation objectives get
[]. - Connected-system objectives get
[]— that is, objectives that declare required capabilities, or whose capability context already has a selected Receipt Connect provider. - A profile whose
defaultValidationModeisnonegets[]. - Otherwise the default list, which is
["bun run build"].
--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,archiveactionPolicy.allowedCreateModes:delivery,investigationdefaultObjectiveMode: 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.mdpaths
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.
OpenSandbox host is not healthy after readiness check for <protocol>://<domain>.
The task packet
Everything a worker reads and writes for one task lives underreceipt/current/ in the task workspace:
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:
pathprints the absolutereceipt/currentpath.showmaterializes a repository projection intoreceipt/current/when none is mounted, then printsReceipt workbench: <path>, the objective and task titles when it has them, andRead receipt/current/context.md and receipt/current/receipt-cli.md.finalizeprints a JSON object with two keys,resultPath(receipt/current/output/result.json) andsummaryPath(receipt/current/output/summary.md), both as absolute paths.
Unknown receipt-workbench command: <cmd> on stderr.
Prerequisites
The message a missing config produces is:
.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:
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.
--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 objectFactory config defaultChecks must be an arrayFactory config defaultChecks at index <i> must be a stringFactory config defaultPolicy must be an object, and the same wording fordefaultPolicy.concurrency,defaultPolicy.budgets,defaultPolicy.throttlesanddefaultPolicy.promotionFactory config schedules must be an arrayFactory config schedule at index <i> must be an objectFactory config schedule at index <i> requires agentIdFactory config schedule '<agentId>' must set intervalMs >= 1000Factory config schedule '<agentId>' requires payload to be an objectFactory config has duplicate schedule id '<id>'Factory config already exists at <path>— frominitwithout--force