The HTTP surface of the CommonForge control plane (apps/worker, Hono on Cloudflare Workers).
Every client talks to it: the CLI (cforge, Task 18), the web app (Task 20), the demo agents
(Task 19), editions (Task 21), previews and releases (Task 22), and the integration runner (Task 14).
Type names in code refer to @commonforge-org/core Zod schemas (for example ProposalRecord,
FrozenCandidate, ProjectSnapshot), or to @commonforge-org/runner-protocol (SignedJob,
RunnerResult, RunnerCredentialRequest), unless the text says otherwise. Every request body is
JSON unless a route says otherwise. Every timestamp is ISO-8601 UTC with milliseconds.
- Ledger. The
OrgLedgerDurable Object (one per organization) is the single authority for accepted state: heads, proposals, candidates, approvals, receipts and the audit trail. - Directory. D1 (
DB) holds the directory: users, sessions, orgs, roles, grants, projects, mounts and resources. It also holds bookkeeping for tasks, runner jobs, intake and exports, and the full snapshots behind proposal ids. - Slow work never runs inside a request. It runs in the
commonforge-stepsqueue, or in the integration runner through signed jobs oncommonforge-runner-jobs. Slow work here means creating and seeding a workspace, importing a URL, and preparing a candidate. - Artifacts is reached through the REST API. A Workers binding serves exactly one namespace,
but CommonForge needs one namespace per org (
<prefix>-<org>-canonical|tasks|editions). The Worker therefore uses theCF_ACCOUNT_IDandCF_API_TOKENsecrets. Every create, token mint, delete and import goes through the per-namespaceOpsGateDO, which allows at most 25 concurrent operations (spike A9).
Every error response has this shape:
{ "error": { "code": "expected_head_stale", "message": "…", "details": { "resourceId": "…" } } }code is stable. message is for humans and may change.
| Status | Meaning |
|---|---|
| 400 | Malformed input. Codes: full_return_unmatched, invalid_request, invalid_url, invalid_path, revision_mismatch, grant_exceeds_role, not_git, not_assets, invalid_result, zip_refused, invalid_edition_return, and for uploads and file changes invalid_size, invalid_part, part_size_mismatch, parts_mismatch, upload_incomplete, size_mismatch, invalid_upload, edit_unsupported. |
| 401 | No valid credential (unauthenticated), or a bad, used or expired sign-in link (invalid_token). |
| 403 | The caller can see the object but may not do this. Codes: insufficient_role, no_write_grant, agent_not_allowed, lease_required, canonical_write_refused, revision_not_allowed, approval_role_not_allowed, promotion_not_visible, export_incomplete, org_create_forbidden, the broker refusals (access_exceeds_job, repo_not_in_job, identity_mismatch, namespace_role_mismatch, job_invalid, job_unknown), evidence_invalid, scope_violation, no_edition_grant, not_session_owner. |
| 404 | The object does not exist for this caller. Non-members, missing grants and other orgs all get 404, so nothing leaks. |
| 409 | State conflict. Uploads: upload_finished. Ledger refusals use their own code: expected_head_stale, dependency_stale, approval_invalid, policy_revision_stale, proposal_hash_mismatch, receipt_missing, evidence_missing, proposal_not_prepared, idempotency_key_conflict, restore_target_unknown, restore_noop. Freeze refusals: candidate_moved, candidate_moved_during_freeze, freeze_incomplete. Editions: edition_stale, edition_policy_changed, edition_not_ready, edition_read_only, edition_unavailable, no_allowed_changes. File changes: idempotency_key_reused. Others: workspace_not_writable, task_not_ready, task_not_running, job_finished, result_conflict, last_owner, mount_exists, policy_changed, … |
| 410 | job_expired: the presented SignedJob has expired. |
| 413, 411 | ZIP upload too large, or no content-length. Uploads and file changes: file_too_large, part_too_large, too_many_operations, change_too_large, edit_too_large, edits_too_large; length_required (411). The LFS server answers 413 for a batch of more than 100 objects. |
| 429 | rate_limited (magic links), invite_rate_limited (resending a member invite). |
| 501 | not_implemented (none at the moment). |
| 502, 503 | Runner or provider failure: seed_failed, runner_failed, receipt_mismatch, provider_unavailable, compare_unavailable, invite_failed (the invite email could not be sent on resend). |
OrgLedger RPC errors keep the ledger's codes: ledger_invalid_input 400, ledger_not_found 404,
ledger_wrong_organization 403, ledger_conflict 409.
| Kind | Format |
|---|---|
| Org | o-<12 hex> |
| Project | p-<12 hex> |
| Resource | res-<16 hex> |
| Task | task-<uuid> |
| Change | chg-<uuid> |
| Proposal | prop-<uuid> |
| Task workspace | tw-<24 hex>. Deterministic per (task, resource). |
| Intake | in-<16 hex> |
| Export | exp-<16 hex> |
| Job | job-<24 hex> |
Revisions, candidates and snapshots use core's formats: art:<repoId>:<sha>, r2m:<manifestId>,
cand:<taskWorkspaceId>:<sha>, and a 64-hex SnapshotId or ProposalHash.
acceptandrestoretake anidempotencyKeymatching[A-Za-z0-9._:-]{8,200}.- The server scopes the key per organization.
- The same key with the same request replays the original
acceptedVersionId(replayed: true). - The same key with a different request is refused with
idempotency_key_conflict.
| Principal | Credential | Can call |
|---|---|---|
| user (browser) | Cookie __Host-cf_session (HttpOnly, SameSite=Strict, Secure, Path=/, no Domain: a sibling host on the same site cannot plant it; a request carrying two copies authenticates nobody; review I-2). The browser never sees the session value. |
Everything their role and grants allow |
| user (CLI) | Authorization: Bearer cfs_…, issued only by POST /api/auth/cli/verify |
Same |
| agent | Authorization: Bearer cfl_…, a task lease token |
Only /api/tasks/<its task>/…. It acts on behalf of the lease holder; the holder's role and grants are re-checked on every request. |
| runner | The SignedJob in the request body |
Only /api/runner/…, and only for repositories and keys that the job names |
- Every bearer secret is stored only as
HMAC(COMMONFORGE_SESSION_SECRET, purpose|token). - CORS allows only the
COMMONFORGE_APP_URLorigin, with credentials. - CSRF. State-changing requests (
POST,PUT,PATCH,DELETE) without anAuthorizationbearer are checked; this covers cookie sessions and sign-in itself. A presentOriginmust beCOMMONFORGE_APP_URL's origin or the Worker's own; withoutOrigin,Sec-Fetch-Sitemust besame-originornone. Otherwise the answer is403 csrf_refused. JSON bodies must be sent ascontent-type: application/json(415 unsupported_media_type)./api/runner/*is exempt, because the SignedJob authenticates it.
| Route | Body | Response |
|---|---|---|
POST /api/auth/magic-link |
{ email, client?: "web"|"cli" = "web" } |
202 { ok: true }, whether or not the address is known. Details below. |
POST /api/auth/verify |
{ token } |
{ user: { userId, email }, expiresAt } (no-store). Details below. |
POST /api/auth/cli/verify |
{ code } |
{ user, sessionToken: "cfs_…", expiresAt } (no-store, no cookie). This is the only endpoint that hands out a session bearer. |
POST /api/auth/cli/code |
Signed in (cookie or bearer). 201 { code: "cfm_…", expiresAt } (no-store): a one-time cforge login --code code for the caller, without an email. Valid once, for 15 minutes; 30 per person per 15 min (429 rate_limited). The app's Give to agent puts it in the prompt it copies, so the agent can sign in by itself. |
|
GET /api/auth/sessions |
Signed in. { sessions: [{ sessionId, client: "web"|"cli"|"handoff"|"unknown", createdAt, expiresAt, current }] } (no-store): the caller's live sessions, newest first. sessionId is a prefix of the session's keyed hash, never the token. handoff: opened with a hand-off prompt's code; it lasts 7 days instead of 30. |
|
POST /api/auth/sessions/revoke |
{ sessionId } or { others: true } |
Signed in. { ended }: ends one of the caller's own sessions, or all but the current one. Another person's session id ends nothing. |
POST /api/auth/logout |
{ ok: true }. Revokes the session (cookie or bearer) and clears the cookie. |
|
GET /api/me |
{ user, memberships: [{ orgId, name, role }], canCreateOrgs: boolean } |
Magic-link requests (/api/auth/magic-link):
-
Rate limits are atomic, held in the OpsGate DO
ratelimit:magic-link:- 5 per address per 15 min
- 10 per client IP (
cf-connecting-ip; IPv6 bucketed by /64) per 15 min - 200 per hour globally, to protect the sender domain
Over a limit the answer is
429 rate_limited. -
webemails a link<APP_URL>/auth/verify?token=cfm_…. -
cliemails a code (<APP_URL>/auth/cli?code=cfm_…) forcforge login --code. -
Each link or code is valid once, for 15 minutes.
Verify (/api/auth/verify):
- It accepts web tokens only and sets the session cookie (30 days).
- The response body never carries the session (I-5).
- Nothing is consumed on GET. The app's
/auth/verifypage must ask for a click ("Sign in") and then POST, so mail-link scanners cannot burn the link.
The first sign-in of an address creates its user.
Email delivery.
/healthreportsdeployment(production, orciwhenCOMMONFORGE_DEPLOYMENT=ci) andemailMode. Log mode is honored only on a local Worker or the CI deployment; anywhere else it is refused (no link is written, sign-in fails) and/healthanswersok: false(review m4).COMMONFORGE_EMAIL_MODE=senduses Cloudflare Email Service through thesend_emailbindingEMAIL. It sends fromCOMMONFORGE_EMAIL_FROM, the sender address you onboarded for Email Sending. The binding is restricted to that sender (allowed_sender_addresses); the committedwrangler.jsonccarries the placeholderlogin@example.com, and a deploy writes the real address into both (scripts/deploy-live.sh,scripts/bootstrap.sh --email-from).COMMONFORGE_EMAIL_MODE=log(dev only) writes the link to the Worker log.- Tests inject
CapturingEmailTransport.
| Role | Default rights |
|---|---|
owner |
Everything in the org. Implicit read and write on every project and resource. Manages members and grants. Only owners change project policy and mounts, see the ledger audit, take over another developer's live lease, and see task repo coordinates other than their own. |
developer |
Creates projects, resources, intakes, tasks, proposals and exports. Integrates, approves, accepts and restores on resources it has a write grant for. Reads only granted projects and resources. |
client |
Reads granted projects and resources, and approves proposals (if policy allows the role). Never sees canonical coordinates, never gets a credential, never accepts. |
viewer |
Reads granted projects and resources. Never approves. Never sees canonical coordinates. |
- Grants come from
PUT /api/orgs/:org/grants, with{ userId, scope: { kind: "project"|"resource", id }, operations: ["read"|"write"] }.- A project grant shows the project and those mounts whose resource the caller can also read.
- A resource grant shows the content.
- A project grant never exposes a resource by itself (AT-16).
- Viewers can hold only
readgrants. Only owners grantwrite. - A client's
writemeans Can change (2026-10-14): in a staging-mode project, their agent may change that folder in its own task copy, and the change goes to staging for review. It never lets a client write the folder itself (resourceOpsreads it asread: no uploads, no file changes, no canonical credentials,canWritefalse). A client withreadonly changes nothing. Migration 0017 gave existing clientswritewherever they could already change a folder this way. - Developers may only add
read, and may not touch a grant that includeswrite(403 grant_owner_only). - Revoking a grant, or narrowing it from write to read, ends the user's task leases and revokes their task tokens (N-2, N-3).
- Creators get a read+write grant on what they create.
GET /api/orgs/:org/grants[?userId=]lists grants.DELETE /api/orgs/:org/grants(owner only) takes the same body withoutoperations.
| Org route | Who | Body → response |
|---|---|---|
POST /api/orgs |
Instance admin (COMMONFORGE_ADMIN_EMAILS) |
{ name } → 201 { orgId, name, role: "owner" }. Anyone else: 403 org_create_forbidden. |
GET /api/orgs |
User | { orgs: [{ orgId, name, role }] } |
GET /api/orgs/:org |
Member | { orgId, name, createdAt, role } |
POST /api/orgs/:org/archive |
Owner | → { orgId, archivedAt }. Hides the org from every member's GET /api/me and GET /api/orgs; every member route then answers 404. Nothing is deleted. |
POST /api/orgs/:org/unarchive |
Owner of the archived org | → { orgId, archivedAt: null }. 404 for anyone else, and for an org that is not archived. |
GET /api/orgs/:org/members |
Owner or developer | { members: [{ userId, email, role, since }] } |
POST /api/orgs/:org/members |
Owner | { email, role } → 201 { userId, email, role, invite: "sent"|"failed"|"rate_limited" } (creates the user if needed). Emails an invite linking to <COMMONFORGE_APP_URL>/signin (no token). The member is added whatever the email outcome. Invites are limited to 30 per hour per org and per inviter. |
POST /api/orgs/:org/members/:user/invite |
Owner | Resends the invite → { invite: "sent" }; 429 invite_rate_limited; 502 invite_failed; 404 for a non-member. |
PATCH /api/orgs/:org/members/:user |
Owner | { role }. The last owner cannot be demoted (409 last_owner). Demoting to client or viewer strips write grants and ends the member's leases. |
DELETE /api/orgs/:org/members/:user |
Owner | Removes the membership and the member's grants, ends their task leases and revokes every token (write and read, reference copies included) minted on those tasks' repos. |
| Route | Who | Body → response |
|---|---|---|
POST /api/orgs/:org/projects |
Owner or developer | { name } → 201 { projectId, name } |
GET /api/orgs/:org/projects |
Member | { projects: [{ projectId, name, createdAt }] }, visible projects only |
GET /api/orgs/:org/projects/:pid |
Project grant | { projectId, name, membershipRevision, policyRevision, mounts: [{ mountName, resourceId, provider, resourceName, mode, operations, canWrite, canStartTask }], policy?, policyChanged?: { at, by } }. Lists only readable mounts. canWrite says whether the caller may upload to and change the folder (an owner, or a developer with write on it; always false for clients and viewers); the Files tab shows write actions only when it is true, and the write routes still check for themselves. canStartTask says whether POST …/projects/:pid/tasks accepts the folder in writeMounts for the caller, by the same rule the route enforces: owners and developers need a write grant on the folder, a client a git folder in a staging-mode project that the owner opened for writing and gave them Can change on (a resource grant with write; so it can be true while canWrite is false); the mount must allow write and the folder be active; always false for viewers. (A folder with no accepted version yet still answers 409 no_accepted_head on create.) policy and policyChanged (who last changed the policy, from the latest project.policy_changed event; by is an email or null) are shown to developers and owners only, and policyChanged only once a change exists. |
GET /api/orgs/:org/projects/:pid/access |
Owner, or developer with the project | { people: [{ userId, email, role, level: "none"|"read"|"write", folders: [{ mountName, resourceId, level, min?, shared?, via? }] }] } for every member. Owners are write (implicit). folders lists the folders the caller can read (owners: all). min is the folder's floor: the highest level the person's other projects that attach it give (capped by role, never above what they hold); this project can't take it lower. shared: true marks a floor above this project's level (the folder is held there by another project); via is that project's name when the caller can see it. |
PUT /api/orgs/:org/projects/:pid/access |
Owner; developer (none → read only) | { userId, level, folders?: { [resourceId]: level } } → the person's access. Writes the project grant and one grant per folder in one batch. A folder can only be lower than the project (400 folder_exceeds_project); 400 unknown_folder, 400 grant_exceeds_role (viewers: read at most; a client's write is Can change, see Grants), 400 owner_access_implicit. Developers may only raise someone from none to read with no exceptions (403 grant_owner_only). Developers' PUTs skip folders they can't read; the response lists the folders the caller can read. Removing or narrowing ends the person's task leases. A folder the person also reaches through another project is narrowed at most down to its floor (min); the floor never raises a folder. Developers' PUTs never write write. A real move to none (from a project grant) also ends the person's grants on this project's editions (same batch) and revokes their edition tokens (edition.grant_revoked). |
GET /api/orgs/:org/projects/:pid/folders |
Owner, or developer with the project | { folders: [{ mountName, resourceId, resourceName, provider, state, error?, settingUp, setupFailed, alsoIn: [{ projectId, name }] }] }. state is empty, importing, active or failed. settingUp: true while a folder added with POST …/folders waits for its first version (state is empty meanwhile); a failed setup is state: "failed", settingUp: false, setupFailed: true, with error ("Setting up this folder failed: …"). setupFailed is true only for a folder whose setup failed, the only case where POST …/folders/:mount/setup/retry can work (offer Try again only then; a folder failed for another reason, such as an import, has setupFailed: false). Non-owners see only folders they can read, and alsoIn lists only projects they can read. |
POST /api/orgs/:org/projects/:pid/folders |
Owner, or developer with the project | { name, kind? } → 201 { mountName, resourceId }, before the folder's first version exists. Creates a git folder (an Artifacts repository, provider: "artifacts", state: "empty"), attaches it under name (mode must-match-accepted, read + write) and starts its setup. There is one kind of folder: kind is optional and ignored ("files" and "code" give the same folder; any other value is 400 invalid_request). Everyone with access to the project gets it (automatic access). A developer creator holds read + write on it. 409 mount_exists creates nothing. Setup: a few seconds later the runner has put the folder's first commit on its main and it is bound as the folder's first version (state: "active", event resource.initialized). Like an import, that first version is genesis: no proposal, no checks, no approval, and no staging (in a staging-mode project it is live at once). The commit holds exactly .gitattributes (Git LFS for media, archives, fonts, PDF and Office files: one header comment, then *.<ext> filter=lfs diff=lfs merge=lfs -text per extension), .lfsconfig ([lfs] url = <COMMONFORGE_PUBLIC_URL>/api/lfs/<org>/<resourceId>/info/lfs, see The LFS server) and README.md (# <name>). Author: the creator; committer CommonForge <noreply@commonforge.invalid>; both dated the request's second; message Create folder <name>. Every attempt of a setup makes the same commit: the first job's files, author and message are kept and reused (also by Try again), with the request's second. The setup is tried up to 3 times (a stalled one is picked up again by GC after 15 minutes); then the folder is failed (event resource.init_failed) until someone tries again. File collections (r2-assets) are no longer made here: use POST /orgs/:org/resources with provider: "r2-assets" (owners and developers), then POST …/mounts (owners). |
POST /api/orgs/:org/projects/:pid/folders/:mount/setup/retry |
Owner, or developer with write on the folder | 202 { state: "empty", settingUp: true }: starts a failed setup over (3 more attempts; event resource.init_retried). 404 for a folder the caller cannot read; 403 no_write_grant for a developer without write; 409 setup_not_failed unless the folder's setup failed (still setting up, done, or a folder attached rather than added here). An owner may also detach a failed folder. |
PUT /api/orgs/:org/projects/:pid/policy |
Owner | { checks: CheckSpec[], contracts: ContractSpec[], approvalRoles?: Role[], rules: string[], preview?, release?, publishRoles?, staging? } → { policyRevision, policy }. Every section is optional except checks, contracts and rules; staging needs a release section (see "Staging"). Bumps the ledger's policy revision. This is the only source of checks, contracts and approval roles; repository files never become policy (AT-18, AT-38). Optional expectedRevision: when stale, 409 policy_changed with details.policyRevision. A save that races another save can also get 409 policy_changed (with the current details.policyRevision), even without expectedRevision; nothing is saved. |
POST /api/orgs/:org/projects/:pid/policy/validate |
Owner | A full policy document → { ok, issues: [{ path, message }] }, running the same checks as PUT …/policy (schema, release targets, Worker quota). Never saves. path is the field path joined with .; release-target refusals use release. |
POST /api/orgs/:org/projects/:pid/mounts |
Owner | { mountName, resourceId, mode?: DependencyMode = "must-match-accepted", operations?: ["read","write"] } → 201. A write operation needs a write grant. Adding a mount bumps membershipRevision. Attaching grants the folder to every non-owner with access to the project, at their project level (clients and viewers: read), never narrowing an existing grant. Import attaches do the same. A folder the project already attaches (under any name) is 409 folder_already_attached; a taken name is 409 mount_exists. |
DELETE /api/orgs/:org/projects/:pid/mounts/:mount |
Owner | Removes the mount, never the resource → { ok: true, changed: userIds }. Each non-owner with access to the project keeps only what their other projects attaching the same folder give; otherwise the folder grant is removed. People without access to the project are untouched. The mount removal, the membershipRevision bump and every grant write are one batch (all or nothing). Leases end for anyone who lost access or write. Records mount.removed. |
GET /api/orgs/:org/projects/:pid/snapshot |
Project grant | ProjectSnapshot of the mounts at their accepted heads. Non-owners see only entries they can read. |
GET /api/orgs/:org/projects/:pid/tree?mount=&path=&revision= |
Resource grant | { mountName, resourceId, provider, revisionId, path, entries: [{ path, type, size?, lfs? }] }. Defaults to the accepted head. revision= is allowed for owners and developers only (403 revision_not_allowed). In a git folder, a file that is an LFS pointer to an object CommonForge holds is listed with the object's real size and lfs: true (recognised by the pointer's git blob id, one lookup per listing): accepted objects at the head, also staged ones at an explicit revision. A pointer to an object CommonForge never received (an imported repository's) is listed as an ordinary small file. |
GET /api/orgs/:org/projects/:pid/file?mount=&path=&revision= |
Resource grant | Raw bytes (application/octet-stream, content-disposition: attachment, x-commonforge-revision). For git folders x-commonforge-blob is the file's git blob id (the baseBlob of an edit, see Folders with uploads); for assets the body is streamed from R2 and x-commonforge-sha256 is set. A git file that is an LFS pointer is served as the real file, never the pointer: the object is streamed from R2, pinned to the etag its hash step verified, with x-commonforge-lfs: <sha256>, x-commonforge-sha256 and content-length, and no x-commonforge-blob (LFS files are not edited in place). Any reader gets accepted objects at the head; at an explicit revision (owners and developers) staged objects too. An object whose bytes are gone or were written again answers 404 object_missing. A pointer to an object CommonForge does not hold is served as the pointer text, with x-commonforge-lfs: missing and its x-commonforge-blob. |
POST /api/orgs/:org/resources |
Owner or developer | { name, provider: "artifacts"|"r2-assets", branch? } → 201 Resource. Details below. |
GET /api/orgs/:org/resources |
Member | { resources: Resource[] }, readable only |
GET /api/orgs/:org/resources/:rid |
Resource grant | Resource & { head: AcceptedHead|null, operations } |
POST /api/orgs/:org/resources/:rid/adopt-head |
Write grant | Binds an empty canonical repo to the current head of its branch (genesis; idempotent). → { resourceId, revisionId }. Returns 409 branch_empty if the branch has no commits. |
GET /api/orgs/:org/resources/:rid/versions |
Resource grant | { versions: [{ acceptedVersionId, sequence, kind, proposalId, proposalHash, acceptedAt, head }] }, newest first |
GET /api/orgs/:org/resources/:rid/candidates |
Owner or developer | { candidates: Candidate[] } |
GET /api/orgs/:org/candidates/:cid |
Owner or developer | Candidate |
POST /api/orgs/:org/resources details:
artifactscreates the canonical repo<prefix>-<org>-canonical/<resourceId>and revokes its create token at once. The resource starts in stateempty.r2-assetswrites an empty manifest and binds it as the genesis head (stateactive).
Resource is { resourceId, name, provider, state: "empty"|"importing"|"active"|"failed", branch, createdAt, updatedAt, error? }.
Owners and developers also get coords (ArtifactsCoords or AssetManifestCoords) and source.
Candidate is FrozenCandidate without fork (non-developers only) plus { state, preparedReceiptId }.
| Route | Who | Body → response |
|---|---|---|
POST /api/orgs/:org/intake/url |
Owner or developer | { url, branch, name, depth?, projectId?, mountName? } → 202 { intakeId, resourceId, state: "queued" }. Details below. |
POST /api/orgs/:org/intake/zip?name=<archive> |
Owner or developer | Raw ZIP body. content-length is required; the maximum is 100 MiB. → 202 { intakeId, state: "inventorying", sha256, jobId }. Details below. |
GET /api/orgs/:org/intake/:id |
Owner or developer | { intakeId, kind, state, request, plan?, inventory?: { files }, result?, error? } |
POST /api/orgs/:org/intake/:id/confirm |
Owner or developer | { projectId?, skip?: string[], include?: string[] } → { resources: [{ resourceId, name, provider, mountName? }], excluded, includedAnyway?: string[], pendingPopulation: true, note }. Details below. |
URL import (/intake/url):
- Only public
https://remotes are accepted: no credentials, no query, no localhost. - The import runs in the queue step
intake.import. - A 504 is retried once with
depth: 1, and the resource is recorded ashistoryTruncated. - A 409 while in progress is polled. A 404 after a 409 is a failed import.
- The 24 h import token is revoked.
- The branch is recorded explicitly.
- The head is bound in the ledger.
- The optional mount is created. Naming a project (
projectId+mountName) is owner-only, likePOST /projects/:pid/mounts(403 insufficient_role); a mount name the project already has is refused up front with409 mount_exists(details.names), before any resource exists.
ZIP upload (/intake/zip):
- The body is hashed while it streams to R2 at
org/<org>/intake/<id>/upload.zip. - A runner
inventoryjob lists it. The listing is deterministic and nothing is executed. - The result becomes a
plan:{ placements: [{ resourceName, provider, pathPrefix, files, bytes, reason }], excluded: [{ path, reason }] }. - The plan rules:
node_modules, nested.git, build output and caches are excluded with a reason. Folder names match case-insensitively (.GIT/,NODE_MODULES/,Dist/).- Credential-looking file names are blocked. Only the name is shown; the content is never read.
- Media files and files over 32 MiB go to an
r2-assetscollection. - Each top-level folder becomes its own code resource.
Confirm (/intake/:id/confirm):
- Creates the planned resources and mounts. With
projectIdit is owner-only (adding mounts is project membership); without it, owners and developers create the resources only. - Every planned folder name (minus
skip) must be free in the project: otherwise409 mount_existswithdetails.names, before anything is created. Each added mount recordsmount.added. - Populating them from the staged archive needs a runner job that does not exist yet
(
intake-apply). The response says so withpendingPopulation: true. - Include anyway (
include, owner-only: a developer sending it gets403 insufficient_role): exact paths (up to 4096 characters, like inventory paths) of files the dry run excluded only because of a secret-looking name (.env*,.npmrc,id_*keys,*.pem, …), which the owner has inspected. Each path must be in the plan'sexcludedlist, every row for it must carry that reason (a duplicate entry name the inventory refused makes the path non-includable), it must be no larger than 32 MiB, and fall in a code resource this confirm creates (its top-level folder, or the root resource; not skipped). Files excluded for.git/,node_modules, build output or caches, refused by the inventory (path traversal) or unknown are never includable. Any refusal is400 include_refusedwithdetails.paths(the offending paths) anddetails.refused: [{ path, why: "not_excluded"|"not_secret_name"|"oversize"|"no_placement" }], before anything is created. Accepted paths are recorded on the plan asincludedAnyway: [{ path, resourceName }], returned asincludedAnyway, subtracted fromexcluded, and logged asintake.included_anyway(detail.paths), an org-level event naming no resource, so only owners see it.GET /intake/:idalso showsplan.includedAnywayandresult.includedAnywayto owners only (developers still see every excluded path, as before). - ZIP population is still pending (
intake-apply), soincluderecords the owner's intent for when it lands: today nothing reads any archived file's contents. The inventory itself only ever lists names and sizes; files not included stay unread and unstored after population too.
| Route | Who | Body → response |
|---|---|---|
POST /api/orgs/:org/projects/:pid/tasks |
Owner or developer with write grants; client in a staging-mode project (see below) | { title, request?, writeMounts?: string[], readMounts?: string[] } → 201 Task. Details below. |
GET /api/orgs/:org/projects/:pid/tasks |
Owner or developer; client in a staging-mode project (own tasks only) | { tasks: [{ taskId, title, state, changeId, createdAt, updatedAt, leaseExpiresAt }] }. updatedAt moves when a lease is taken or released and when the state changes (a push does not move it); leaseExpiresAt is the current lease's expiry, or null with no lease (never taken, or released). |
GET /api/tasks/:id |
A client who created the task (staging mode), or a developer who can read the project and every resource the task snapshot names, or the task's agent | Task & { candidates: [{ candidateId, resourceId, taskWorkspaceId, state, preparedReceiptId, sha, changedPaths }] }. Workspaces carry remoteUrl (the -tasks repo) only for the agent, the lease holder and owners. |
POST /api/tasks/:id/lease |
User (developer or owner, or the client who created the task in staging mode) who can see the task and has write grants on every written resource | { ttlSeconds?: 300..28800 = 3600 } → { taskId, leaseToken: "cfl_…", leaseGeneration, expiresAt } (cache-control: no-store). Details below. |
DELETE /api/tasks/:id/lease |
The agent, the lease holder or an owner | Releases the lease (bumps the generation, revokes write tokens). |
POST /api/tasks/:id/credential |
Agent only (403 lease_required for users) |
{ mountName, access: "read"|"write" } → WorkspaceCredential { tokenId, secret, scope, expiresAt, remoteUrl } (no-store). Details below. |
GET /api/tasks/:id/materialization?root=/abs/path |
Developer or agent | MaterializationPlan (runner-protocol planMaterialization). Each git (Artifacts) mount's source also carries lfsUrl, the folder's LFS endpoint (<COMMONFORGE_PUBLIC_URL>/api/lfs/<org>/<resourceId>/info/lfs, see The LFS server; left out when COMMONFORGE_PUBLIC_URL is not https or loopback http); asset mounts have none. Details below. |
GET /api/tasks/:id/assets/file?mount=&path=[&source=snapshot|workspace] |
Agent only (task running; 403 lease_required for users) |
Raw bytes of one asset, streamed from R2 (application/octet-stream, content-disposition: attachment, cache-control: private, no-store, x-commonforge-revision: r2m:<manifestId>, x-commonforge-sha256). Details below. |
POST /api/tasks/:id/assets/stage |
Agent | { mountName, path, size, mime } → { stagingKey, uploadUrl } |
PUT /api/tasks/:id/assets/upload?key= |
Agent (task running, write grant, content-length required) |
Raw bytes → 201 { stagingKey, size } |
POST /api/tasks/:id/assets/commit |
Agent | { mountName, changes: [{ path, stagingKey, sha256, mime } | { path, delete: true }] } → { taskWorkspaceId, manifestId, parentManifestId, entries }. Promotion verifies each object's size and sha256 (400 asset_sha256_mismatch), then the next manifest is written. |
Staging mode (client tasks, spec 2026-10-07 §4). In a project whose policy has a valid
staging and release section, a client who can read the project may also create tasks (write
mounts: code folders whose mount allows write and on whose resource the owner gave the client Can
change, a write grant), list and
read the tasks they created, lease them, and freeze as lease holder. Only their agent (the lease
token) gets credentials, and only for the task's own -tasks copies; the client's session gets
403 lease_required from /materialization, no remoteUrl/fork, and the client view (opaque
revision ids, no taskWorkspaceId/sha) on /api/tasks/* as on /api/orgs/:org/*. Anything short
of a readable staging-mode project answers 403 insufficient_role as before. Proposals, integrate
and accept stay owner/developer. A client's task in a staging-mode project is pinned to the
live staging build's revision for each resource that build names, else to the accepted head. Until
Phase 2 promotion, owner and developer tasks start from the accepted heads, as without staging (a
proposal based on unpromoted staging work would fail integrate with stale_base).
Provider errors on the task routes (GET/POST/DELETE /api/tasks/:id/*, freeze) keep their status and
code but, for a client or their agent, carry a fixed plain message ("The workspace service is
unavailable; try again." for provider_unavailable, else "This task's workspace could not be
updated ()."), because the provider's text can name the task repository. Owners and
developers still get the raw message.
Creating a task:
- The folders come from the project's access settings unless the body names them. Without
writeMounts, the agent may change every mount the caller may start a task in (canStartTask:
the same rule as for a named mount) that has an accepted version; with none, 403 no_write_grant.
Without readMounts, it reads every other mount the caller can read. A mount the caller cannot
read is never included. The web app sends no folders for clients; owners and developers may
narrow them.
- The snapshot is pinned to the current accepted heads (staging mode: see above). Written mounts get operations
["read","write"]; everything else gets ["read"].
- Each written Artifacts resource gets a workspace
tw-… (access: "write"), created in the queue step
workspace.create. That step is serialized per workspace id by an OpsGate lock. It calls
createIsolatedChange, which creates a fresh -tasks repo and seeds it with a seed-workspace
runner job. The handle is then recorded in the ledger.
- Each read (reference) Artifacts mount gets a read-only copy in
-tasks (access: "read"),
created and seeded the same way at the pinned sha. Agents never get a token for a canonical repo
(plan §F row 1, controller ruling I-6). Reference copies are collected (state collected) when
the task turns submitted. When the task fails they are marked abandoned, in D1 and the ledger,
and the GC sweep deletes them; it also cancels running tasks without a live lease and with no
activity for 7 days (see "Outbox, wakeups and GC").
- Asset mounts get an R2 staging area, which is writable at once.
- Task states:
provisioning, ready, running, submitted, failed, canceled (GC: running, no live
lease, 7 days inactive; ready tasks are never canceled).
Task is { taskId, orgId, projectId, changeId, title, request, state, snapshot: ProjectSnapshot, lease, workspaces: [{ taskWorkspaceId, mountName, resourceId, provider, access, baseRevision, state, assetManifestId?, error?, remoteUrl? }] }.
Taking a lease:
- Taking over another developer's live lease is owner-only; others get
409 lease_held.
- A new lease bumps the generation.
- The previous agent token stops working (401).
- Active write tokens on the task repos are revoked.
- The event
task.started is recorded.
Agent credentials:
- For a written mount, the broker calls
issueWorkspaceCredential on the task's own -tasks repo.
That call refuses canonical namespaces, non-writable or freezing workspaces, and stale lease
generations. The TTL is at most min(1 h, lease remaining).
- For a reference mount, the broker issues a read token on the task's read-only reference copy
in
-tasks; a write request gets 403 read_only_mount.
- A lease with less than 60 s left gets
409 lease_too_short.
- Asset mounts answer
400 not_git.
Materialization plan:
- Built from the ledger and grants only; it contains no tokens.
- Writable mounts point at the task repo.
- Reference mounts point at their read-only
-tasks copy with credential: "helper"; the
workspace.json coordinates are rewritten the same way, so the plan names no canonical repository.
The CLI helper asks /credential (read) for them.
- Asset mounts carry their pinned manifest (read-only in the workspace). The CLI downloads each
object with the lease through
GET /api/tasks/:id/assets/file (below); changes go through
assets/stage → upload → commit.
Agent asset reads (GET /api/tasks/:id/assets/file). cforge checkout materializes asset
mounts with this route, so an agent holding only the task lease can check out a task whose snapshot
includes an R2 mount. The user-only project file route (GET /api/orgs/:org/projects/:pid/file)
still answers 403 agent_not_allowed to a lease.
- Principal. Only the current lease token of this task. Users (even the lease holder) get
403 lease_required; another task's lease gets 404; an expired, released or replaced lease is no
principal (401). The lease holder's read grant on the resource is re-checked on every request.
- State. The task must be
running (409 task_not_running).
- Mount. It must be named in the task snapshot (else 404, even if the project mounts it) and
pinned to an asset manifest (
r2m:). Git mounts, written or reference, answer 400 not_assets.
- Manifest, chosen server-side. The caller never names a revision.
source=snapshot (default): the manifest pinned in the task snapshot. This is what the
materialization plan lists, so checkout bytes always match the plan's sha256s.
source=workspace: the task's own asset workspace (written mounts only): its last committed
manifest, else its base. A reference mount has no asset workspace (404).
- Path. It must be a normalized relative path (
400 invalid_path) listed in that manifest (404).
Directories are not served.
POST /api/tasks/:id/workspaces/:ws/freeze can be called by the task's agent, its assignee (the
current lease holder, who also needs a write grant) or an owner (I-1). Others get
403 not_task_assignee, or 404 if they cannot see the task. Reference copies are never frozen
(409 workspace_read_only). Candidate fork coordinates are shown only to owners and to the
freezing agent or assignee.
Workspace
Body
Response
Artifacts
{ expectedSha, tool? }
201 Candidate (with fork)
Asset
{}
{ taskWorkspaceId, state: "frozen", manifestId }
Artifacts freeze sequence:
- The workspace is marked
freezing. The provider's workspace resolver then reports it frozen,
so no new write token can be minted.
freezeCandidate runs:
- It checks HEAD against
expectedSha (409 candidate_moved).
- It revokes every write token, then re-reads HEAD.
- It computes
changedPaths server-side with a Worker tree compare. The paths are never taken
from input.
- The ledger records the
CandidateRecord (state frozen) and the workspace (state frozen).
- The queue step
candidate.prepare runs runner prepare-candidate through
prepareForAcceptance. It records the PreparedReceipt and attaches it to the open proposals
that contain the candidate.
- On failure the workspace is writable again.
Route
Who
Body → response
POST /api/orgs/:org/proposals
Owner or developer with write grants
{ taskId, candidateIds?: CandidateId[] } → 201 Proposal. 409 staging_proposal in a staging-mode project ("this project uses staging: promote the task instead"; landings make their own proposals). Details below.
GET /api/orgs/:org/proposals[?projectId=]
Member
{ proposals: [{ proposalId, proposalHash, projectId, changeId, state, updatedAt }] }. Only proposals whose changed resources the caller can all read.
GET /api/orgs/:org/proposals/:id
Same
Proposal. Client and developer views carry the identical proposalHash (AT-19).
POST /api/orgs/:org/proposals/:id/integrate
Owner or developer with write grants on the changed resources
→ 202 { proposalId, jobId, state: "queued" }. Details below.
POST /api/orgs/:org/proposals/:id/approvals
Owner, developer or client (subject to policy approvalRoles)
{ proposalHash } → 201 ApprovalRef. Details below.
GET /api/orgs/:org/proposals/:id/approvals
Same as GET proposal
{ approvals: ApprovalRef[] }. Non-developers see only the approver's kind.
POST /api/orgs/:org/proposals/:id/accept
Owner or developer with write grants
{ idempotencyKey } → { proposalId, acceptedVersionId, replayed }. Details below.
POST /api/orgs/:org/resources/:rid/restore
Write grant
{ expectedHead, targetRevision, idempotencyKey } → { resourceId, acceptedVersionId, replayed, headRevisionId }. Details below.
Creating a proposal:
- The default candidate set is the task's frozen candidates. When a resource has several, the
latest freeze wins.
- The proposal also includes the task's frozen asset manifests as
assetManifestChanges.
- Policy references (Task 23 review C1). The proposal's base snapshot (and so its candidate
snapshot,
candidateSnapshotId and hash) also pins every project mount the policy's checks and
contracts need (check.mountName, a contract's producer mount, schema resource and consumer
resource) that the task snapshot lacks, at its accepted head, with mode must-match-accepted
and operations: ["read"] (re-review R1). They appear as readDependencies (redacted for
callers who cannot read them). Once one of them moves (for example an api change is accepted
while a requirements-only proposal waits), the proposal lists it in stale and accept refuses
with 409 dependency_stale: no accepted pair of producer, schema and consumer escapes the
contract run. Re-proposing the task re-pins the references at the new heads (no rebase: they
are read-only); validate the new proposal again. Agents and clients get no credential for them: the broker and the
materialization plan read the task snapshot.
proposalHash is computed with core computeProposalHash.
state is prepared once every change has a prepared receipt; otherwise it is open.
In a Proposal, readDependencies on resources the caller cannot read appear as
{ redacted: true, mode }.
Proposal is ProposalRecord & { stale: ResourceId[], approvals, integration: { jobId, forCurrentHash, integratedAt, checks, contractChecks, mergeOutcome, results? } | null, candidates: Candidate[], lastAttempt: IntegrationAttempt | null }.
integration is the latest successful integrate receipt; a failed run leaves it unchanged.
lastAttempt is the latest integrate run whatever its outcome (Task 20 review C-1):
{ state: "queued"|"applying"|"succeeded"|"failed", expired, forCurrentHash, requestedAt, completedAt, code, checks: [{checkId, result}], contractChecks: [{contractId, result, producerResource?, consumerResource?}], mergeOutcome, conflictPaths, signedEvidence }.
code is the runner failure code (contract_failed, check_failed, textual_conflict, stale_base, …, integration_rejected when the control plane refused a receipt, or receipt_not_recorded, below); checks, contracts and merge outcome come from the run's signed evidence when it has any.
contractChecks[].producerResource / consumerResource are left out for callers who cannot read that resource (re-review m1), as readDependencies are redacted.
A run that is superseded never counts as the latest attempt: an older job that finishes after a
newer one was requested, or a job that finishes after the proposal was accepted or closed, is
marked failed with code integration_superseded (event proposal.integration_superseded) and
its receipt is not recorded, so it never overwrites the newer state (re-review R3).
state: "applying" (review I1): the runner reported success but the ledger has not recorded the
receipt yet. integration is read from the ledger (the authority), so it appears only once the
receipt is recorded; the web treats applying as still validating. A retried result POST
re-applies an integrate result (idempotent), so a POST that failed after the job row was updated
never leaves the ledger without its receipt. applying is bounded (re-review R3): if the receipt
is still not recorded 2 minutes after the runner reported (completedAt), the attempt reads
state: "failed", code: "receipt_not_recorded", and the web offers "Validate again". A late
replay that does record it turns the attempt succeeded.
Owners and developers also get jobId, detail (the runner's redacted text) and evidenceId; clients and viewers get codes, names and paths only (I-3).
stale lists resources whose head moved off the base, and must-match-accepted dependencies that moved.
Integrate:
- The proposal must be
prepared.
- Each base must still be the head, asset manifests included; otherwise
409 expected_head_stale
with details.resourceId, because stale-base policy is "refuse" (§C.2).
409 nothing_to_integrate only for a proposal with neither git nor asset changes.
- The runner
integrate job uses server-owned checks and contracts only, all of them: a
check or contract whose mount or resource is not in the proposal's snapshot is refused with
409 policy_unevaluable (details.unevaluable), never dropped (review C1). Re-propose after
fixing the policy or the mounts.
job.references lists every other git entry of the base snapshot (canonical coordinates
verified against the live repo, pinned by the snapshot's sha). The runner gets read access to
them only, so checks and contracts on mounts the proposal does not change can run (N-1).
job.baseSnapshot is the proposal's real base snapshot, and the runner checks each target's
entry against expectedHead. job.candidateSnapshotId is the proposal's candidate snapshot id,
and the evidence must be signed for it (a required, signature-covered field of runner-protocol's
integrate job).
- The results endpoint binds every receipt to its job with runner-protocol's
parseRunnerResultForJob. For integrate this checks the proposal, refs, expected heads, and that
evidence.snapshotId === job.candidateSnapshotId; anything else is 400 invalid_result. The
Worker re-checks the evidence against the ledger's proposal.candidateSnapshotId before
recording, and otherwise records proposal.integration_rejected.
- The result's evidence must verify with the per-job HMAC. The receipt is then recorded in the
ledger.
- Assets-only proposals (only
assetManifestChanges, for example a logo replacement;
follow-up Task 23) are integrated too. The job has no candidates and no targets and carries the
proposal's asset changes as assetChanges (runner-protocol README). Every git entry of the base
snapshot is a reference, so the policy checks and contracts run there (offline, after the
runner's own dependency install); nothing is merged or pushed and no write credential is minted.
The receipt has results: [] and its evidence mergeOutcome: []. The evidence names no
manifest: it binds them through proposalHash, which covers assetManifestChanges. A failing
required check or contract refuses as for any proposal (check_failed, contract_failed).
Approvals:
- The
proposalHash must be the current one; otherwise 409 proposal_hash_mismatch with
details.currentHash.
Accept calls OrgLedger.accept (planCommit) with this input:
Field
Value
expectedHeads
The bases the reviewers saw.
newHeads
The integration commits, or the fast-forward candidates; r2m:<newManifestId> for asset changes.
approvals
Filtered by current membership and policy roles.
evidence
The integrate receipt's signed evidence (for this proposal hash). Every proposal needs it, assets-only ones included: without a successful integrate run for the current hash, accept answers 409 evidence_missing.
- A refusal is
409 <ledger code>, for example expected_head_stale with details.resourceId.
- While the proposal is
prepared and its latest integrate run is applying, accept answers
409 integration_applying with retry-after: 2 (review I1); past the grace,
409 receipt_not_recorded (validate again). These checks are skipped once the proposal is
accepted, so a lost-response retry replays (re-review R3).
- A retry with the same key returns
replayed: true and the same acceptedVersionId.
Restore:
- Moves the head forward to a previously accepted revision.
409 restore_target_unknown if the revision was never accepted, 409 restore_noop if it is
already the head, 409 expected_head_stale if the head moved.
Route
Who
Response
POST /api/orgs/:org/projects/:pid/exports
Owner or developer who can read every mounted resource
202 { exportId, snapshotId, jobId, state }. A runner export job writes git bundles and asset manifests under org/<org>/exports/<exportId>/.
GET /api/orgs/:org/exports/:id
Same
{ exportId, projectId, state, snapshot, receipt: ExportReceipt|null, downloads: string[] }
GET /api/orgs/:org/exports/:id/download?key=
Same
Bytes of one receipt item
GET /api/orgs/:org/events?after=&limit=&projectId=
Member
{ events: [{ seq, at, projectId, action, subject, actor, detail? }], next }. Filtering rules below.
GET /api/orgs/:org/audit?after=&limit=
Owner (org-wide and unfiltered)
{ audit: [{ seq, …AuditEvent }], next }, the ledger audit trail
Event filtering (I-3):
- Every event records the resources it is about.
- Non-owners see an event only with read on its project, if it has one, and on every resource
it names. Org-level events that name no resource are owner-only.
- Clients and viewers see only client-visible events. They get
detail = the event's
client summary (codes and paths, for example { code, conflictPaths }), never raw runner
output, and the actor reduced to its kind.
Event actions:
- Org and project setup:
org.created, member.*, grant.*, project.created,
project.policy_changed, mount.added, resource.created, resource.head_bound,
resource.initialized (a new folder's first version: { mountName, commitSha }),
resource.init_failed ({ mountName, code, detail, attempts }), resource.init_retried.
- Intake:
intake.requested, intake.imported, intake.confirmed, intake.included_anyway
(owner-only).
- Tasks and candidates:
task.created, task.started, task.lease_released,
workspace.provisioned, credential.issued, assets.committed, task.candidate_frozen,
candidate.prepared, candidate.prepare_failed.
- Proposals:
proposal.created, proposal.integration_requested, proposal.integrated,
proposal.integration_failed, proposal.approved, proposal.accepted.
- Restore and export:
resource.restored, export.requested.
- Staging:
staging.landing_checking, staging.landed, staging.landing_failed,
staging.rolling_back, staging.rolled_back (subject: the task), staging.land_failed (the
landing step itself failed), staging.build_failed and staging.reset (subject: the project).
All are client-visible; a client's detail carries a code only (for example land_failed).
- Outbox and GC:
task.abandoned (actor service:worker-outbox), outbox.dead_lettered (names
the event's resources; org-level otherwise).
task.started and task.candidate_frozen are the AT-05 overlap evidence.
A Workspace Edition is a developer-authorized, fresh-history copy of selected source, in its
own repo <prefix>-<org>-editions/ed-<editionId> (PRD §4.5, plan §B.13, §C.1 row 9). Clients edit
it in any tool and send it back as a ZIP; the server maps the return onto the canonical resource
and opens a normal proposal. The edition manifest (inventory, mappings, protected paths, canonical
bases, source release) is server-owned (D1 edition_builds); nothing in an upload can change it.
Policy (body of create / PUT …/policy):
{ "name": "Website pages",
"sources": [{ "mountName": "website",
"include": ["index.html", "src/**", "public/**", "tests/quote-view.test.ts", "package.json"],
"write": ["index.html", "src", "public", "tests"],
"editionPathPrefix": "" }],
"exclude": ["**/.env*"],
"protectedPaths": ["package.json", "vite.config.ts", "tests/quote-contract.test.ts"] }
include (read allowlist): canonical globs (core matchesPathPattern; a plain path matches
itself and below). Only these files are published. exclude (default **/.env*) and the
segments .git, .commonforge, .vibecontrol are never published; symlinks and submodules are
never copied.
write (write allowlist): canonical path prefixes (files or folders, no globs; "" = the
whole resource). They become writable mappings; everything else published is read-only.
protectedPaths: canonical globs a return may never change, even inside a write prefix. Matched
case-insensitively too (Tests/Quote-Contract.test.ts is tests/quote-contract.test.ts). The
exclude patterns are protected for returns as well (a client cannot add src/.env.production).
- Up to 8 sources (Artifacts mounts only); at most one has
write (a return targets one
resource). editionPathPrefix defaults to the edition root for one source, else the mount name;
prefixes may not overlap.
- Configuring, building and granting need the owner, or a developer with write grants on every
source resource (
403 no_write_grant). Policy changes bump policyRevision, un-publish the
current build (state draft until the next build: no new sessions, edition_not_ready) and
revoke every edition token (a build requested under the older revision is never promoted); sessions of the older revision can no longer download, clone or
return (409 edition_policy_changed). Each build publishes the policy snapshotted on its row.
Route
Who
Body → response
POST /api/orgs/:org/projects/:pid/editions
Owner / developer (write on sources)
Policy + name → 201 Edition (state draft). 409 staging_mode in a staging-mode project ("staging-mode projects use client tasks").
GET /api/orgs/:org/projects/:pid/editions, GET /api/orgs/:org/editions
Member
{ editions: Edition[] }, visible ones only
GET /api/orgs/:org/editions/:eid
Edition grant, or developer who can read the project and every source
Edition
PUT /api/orgs/:org/editions/:eid/policy
Manager
Policy (+ optional name) → Edition
POST /api/orgs/:org/editions/:eid/builds
Manager
→ 202 { editionId, buildId, state: "queued", sourceReleaseId }. Details below.
GET|PUT|DELETE /api/orgs/:org/editions/:eid/grants
Manager (GET: developers)
{ userId }. Clients and developers may hold edition grants (viewers: 400 grant_exceeds_role). DELETE revokes the user's edition read tokens. Removing a member, or demoting one to viewer, deletes their edition grants and revokes their tokens; viewers never have edition use.
POST /api/orgs/:org/editions/:eid/sessions
Owner or grant holder
{ title, request? } → 201 Session ("Start an update": pins the current build). 409 edition_not_ready before the first build ("Developer setup required"), 409 edition_read_only without a write allowlist. 409 staging_mode once the project is in staging mode ("staging-mode projects use client tasks"), also for an edition made before.
GET /api/orgs/:org/editions/:eid/sessions
Same as GET edition
{ sessions }: own sessions; developers see all
GET /api/orgs/:org/edition-sessions/:sid
Session owner, or developer view
Session
GET /api/orgs/:org/edition-sessions/:sid/download
Same
The working copy as a stored ZIP (application/zip, no-store): exactly the build's inventory, read from the edition repo and checked against the manifest hashes. 409 edition_unavailable once a rebuild replaced the commit.
POST /api/orgs/:org/edition-sessions/:sid/credential
Session owner
{ ttlSeconds? = 3600 (60..86400) } → { tokenId, secret, scope: "read", expiresAt, remoteUrl, branch, commit, current } (no-store): a read token on the edition repo, never on a canonical one (AT-25).
POST /api/orgs/:org/edition-sessions/:sid/returns?mode=partial|full&delete=<path>&tool=<name>
Session owner
Raw ZIP body (content-length required, ≤ 32 MiB) → 202 Return. 409 staging_mode once the project is in staging mode, also for a session started before. Details below.
GET /api/orgs/:org/edition-returns/:rid
Return owner, or developer view
Return
Edition (client view): { editionId, projectId, name, state: "draft"|"building"|"ready"|"failed", ready, policyRevision, files, editable: string[], protected: string[], readOnly, builtAt, canStartUpdate, createdAt, updatedAt }. Developers also get policy, canManage,
currentBuildId, repo (the edition repo's namespace, name and remote URL) and builds: [{ buildId, state, policyRevision, sourceReleaseId, canonicalBases, exportedSnapshotId, commit, files, error?, createdAt, updatedAt }].
Session: { sessionId, editionId, editionName, projectId, title, request, state, files, stale, policyChanged, downloadUrl, downloadRoot, returnUrl, returns: Return[], createdAt, updatedAt } (stale: the
target moved since the build, so a return would be refused).
Return: { returnId, sessionId, editionId, state, mode, deletions, changes: [{ path, kind, oldPath? }], blocked: [{ path, reason: "not_mapped"|"read_only"|"protected"|"path_escape"|"case_collision"|"path_conflict" }], unchanged, keptAsIs, unknownDeletions, ignored, strippedFolder, proposalId, tool, error?: { code, message }, createdAt, updatedAt }. Paths are edition paths. Developers also get canonicalPath
per change, taskId, taskWorkspaceId, jobId, resourceId, size, sha256 and
error.detail (the runner's text). States: queued → running → freezing → proposed, or
no_changes, stale (stale_base), conflict (textual_conflict), refused
(scope_violation), failed.
Build (queue step edition.build): the sources' current accepted heads become the build's
canonicalBases and snapshot. sourceReleaseId is the project's live release at build time
(the release service's live pointer, Task 22; AT-30), or null while nothing has been published
(core EditionManifestServerSide.sourceReleaseId is nullable; services/editions.ts
liveRelease() reads release_live). The step creates
the edition repo once (its create token is revoked at once), then issues build-edition: canonical
read only, the edition repo write only; one parentless commit with only the allowlisted
blobs, pushed to refs/heads/main (a rebuild replaces it). The runner refuses to publish when a
configured canary string occurs (AT-26). The result marks the build ready with its inventory.
Return (AT-28, AT-29, AT-30):
- The session's build must still have the edition's policy revision (
409 edition_policy_changed)
and the target's ledger head must still be the build's base (409 edition_stale: a return is
never applied over newer accepted or live work).
- The Worker reads the ZIP with a safe reader (
lib/zip.ts, mirroring the runner's): traversal,
absolute paths, symlinks, special files, duplicates, case collisions and ratio bombs refuse the
whole upload (400 zip_refused, details.rejected / details.reason); so do ZIP64,
encryption, lying sizes or CRCs. OS metadata (__MACOSX/, .DS_Store, Thumbs.db) is ignored;
one wrapping folder is stripped only when it is the download's own root (downloadRoot, the
ZIP's base name), so a new top-level folder is never flattened. Inflated content is capped at
24 MiB; the upload is staged as sent (re-packed only when a wrapper or OS junk was removed).
- Core
diffAgainstInventory + mapEditionDelta against the build's manifest. partial
(default): files left out stay as they are; only delete= paths are deleted. full: a
published file missing from the ZIP is a deletion. Identical content under a new name is a
rename. A new or renamed-to path that differs only by case from a published file is
case_collision (and a case-only rename applies neither side). A new path that is a file where
a published folder is, lies below a published file, spells a published folder in another case,
or conflicts that way with another new path of the same return is path_conflict. In full
mode, an upload that matches none of the published files is refused (400 full_return_unmatched) instead of deleting everything. Blocked changes are reported,
never applied. No allowed change → 409 no_allowed_changes (details.blocked), nothing staged.
- A normalized ZIP is staged at
org/<org>/edition-returns/<returnId>/upload.zip; a task
(Client update: <title>, created by the client, state provisioning) and one write workspace
are recorded. Queue step edition.return creates the empty -tasks repo (tokens revoked),
records the handle in the ledger and issues edition-return (canonical read, the fresh task repo
write, expectedHead = the build's base; the runner re-checks the ledger head → stale_base).
The runner also blocks, as not_mapped, any new path that lands on (or differs only by case
from) an existing canonical file the edition does not publish, is a file where the canonical
tree has a folder, lies below a canonical file, or spells a canonical folder in another case,
whatever its content, so a return is never an existence or content oracle. As a tripwire, the
candidate tree may differ from the base only in the allowed paths (scope_violation
otherwise; nothing is pushed).
- The result: failure → the return's state, the workspace
abandoned and the task failed (GC
deletes the repo). A candidate → queue step edition.candidate freezes it like an agent
candidate (freezeCandidate: HEAD check, tokens revoked, changed paths computed server-side),
marks the task submitted, opens the proposal (prop-ed-…, deterministic per return; created
by the client) and runs candidate.prepare. Review, integrate, approve and accept are the normal
proposal flow.
Edition proposals (ruling I-1). A proposal opened by an edition return is visible to, and
approvable by, its returning client and any client holding a live grant on that edition, through
edition use alone: no project or resource grant is needed, and none is implied (tree, file,
resource and version routes stay 404). That view carries edition: { editionId, name } and is
limited to the edition: changed paths, conflict paths and merge outcomes are edition paths, and only
those that are published or that this return itself added. A client's approval counts at accept
only while the approver can still see the proposal (canonical read, or edition use). The proposal's events reach
the same users with the same path limit. Developers and owners see the proposal normally.
Events: edition.created, edition.policy_changed, edition.build_requested, edition.built,
edition.build_failed, edition.granted, edition.grant_revoked, edition.session_started
(client-visible), edition.credential_issued, edition.returned, edition.return_failed,
edition.return_empty (client-visible, codes only). Non-developers see edition events only with
use of that edition.
For callers whose role in the org is client or viewer, every JSON response under
/api/orgs/:org/… passes through lib/client-view.ts: Artifacts revision ids
(art:<repoId>:<sha>), candidate ids (cand:<taskWorkspaceId>:<sha>), and gh:<owner>/<repo>:<sha> /
local:<repoId>:<sha> ids, whole or embedded in
text, become stable opaque labels rev-<16 hex> (equality still works), the keys
taskWorkspaceId, fork, treeId and sha are dropped, and submitter is reduced to its kind.
x-commonforge-revision is rewritten the same way. Asset manifest ids (r2m:) are content hashes
and stay.
Plan §C.1 row 10, §F "Preview production-data access"; AT-31, AT-37. Owner decision 2026-10-06:
previews are path-based on a dedicated workers.dev Worker (commonforge-preview), token-gated
and expiring, all sharing that one origin (P0 shortcut, see Known deviations).
Route
Who
Body → response
POST /api/orgs/:org/proposals/:id/previews
Owner or developer with write grants on the changed resources
→ 202 Preview. Needs code changes (409 nothing_to_preview for an assets-only proposal), policy.preview (409 preview_not_configured) and a successful integration for the current hash (409 integration_required). 409 staging_mode in a staging-mode project ("staging replaces previews"); previews made before stay readable.
GET /api/orgs/:org/proposals/:id/previews
Anyone who can see the proposal (clients too)
{ previews: Preview[] }, newest first
GET /api/orgs/:org/previews/:pvid
Same
Preview
DELETE /api/orgs/:org/previews/:pvid
Owner or developer
Revokes the link at once (state: "revoked")
Preview = { previewId, proposalId, proposalHash, forCurrentHash, state: queued|ready|failed|expired|revoked, createdAt, updatedAt, expiresAt, url, error: {code, detail?} | null };
owners/developers also get jobId, sources and the runner's error.detail.
- Build. A runner
preview-build job (Sandbox) builds at the proposal's integration commits
(changed resources) and the base for the other mounts, with the server-owned policy.preview
recipe (recipe, bundles, hostVars, ttlSeconds). Canonical access is read-only; the job
may write only org/<org>/previews/<previewId>/site/… and …/bundles/<name>/….
- Link. When the result arrives the Worker writes
…/meta.json (PreviewMeta) and the link
becomes <COMMONFORGE_PREVIEW_URL>/p/<org>/<previewId>/<token>/. The token is
<expiry base36>.<HMAC-SHA256(COMMONFORGE_PREVIEW_KEY, org|previewId|expiry)>
(src/lib/preview-token.ts); it is never part of a job or receipt and is only returned to
callers who can see the proposal. Revoking deletes meta.json.
- Preview Worker (
src/preview/, wrangler.preview.jsonc): every request needs a valid token
and meta.json with an unexpired expiry (else 404). Static files come from site/ (SPA
fallback for extension-less paths). …/api/* goes to the preview's PreviewBox container, which
runs the api bundle with apps/runner/dist/preview-host.js: enableInternet: false, no
outbound interceptors, the host as uid 10001, fixture bindings only (ORDERS in-memory KV,
PAYMENT_WEBHOOK_URL/EMAIL_WEBHOOK_URL → in-process fixture sink, every other fetch
refused); GET …/api/__fixtures shows the fixture counters. Cookie/Authorization are never
forwarded and Set-Cookie is stripped; responses carry
Content-Security-Policy: default-src 'self'; … connect-src 'self'; frame-ancestors 'none',
Referrer-Policy: no-referrer, Cache-Control: private, no-store.
Plan §A.5 rule 6 (approval ≠ acceptance ≠ release), §A.6 Release, §C.1 row 11; AT-21, AT-33,
AT-34, AT-35. Accepting a proposal never deploys.
Route
Who
Body → response
POST /api/orgs/:org/projects/:pid/releases
Owner or developer
{ acceptedVersionId? } → 202 Release & {created: true} or 200 (same inputs → same release). 409 release_not_configured, 409 accepted_version_stale (with details.currentAcceptedVersionId).
GET /api/orgs/:org/projects/:pid/releases
Project readers (clients need read on every source resource)
{ releases: Release[], live: {releaseId, acceptedVersionId, outputDigest, since} | null }
GET /api/orgs/:org/projects/:pid/releases/live
Same
{ live: Release | null }
GET /api/orgs/:org/releases/:rid
Same
Release
POST /api/orgs/:org/releases/:rid/publish-approvals
Roles in policy.publishRoles (default ["owner"]; else 403 publish_role_not_allowed)
{ outputDigest } → 201 { approvalId, releaseId, outputDigest, envDigest, approver, role, approvedAt }. 409 output_digest_mismatch, 409 release_not_prepared.
GET /api/orgs/:org/releases/:rid/publish-approvals
Same as GET release
{ approvals }
POST /api/orgs/:org/releases/:rid/deploy
Owner or developer with write grants on every source
{ idempotencyKey } → 202 { releaseId, jobId, attempt, replayed: false, release }; the same key → 200 replayed. 409 publish_approval_required, deploy_in_progress (also when another release of the project is deploying, verifying or rolling back: one deploy per project), already_live, 403 release_target_not_allowed / binding_not_allowed (re-checked here), 503 config_missing (no deploy token). Allowed from publish_approved, failed, partial and recovery_required.
POST /api/orgs/:org/releases/:rid/recover
Owner
{ note } → Release: after checking production by hand, a recovery_required/partial release becomes failed (retryable) and stops blocking the project (review R-1). 409 not_recoverable while a rollback runs.
POST /api/orgs/:org/deploy-resources
Owner
{ kind: "kv" | "d1", name } → 201 { kind, name, id, title, created: true }, or 200 { kind, name, id, created: false } when the org already has that kind and name: the platform creates KV namespace or D1 database <prefix>-<org>-<name> on the deploy account. Release policies may bind only these (D1: see "Production D1 and migrations"). At most 5 KV and 3 D1 per org and 5 distinct release Workers per org (409 quota_exceeded); a release Worker name belongs to one project of the org (policy save refuses a name another project names or runs live: 409 release_target_in_use); a placeholder row claims the name before the API call (409 deploy_resource_pending for a concurrent create).
GET /api/orgs/:org/deploy-resources
Owner or developer
{ workerPrefix, resources: [{kind, name, id, createdAt}] }
Release targets are platform-controlled (review C-1; the deploy token is account-wide):
- Worker names must be
<COMMONFORGE_DEPLOY_WORKER_PREFIX>-<orgId>-<name> (default prefix cfapp);
names starting with commonforge (the platform's own Workers) are refused by the schema. Checked
at PUT …/policy, prepare, deploy, and again by the Sandbox gateway for every API call and job.
- Bindings: plain vars and KV namespaces from
deploy-resources of that org only; the gateway
inspects every upload's metadata and refuses any other binding type or KV id.
- The health URL is derived:
https://<workerName>.<deploy account workers.dev subdomain>.workers.dev<path>
(the subdomain is looked up with the deploy token); healthCheck.url no longer exists.
- Strongest control: point
COMMONFORGE_DEPLOY_ACCOUNT_ID and a token scoped to it at a
separate Cloudflare account, so the deploy token cannot reach the platform at all.
Release = { releaseId, projectId, acceptedVersionId, recipeRevision, envDigest, outputDigest, state, buildState: building|built|failed, status, live, publishApproved, deployAttempts, deployments: [{targetId, workerName, url}], health, error, createdAt, updatedAt };
owners/developers also get sources, job ids, each target's full deploy config (vars, KV bindings,
compatibility date/flags: what the output digest and the approval cover; review m-7), health
details and error.detail. Clients and viewers get health as {ok, checkedAt, targets:[{targetId, ok}]}
only (review m-2). rollback is {state: running|verifying|done|failed} when production was rolled back.
status is the UI label: Building, Ready for publish approval, Approved, not live,
Deploying, Verifying the live version, Live, Build failed, Partly deployed, retry to finish,
Not verified, rolling back, Recovery required: production is unverified.
- Prepare binds
{acceptedVersionId, recipe (revision), envDigest} and the exact source
revisions (the ledger heads; acceptedVersionId = the latest accepted version touching them)
into a rel-<24 hex> and issues release-build (Sandbox; canonical read only). envDigest
covers the server-owned policy.release targets, environment (per-target production
vars and kvNamespaces), healthCheck and the deploy account.
- Build receipt fixes
outputDigest (every file's sha256 and size plus each target's deploy
config, recomputed by parseRunnerResultForJob). Deploy config comes from the source's
wrangler.jsonc data fields read before any build step (compatibility_date,
compatibility_flags, vars, assets.not_found_handling), overlaid by the server-owned
environment; COMMONFORGE_RELEASE_ID is always the release id. Outputs are stored under
org/<org>/releases/<releaseId>/<targetId>/.
- Publish approval is its own record, bound to
{releaseId, outputDigest, envDigest} and
re-checked against the approver's current role at deploy time. Any other change (source,
recipe, environment, output) is a different release and needs its own approval (AT-33).
- Deploy claims the release (CAS
publish_approved|failed|partial|recovery_required → deploying,
only while no other release of the project is deploying, verifying or rolling back; it records
the then-live release as the rollback target) and
issues release-deploy: the runner fetches every stored file, checks sha256, target digests and
outputDigest, writes a data-only wrangler.json (no_bundle for Worker targets) and runs its
own pinned wrangler. The account token reaches only that job: POST /api/runner/deploy-credential
(below) for broker-mode runners, or the Sandbox egress gateway injects it.
- Health (queue step
release.health, up to 6 attempts): every target's active
deployment must run the version this deploy uploaded (Workers deployments API, review m-3), and
the health-check target's /version (derived URL, redirect: manual) must answer 200 with
release === releaseId. Only then the project's live pointer (release_live) moves
(state: live).
- A deploy that failed before changing anything leaves the previous release live:
failed,
Approved, not live. Retrying deploys the same release again (new deployAttempts, no new
acceptance or approval; AT-35).
- Any failed deploy is checked against production (review R-2): every target's active
version ids are compared with the live pointer's recorded versions (a target the live release
does not have must not exist). If anything differs (partial deploys, a failure after an upload),
or production cannot be checked, the release becomes
recovery_required, the live pointer is
cleared (so nothing, including editions' sourceReleaseId, can name a release that is not
what serves), and the previous release is redeployed as below. Only a failure that left every
target exactly as recorded is plain failed ("Approved, not live", previous release live).
A partial result that changed nothing visible can be redeployed directly (review I-5).
- Lost jobs (review R-1): a deploy or rollback whose job ended without a result (the Sandbox
gave up, the container died, the job expired and GC closed it), or a health/rollback check that
made no progress for 30 min, makes the release
recovery_required (deploy_lost) or
rollback_failed and clears the pointer, releasing the project's deploy lock. This runs before
every deploy claim, in the GC cron and in recover. The Sandbox runner also posts a failure
result itself when it gives up on a job or the runner exits without one.
- A health failure after a successful deploy means the new code is serving (review I-4):
the release becomes
recovery_required (Not verified, rolling back) and the service
redeploys the previous live release's stored outputs (a release-deploy job for it, with the
deploy credential). Only when that verifies (every target) is the release failed ("rolled
back: the previous release is live again") and the live pointer is set back to it. Workers that
only the failed release had are deleted (review m-d; only names inside the org's namespace). Without a previous release, or when the rollback fails, it stays
recovery_required and the UI says production may run it unverified.
Events: preview.requested|ready|failed|revoked, release.prepare_requested, release.prepared,
release.build_failed, release.publish_approved, release.deploy_requested,
release.deploy_failed, release.health_failed, release.rolled_back, release.rollback_failed,
release.live, deploy_resource.created. Every release.* event carries channel
(production or staging) in its detail and client detail; staging releases are never shown as
production.
Runner endpoint POST /api/runner/deploy-credential: { signedJob } (a release-deploy job,
still open, and either the release's current deploy job in state deploying or its running
rollback job) → { token, accountId }
(no-store); anything else is 403/409.
A project is in staging mode when its policy has a valid staging section and a release
section (otherwise reset and rollback answer 404 not_found, and GET …/staging answers enabled: false). Staging is one
environment per project: the release targets deployed as <workerName>-staging with their own
platform-created D1 and KV (never a production id), and a public workers.dev URL. Anything the
staging Workers send to a webhook or email var listed in captureVars goes to the capture sink
instead of the real provider.
The staging policy section (owner, via PUT …/policy; POST …/policy/validate runs the same checks):
Each environment key must be a release target and binding names are unique per target; every
release target's <workerName>-staging must fit 63 characters (release.targets.<i>.workerName). Seed
data exists only here, and staging D1 is platform-created: a production release section binds only org-owned D1 databases
(Phase 2, see "Production D1 and migrations"), never data,
and may not name a Worker ending in -staging (the policy save is refused; a name another project's production target or live Worker uses is 409 release_target_in_use).
Migrations are the .sql files directly under migrationsDir, applied in name order; each file runs as one
request. Each database records what it applied as <file>@<sha256>: a removed, reordered or edited
applied migration resets staging data. A transient Cloudflare API answer (5xx, 429, no response)
while applying data is retried within the build's deadline; a 4xx (e.g. SQL D1 refuses) fails the
landing. Seeds are applied after migrations on a fresh database (SQL files for D1, a JSON array of
{ key, value } for KV).
Routes
Route
Who
Body → response
GET /api/orgs/:org/projects/:pid/staging
Anyone who can read the project
{ enabled, state, stateText, baseline, url, current: { buildId, since } | null, building, landings: [{ landingId, taskId, title, state, reasonCode, reason, promotion, parts?, createdBy, createdAt, detail?, travelsWith? }] }, newest 100 landings first. promotion is the landing's newest promotion, { promotionId, state } or null, for every reader; parts ([{ part, taskId }], part 1 is the task, each revision adds one; see "Landing parts and revisions") only for those who see the landing's reasons (owners, developers, and the client who created it). enabled: false (no other data) when the project is not in staging mode. state is awaiting_baseline (staging starts only after the owner's first production publish; stateText says so, "Waiting for the owner's first production publish.") or active; baseline is null or { since, releaseId? } (releaseId for owners and developers). A landing created before the baseline waits in checking with reasonCode awaiting_baseline and is built once the baseline exists. travelsWith (owners and developers) lists the landing ids this landing is linked to (see the travel-with routes). state is checking, landed, failed, rolling_back or rolled_back. A client sees reasonCode/reason only for landings they created, and the text is composed by the platform (conflicting paths, "the staging deploy failed", "a migration did not apply"), never raw runner or provider output. detail (raw text) is for owners and developers only.
POST /api/orgs/:org/projects/:pid/staging/reset
Owner, developer or client who can read the project
→ 202 { buildId }. Rebuilds staging on fresh D1/KV: every migration and seed is applied from scratch. 404 not_found when the project has no staging; 409 staging_awaiting_baseline before the owner baseline.
POST /api/tasks/:id/rollback
Owner, a developer who can see the task, or the client who created it
→ 202 { buildId }. Takes the task out of staging and rebuilds (buildId: null when no build lists the landing, as for a landing still waiting for the baseline: it is rolled_back at once). 409 not_in_staging unless the task has a checking or landed landing; 404 for anything else.
POST /api/orgs/:org/staging/landings/:id/travel-with
Owner or developer who can read the landing's project
{ landingId } → 201 { landingId, withLandingId }: the landing :id goes to production only together with the earlier landing landingId (a forced predecessor for the promotion set and the alone set; idempotent; at most 20 links per landing). 404 for an unknown landing, 409 travel_with_not_earlier (the linked landing must have a lower sequence in the same project), 409 not_in_staging (both must be checking, landed or promoting), 409 travel_with_limit. If the landing is in a promotion that does not include the linked landing, that promotion is superseded before it is accepted (reason code travel_with_added: "A developer linked this task to '<title>'; promote again."); once it holds production (accepting, releasing) the link is refused with 409 travel_with_in_flight (details.promotionId) and nothing is written. Event staging.travel_with.
DELETE /api/orgs/:org/staging/landings/:id/travel-with/:withLandingId
Owner or developer
→ 204; idempotent. Event staging.travel_with (linked: false). A link to a landing that is failed or rolled_back makes promote answer 409 travel_with_left_staging until it is deleted; a link to a promoted landing is satisfied.
GET /api/orgs/:org/projects/:pid/staging/sent
Anyone who can read the project
{ captures: [{ captureId, receivedAt, method, path, headers, body, truncated }] }, newest 100.
Any method on /api/sink/:token and /api/sink/:token/*
Public; the path token is the credential
→ 202 { ok: true }; 404 for an unknown token; 429 rate_limited beyond 120 requests a minute per project (600 per client IP, 2000 overall). Records the method, the path after the token, headers and up to 16 KiB of body (truncated marks a clipped body). Never forwards anything.
Sink details. Each project has one sink token, derived from the server secret and registered by
hash (the token itself is never stored); it is what staging's captureVars are set to
(https://<app>/api/sink/<token>). Credential-looking headers (authorization, cookie, *token*,
*secret*, *api-key*, *password*, *session*, set-cookie, proxy-authorization) are never
stored. The newest 200 captures per project are kept, and the cron sweep deletes captures older
than 7 days. A staging Worker with any captureVars is built with the compatibility flag
global_fetch_strictly_public (added to the source's flags, replacing a no_global_fetch_strictly_public):
Cloudflare refuses a Worker's fetch to another workers.dev Worker on the same account (404, error
1042) otherwise, so the sink would never receive anything. With the flag, all of that staging Worker's
outbound fetch calls go over the public internet. A production release policy cannot set
environment.<target>.compatibilityFlags (400); production takes the source's wrangler.jsonc.
Landing. In a staging-mode project, freezing the last write workspace of a task (the task turns
submitted) queues staging.land: the task becomes a landing (checking), a staging build merges
every open landing onto the live staging revision (a merge conflict fails only the landing it names,
with the paths), builds with the staging recipe, creates or reuses the D1/KV, applies new
migrations then seeds, deploys <workerName>-staging, and health-checks it. Success marks the
landings landed and the build live; failure marks them failed and keeps the previous staging
live (or rolls the deploy back; if that cannot be confirmed, the landing says a developer must check
staging). One staging build runs per project at a time. A landing of a task that was rolled back,
never became live or whose change was withdrawn ends rolled_back. A failed landing's task is not retried: the client
starts a new task from the current staging, or revises a task that is live (see "Promotions").
Client task rules (see "Tasks, workspaces, leases, credentials"): a client creates, lists, reads,
leases, freezes and rolls back only tasks they created; their agent (the lease token) gets the only
credentials, and the client's own responses never contain a task or canonical repository name.
Proposals, integration and accept stay with owners and developers; a landing proposal is closed when its landing fails
or is rolled back, and is never integrated or accepted directly (409 staging_proposal).
No way around promotion (staging phase 4-2, P42-2). In a staging-mode project a task is not proposed directly
(POST …/proposals: 409 staging_proposal), no preview is requested and no edition is created, started or returned
(409 staging_mode). Proposals, previews and editions from before staging was turned on are not migrated: their reads
and the integrate and approvals routes answer as before (an open proposal is policy_revision_stale at accept, as after
any policy change). Without a staging section nothing changes.
A task that is live on staging goes to production as a promotion: the Worker builds one new
proposal (prop-pro-*, listing kind: "promotion") on the current main heads plus the candidates
of the promotion set (the task's landing and every earlier landing in staging that it shares a
path with, or that one of its revisions was built on, in landing order). Each candidate is applied
with its own staging base as the explicit merge base and committed with a single parent, so only the
task's own edits reach main. Checks and contracts run on the result. The gate decides who may clear
it, the clearance is accepted into main through the ledger, and the release service ships it to
production with D1 migrations applied before the code. Staging mode gates all of this: without it
promote (and revise) answer 404 not_found (assertStagingProject), and every existing flow is
unchanged. The other promotion routes (read, approve, send back, retry-release) keep working on an
existing promotion; a promotion that has not started accepting fails at its next step with
staging_not_configured, while accepting and releasing finish what they started.
Parallel promotions. Promotions of one project build, gate and wait for approval in parallel when
their landing sets are disjoint. Each promotion claims its landings (promotion_landings; a landing is in at most
one in-flight promotion, a database invariant); a request whose set holds a landing another promotion claims
answers 409 promotion_dependency_busy, and 409 promotion_in_progress now means only that this task is already
being promoted. Only the steps that move main and production take turns: from the accept until its release ends
(live, release_failed, failed, or a rebuild back to requested) one promotion holds production (a
database claim, so releases run in accept order). A cleared promotion that cannot take production waits in
cleared (reasonCode production_busy, waitingForProduction: true, first come first served by clearance
time); whoever releases production wakes the oldest one. A promotion's proposal is built on the main it will be
accepted on, so after each accept the oldest waiter rebuilds; it starts as soon as the accepting promotion
reaches releasing (its accept moved main), so the rebuild overlaps the release and the waiter is usually
ready when production frees. Only the oldest waiter rebuilds ahead; the others rebuild when they reach the front. A rebuild caused only by this project's own promotions' accepts
does not count toward the rebuild cap (promotion_rebuilds counts them); if the 6 hour deadline
still fires after such rebuilds the client text is "Production kept changing; promote again." A rebuild from
cleared restarts the 6 hour deadline, so the deadline alone does not bound a wait: waiting is bounded by the
first-in-first-out order (about one rebuild per promotion ahead in line).
Baseline. Staging in a project starts only after the owner has published the current main to
production once: a production release is live and, for every resource it is built from, the ledger head is
exactly the release's source revision. That is recorded once per project (staging_baselines; permanent,
turning staging off and on keeps it) when a production release goes live and when a staging policy is saved.
Before it, landings wait (awaiting_baseline), nothing is built or deployed, and promote and reset answer
409 staging_awaiting_baseline. Clearance releases start each resource at the live release's revision for it;
while nothing is live in production they start at the baseline's revisions. A resource the start point lacks (a
mount added or re-pointed later, missing from the live release or the baseline) is checked from its beginning, so
it needs one owner publish first.
Alone-first. A promotion first tries the task alone (the task plus the landings a developer linked it to
with travel-with) when every resource the alone set changes has at least one required policy check marked
gatesAlone: true (owners mark the checks that build or test the code of their mount; a lint-only check is not
marked; contracts do not count) and the overlap closure is larger. The response then has mode: "alone" and
goingWithout, the earlier overlapping tasks it leaves in staging (Going to production without '<title>' (still in staging)). The alone build merges the task's own edits onto main like any build. If it ends in a textual
conflict, a failed required check or a failed contract, the promotion widens once to the current closure:
the extra landings are claimed, the clearance is void, it rebuilds with mode: "closure" and needs listing the
tasks it added (reasonCode needs_dependencies, "Needs '<title>'; promoting them together."), event
promotion.widened. It fails as before when the closure adds nothing, and with promotion_dependency_busy
(naming the tasks: not live on staging yet, or "being promoted now") when a landing it needs is not landed.
Eligibility is re-checked at every build; if a policy change makes the task ineligible the promotion widens the
same way (policy_changed: "The project's checks changed while the promotion was built. Promote again." as the
failure text when it cannot).
Routes
Route
Who
Body → response
POST /api/tasks/:id/promote
The client who created the task, or an owner or developer who can see it; 404 for anyone else, or a project without staging
{} → 202 { promotionId, landings: [{ landingId, taskId }], mode, goingWithout: [{ landingId, taskId, title, text }] } (the set, in landing order; mode is alone or closure). 409 not_in_staging (no landing in staging for the task or the landing it joined as a revision; also a failed, rolled-back or promoted landing), not_landed (still checking), promotion_in_progress (this task is already being promoted; details.promotionId), promotion_dependency_busy (a landing of the set is checking, or claimed by another in-flight promotion; details: { promotionId?, taskId, title }, and the text says "is being promoted; wait until it finishes" or "not live on staging yet"), staging_awaiting_baseline (the owner has not published main to production yet), travel_with_left_staging (a task this one must travel with failed or was rolled back; a developer deletes the link; details: { landingId, withLandingId }), staging_changed (staging moved while the request was written; try again).
GET /api/tasks/:id/promote
As POST
Dry run (the Promote sheet): → 200 { landings: [{ landingId, taskId, title }], mode, goingWithout: [{ landingId, taskId, title, text }] }, the set POST would promote now, computed by the same code (planPromotion), in landing order. Writes nothing (no promotion, no claims, no events; it does not repair leftover promoting landings or wake a holder, which POST does). The same errors as POST (not_in_staging, not_landed, promotion_in_progress, promotion_dependency_busy, staging_awaiting_baseline, travel_with_left_staging; 404 as POST). A later POST can still differ if staging changes in between.
GET /api/orgs/:org/projects/:pid/promotions
Owner, developer or client who can read the project; viewers 403 insufficient_role; 404 for a project the caller cannot read or another org
?state=a,b (optional, comma-separated promotion states; an unknown state is 400 invalid_request) → { promotions: [Promotion view] }, newest first, at most 100. Owners and developers get every promotion of the project in the developer view (a developer without read on every resource a promotion changes gets the client view of that one, as in GET …/promotions/:id); a client only promotions they requested or with a landing they created, in the client view. List rows never carry resources or checks (read them with GET …/promotions/:id); everything else of the view is there. A project that never had promotions (one that never had staging, for instance) answers { promotions: [] }; a project whose staging was turned off still lists its past promotions.
GET /api/orgs/:org/promotions/:id
Anyone who can read the project
Promotion view (below). 404 otherwise. GET …/staging also gives each landing's newest promotion (promotion: { promotionId, state }).
POST /api/orgs/:org/promotions/:id/approve
Owner or developer whose role is in the project's approvalRoles
{} → Promotion view. pending_approval → cleared with a developer clearance. 403 approval_role_not_allowed, 403 promotion_not_visible (a developer without read on every resource the promotion changes, the rule of the developer view; owners always pass), 409 promotion_not_pending, 409 promotion_changed (rebuilt since the caller read it; reload).
POST /api/orgs/:org/promotions/:id/send-back
Owner or developer
{ note } (1-2000 characters) → Promotion view. pending_approval or cleared → sent_back; any clearance is void and the landings return to landed. The note is shown to the client verbatim. 403 promotion_not_visible (as for approve), 409 promotion_not_pending (already accepting or ended), 409 promotion_changed.
POST /api/orgs/:org/promotions/:id/retry-release
Owner or developer with a write grant on every resource the release deploys from and on the promotion's resources (403 no_write_grant)
{} → Promotion view. release_failed → releasing: a fresh prepare of the latest accepted version and a new deploy, run by the promotion service. It takes production ahead of any cleared waiter (it releases what is already in main). 409 promotion_not_release_failed; 409 production_busy while another promotion accepts or releases (details.promotionId of the holder).
POST /api/orgs/:org/promotions/:id/withdraw
The client who requested the promotion, or an owner or developer who can read its project; 404 for anyone else
{} → Promotion view. requested, building, gating, pending_approval or cleared → withdrawn (terminal; reason code withdrawn, "Withdrawn"): the landings return to landed, their claims are released, the proposal is superseded and any clearance is void. 409 promotion_accepted once it is accepting, releasing, release_failed or live (a failed release is retried, not withdrawn), 409 promotion_not_in_flight when it already ended, 403 not_promotion_requester for any other client, 409 promotion_changed (it moved while the request ran; reload). Its integrate job, if running, still runs to completion. Event promotion.withdrawn.
POST /api/tasks/:id/revise
The task's creator, or an owner or developer who can see it
{ request? } → 201 Task: a follow-up task with the original's write and read mounts, started from live staging, titled "<original title> (revision N)", with revisesTaskId set. Its proposal joins the original's landing as the next part when it is submitted. 409 landing_not_revisable unless the landing is landed, or promoting in a promotion that is pending_approval or cleared (submitting the revision then supersedes that promotion).
Promotion view. Everyone who can read the project gets { promotionId, state, landings: [{ landingId, taskId, title, state }], level, reasonCode, reason, waitingForProduction, mode, goingWithout, needs }. waitingForProduction is true while the promotion is cleared with reasonCode production_busy; mode is alone or closure; goingWithout and needs are [{ landingId, taskId, title }] (goingWithout entries also carry text): the tasks an alone promotion leaves in staging, and the tasks it added when it widened.
level is self, classify or developer once gated. A client (or viewer) sees reasonCode and
reason only for promotions they requested or whose landings they created, and the text is composed
by the platform (conflicts with production, a failed required check, "a developer needs to check it",
"the production update did not go live", "a database migration failed; production was not changed",
"a database update did not confirm; a developer is checking production" (migration_state_unknown),
"production has other changes waiting for an owner's approval") or is a developer's send-back note.
Owners, and developers with read on every resource the promotion proposal changes (before a proposal exists: every resource its tasks' snapshots name; the task view's rule), also get targetLandingId, proposalId, proposalHash, gate (pathLevel,
perPath, veto, classifier, reasonCode; see "Gate"), raw detail, clearance: { kind, clearedBy, clearedAt } | null,
acceptedVersionId, releaseId, requestedBy, createdAt, updatedAt, and what the review screen needs:
landings[] entries also carry request (the task's request text) and parts: [{ part, taskId, title }]
(the task and its revisions, in part order).
resources: [{ resourceId, mountName, head, result }]: per changed resource of the integration for the
promotion's current proposal hash, head (the main head it was built on) and result (the integration
result) as revision ids (for git folders art:<repo>:<sha>), exactly what GET …/projects/:pid/file?mount=&path=&revision=
and …/tree take (developers only); mountName is a mount of the resource in the project (null if none). []
before the integration for the current hash is recorded (also while a rebuild runs).
checks: { checks, contractChecks, mergeOutcome } | null: that integration's validation evidence (checkId,
result and output per check, as in the proposal view), null before it exists.
resources and checks come only from GET …/promotions/:id (and the approve, send-back, withdraw and retry-release responses), never in the project's promotions list.
Clients, viewers and developers without read on every such resource never get gate, resources, checks, request, parts, detail or the ids above (their reasonCode/reason follow the client rule).
States and transitions
State
Meaning → next
requested
The proposal is built on the current main and its integrate job issued → building
building
Waiting for the integrate receipt for the proposal hash; main moved → requested again (a rebuild)
gating
Path levels, the migration veto and (for classify) the risk classifier decide the level → cleared (self, the classifier, an owner's request, or a carried clearance) or pending_approval
pending_approval
Waits for a developer: approve → cleared; send-back → sent_back; a policy change → requested
cleared
Set guard, then takes production → accepting; waits here (production_busy) while another promotion accepts or releases and is nudged when production is free (oldest first); main moved → requested; a policy change → requested; withdraw → withdrawn
accepting
Holds production. Ledger accept with the clearance as the approval → releasing; main moved → requested
releasing
Prepare, clearance publish approval, deploy (migrations first) → live, or release_failed
release_failed
retry-release → releasing. main already has the change (R10)
live
A production release containing the accepted version went live (terminal)
withdrawn
Terminal. Withdrawn by its requester, an owner or a developer before it was accepted; the landings are landed again
sent_back, failed, superseded
Terminal. The landings go back to landed (failed/sent_back) or follow the revision that superseded the promotion
requested, building and gating are bounded: 6 hours from machine_since (restarted when a
person acted and when a waiting cleared promotion rebuilds), at most 10 rebuilds
because main moved by something other than this project's promotions (promotion_rebuild_limit; the
rest are counted in promotion_rebuilds, bounded only by the deadline), at most 10 integrate jobs per proposal hash
(promotion_job_limit); accepting fails 1 hour after it took production (claimed_at, not the clearance), releasing becomes
release_failed 2 hours after it started or was retried. A proposal the ledger shows accepted
without an accepted version naming it (never expected) becomes release_failed with reason code
accepted_version_missing 1 hour after it took production (the landings are promoted: main has the
change; developers read detail); retry-release then prepares the latest version under the
clearance rules. Failure reasonCodes include
textual_conflict, check_failed and contract_failed from the build, promotion_timed_out, promotion_step_failed,
landing_failed (a landing left staging), landing_revised (a revision was submitted), travel_with_added (a developer linked one of its tasks to a landing it lacks; superseded), withdrawn, needs_dependencies (an alone promotion widened; not a failure) and production_busy (a cleared promotion waits for production; not a failure),
staging_not_configured (staging turned off), candidate_missing, promotion_proposal_closed,
and the gate reasons developer_paths, migration_destructive,
classifier_declined, classifier_unavailable, gate_error.
Gate. Each changed path gets a level from staging.promotion.rules (first match; unmatched
paths are developer); the set's level is the strictest. A deterministic migration veto reads the
migration files at the main head and at the integration result (lintMigrations): anything not
provably additive (drop, rename, type change, UPDATE/DELETE, unknown statements, a changed or
removed applied file, files too large, non-regular git modes) makes the level developer. The gate fails
closed: any error while gating is developer.
Risk classifier (staging phase 3). A set whose strictest path level is classify, whose veto passed and that
has no developer path, asks the project's classifier (staging.promotion.classifier.provider: jev (TypeSafe,
model jev-latest), openai-decisions (model COMMONFORGE_OPENAI_DECISIONS_MODEL, default gpt-6-luna) or
none) once per gating, with a 5 s timeout. It answers five fixed choice questions about the change (risk
low/medium/high, migration none/additive/destructive, API change none/backward_compatible/breaking, touches sign-in
or payments yes/no, and a guard: does the change contain text addressed to a reviewer); the promotion clears only
when every question's safe options reach threshold. Its input is platform text plus, as quoted data, the set's
task titles and requests, every changed path with its level, a diff capped at 64 KiB (16 KiB per file; migration
SQL in full) and the integration's check results; an input over 96 KiB is not sent. Every failure (no provider, no
key, timeout, HTTP error, refusal, malformed or partial answer) waits for a developer. A clearing answer writes a
classifier clearance (cleared by the requester).
The classifier clears only a complete view of the change (P3-11). It is not asked (error input_incomplete)
when a text diff was cut by the caps or shown only as a size line (diff_truncated), files were left out past the
diff cap (files_omitted), a changed file at a classify or self path was not read: a read error, a path absent
on both sides, or a file after reading stopped (file_not_read, listed as "(file not read)"), a binary or LFS file
sits at a classify path and its extension is not png, jpg, jpeg, gif, webp, avif, ico, woff, woff2, ttf, otf, mp4,
webm, mp3 or pdf (binary_file; svg is text), more than 200 changed classify/self files (too_many_files;
nothing is read), a side over 1 MiB (file_too_large) or more than 4 MiB read in all (read_limit). A cut task
text does not count. Reading stops at the first such finding. Before evidence clears, the gate checks it again: no
error, a complete input, the policy's provider and threshold, and the answers clear again at the policy's threshold.
gate.classifier (developers and owners only) is { asked, cleared, reason, evidence? }, reason one of:
not_needed (the level was not classify, the veto was destructive, or a developer path; not asked),
carried (a clearance carried from an earlier build covers classify; not asked again), not_configured
(provider none or no key), cleared, declined (answered, not confident) or error (also evidence for another
provider or threshold than the policy names). asked is whether it was asked in this gating; cleared whether the
evidence clears (asked now, or carried). evidence is
{ provider, model, threshold, inputDigest (sha256 of the exact input), truncated, omittedFiles, inputBytes, latencyMs, answers: { <question>: { choice, probabilities, confidence? } } | null, safeProbability, cleared, failed (the first question that did not clear), error (not_configured, timeout, http_error, refused, malformed, input_too_large, input_incomplete), incomplete? (with input_incomplete: diff_truncated, files_omitted, file_not_read, binary_file, too_many_files, file_too_large, read_limit) }; never the provider's raw body or a key.
gate.reasonCode is classifier when it cleared, and also for a carried classifier clearance, shown as
classifier: { asked: false, cleared: true, reason: "carried", evidence } with the evidence it was first cleared on
(a classifier clearance keeps it; evidence that no longer clears under the policy, such as a changed provider or
threshold, asks again). A carried developer clearance over a classify level is gate.reasonCode: "carried"
(classifier: { asked: false, cleared: false, reason: "carried" }).
Promotion reasonCodes of a classify set that waits for a developer: classifier_unavailable ("Needs a
developer's review.": not configured, an error, a timeout, too large or an incomplete input; the developer-only
detail names the evidence error, e.g. classifier: input_too_large or classifier: input_incomplete (file_not_read)) and classifier_declined, whose
text names the first question that did not clear: risk "The automatic review could not confirm this change is
low risk.", migration "Changes the database structure in a way that needs a developer.", API change "May change
how the API works for existing callers.", sign-in or payments "Touches sign-in or payments.", the guard "Needs a
developer's review.".
Clearances. A clearance is a typed ledger approval (role: "clearance:self" | "clearance:classifier" | "clearance:developer")
written only by the promotion service, bound to the promotion proposal's hash and honoured only while
the promotion row is cleared/accepting for it. Users can never create one through the approvals
route.
self: the gate level is self; cleared by the requester (a client may).
classifier: the gate level is classify and the risk classifier cleared it; cleared by the requester (a client may). Covers classify and self.
developer: an owner or developer approved, or an owner requested the promotion and owners are in approvalRoles.
- Carry rule. When
main moves (or the policy changes) the promotion is rebuilt, the checks rerun and the
clearance is carried to the new proposal without anyone approving again, if the key (the set's landing
proposal hashes) is unchanged, the new level is no stricter than the clearance covers (developer covers every
level, classifier covers classify and self, self only self) and the clearer still qualifies; a carried classifier clearance does not ask the classifier again (gate.classifier.reason: carried, its evidence kept) unless that evidence no longer clears under the policy (another provider or threshold).
- Voided by a new revision part of any landing in the set (a new proposal hash), by a policy change that raises the level
above what was cleared, by a send-back, or when the clearer no longer qualifies.
- Staging mode needs approvers: saving a staging policy whose
approvalRoles has neither owner nor developer is refused.
Release. After the accept the landings are promoted (not when production is live: R10), their landing proposals
superseded, and staging is rebuilt without them. What else main gained is recorded with the accept: every
part's task has its verified LFS objects accepted (in the resources both its landing proposal and the promotion
proposal change; objects the hash step verifies later too, as for an accepted task proposal), and a file change
staged through a part becomes added (its LFS objects accepted, event files.change_added with promotionId;
see "File changes"). Both are idempotent and repeated when the release starts, so a step that died after the
accept is repaired. The production release is prepared and deployed by the
promotion service on behalf of the clearer (a client who cleared by self can call no release route); the
clearance itself is the publish approval, only when everything new in production came through promotions of this
project. If production would also get accepted versions no promotion cleared (an ordinary accept, a restore),
the release stops with release_needs_publish_approval and an owner publishes the release by the normal routes.
A folder mounted after the owner baseline needs one owner publish before clearances can carry it.
Migrations are applied first; a failed migration stops the release before any code changes.
Errors raised while a promotion is built or released (a promotion's reasonCode, or the HTTP code of
the route that shows it): promotion_part_missing / promotion_part_changed (a landing proposal is gone or
changed since the request), promotion_candidate_not_prepared, nothing_to_promote (the set has no git
changes), promotion_base_unsupported (a candidate has no git base), promotion_changed (the proposal's hash
changed; at accept it fails the promotion), integration_required (a promotion is accepted only on its own
integration result for the current hash), promotion_rebuild_limit, promotion_job_limit,
release_needs_publish_approval, and the migration codes below.
Landing parts and revisions. A landing is made of parts: part 1 is the task, each revision appends a
part (staging_landing_parts). The landing keeps its position and returns to checking until a staging build
with the new part is live; staging applies all parts in order. A revision is applied at its landing's position
and may conflict if it builds on a later landing. Task responses carry revisesTaskId for a revision.
Every task in a staging-mode project, whoever creates it, starts from the live staging commit (the staging build it was
seeded from is recorded in tasks.staging_build_id, an internal column that the API does not return; promotion sets
use it to include the later landings a revision was built on).
Landing reasonCodes added in this phase:
Code
Landing
Meaning
revision_landed_alone
the revision
the revised landing was still being accepted into production 2 hours after the submit, so the revision became a landing of its own
revision_without_original
the revision
the revised task is no longer in staging (failed/rolled_back), so the revision landed on its own without it
conflicts_with_production
the landed task
a landing that was live no longer merges onto main after a promotion; it fails (its promotion is superseded)
promoted_after_staging_failure
promoted landing
staging dropped it after the promotion's set guard, but its change is in main
A revision that cannot be added to staging is dropped without touching the live parts (event
staging.revision_dropped on the revision task with the platform text "This revision conflicts with staging in …"
or "This revision could not be added to staging"; its proposal is withdrawn); the landing returns to landed
without it. Landing states are now checking, landed,
promoting, promoted, failed, rolling_back, rolled_back. A failed or rolled-back landing's proposals are
closed. After 5 failed staging builds in a row staging requests no further build by itself (staging.stuck,
visible to developers) until a new landing or a reset.
Proposals listing. GET …/proposals entries carry kind: "staging" | "promotion" | null. Landing (staging) and promotion
proposals are refused by the integrate, accept and approvals routes: 409 staging_proposal ("Promote this task from
staging instead") and 409 promotion_proposal (a promotion proposal is approved and accepted only through its promotion).
Events (project timeline; the client-visible ones carry only codes): promotion.requested, promotion.pending_approval,
promotion.cleared (kind, clearedBy; a carried clearance is recorded by the promotion service), promotion.accepted,
promotion.live, promotion.release_failed, promotion.release_retried, promotion.sent_back, promotion.failed,
promotion.superseded, promotion.withdrawn, promotion.widened (needs, landings, failure); staging.baseline_established (client visible, no detail; developers see headsMovedSince when a head moved between the check and the write), staging.travel_with (landingId, withLandingId, linked); staging.promoting, staging.promoted; proposal.accepted (with promotionId);
release.migrations_applied, release.migration_failed; production_migration.resolved; staging.revision_dropped, staging.stuck.
POST /api/orgs/:org/deploy-resources takes kind: "kv" | "d1" (owner). A d1 creates a D1 database
<prefix>-<org>-<name> on the deploy account → 201 { kind, name, id, title, created: true }. At most 3 D1
databases per org (409 quota_exceeded), 5 KV. A production release may bind only org-owned databases
(release.environment.<target>.d1Databases: [{ binding, id }]), never a staging database; each D1 belongs to
one project (409 binding_in_use at policy save and deploy).
release.migrations: [{ targetId, binding, dir }] (≤ 8; the target must be a worker target that binds binding).
The Worker derives the target's data from these folders: production carries migrations only, never a seed, and
release.environment.*.data stays refused. A build whose data files are not all inside a declared folder is refused
(403 release_data_not_allowed); a migrations entry the build does not bind is 409 release_migration_unbound.
- Applying. Before a production deploy the Worker applies pending
.sql files of each folder in name order, one
D1 query per file, each file's stored bytes verified against the build receipt's sha256 first (409 release_data_missing,
409 release_data_mismatch), and records <file>@<sha256> per database. Applied files are never re-run, edited or
reverted. 409 production_migration_changed (an applied name has other content: add a new migration),
409 migration_out_of_order (a new name sorts before the last applied one), 409 migration_failed (D1 refused the
file with a 4xx: nothing is recorded, a retry runs it again), 409 migration_state_unknown (the query was sent but
its outcome is unknown, or another deploy is applying it; every later deploy needing the database refuses until an
owner resolves it), 503 migration_unavailable (could not start; retry the deploy).
- Records are
applying (written before the query is sent) or applied.
Route
Who
Body → response
GET /api/orgs/:org/production-migrations?state=applying|applied
Owner or developer
{ migrations: [{ resourceId, key, releaseId, state, startedAt, appliedAt }] }, newest first, at most 500. 400 invalid_request for another state. ?state=applying lists those an owner must resolve.
POST /api/orgs/:org/production-migrations/resolve
Owner
{ resourceId, key, applied } → { resourceId, key, state: "applied" | "pending" }: after checking the database by hand, applied: true records it as run (never run again), false removes the record (the next deploy runs it). 404 unknown record, 409 migration_state_known (already applied), 409 deploy_in_progress (a production release deploying with that database).
commonforge-runner (src/sandbox/, wrangler.runner.jsonc) consumes commonforge-runner-jobs
and runs each job in a fresh container (RunnerBox, standard-1, destroyed afterwards) with the
apps/runner image, enableInternet: false, reaching the control plane only through the
CONTROL service binding. Before start it asks the broker for the job's repository tokens (and
the deploy token for release-deploy) and registers per-job interceptors (src/sandbox/gateway.ts):
- Artifacts git host: only the job's repositories;
upload-pack for each, receive-pack
only for write repositories, and the receive-pack command list must be exactly one update of a
ref in jobAllowedPushRefs(job) with the job's lease (no delete, atomic or push options).
The bearer is injected here; nothing passes after the job's expiry; tokens are revoked at the end.
- Control plane:
POST /api/runner/{results,evidence-key,heads} and GET|PUT /api/runner/blobs/*
only (never the broker or deploy-credential).
- npm registry (install steps of
integrate, preview-build, release-build): GET/HEAD only,
over plain HTTP inside the container (PNPM_CONFIG_REGISTRY=http://registry.npmjs.org/;
pnpm 12 does not trust the interception CA); the gateway fetches HTTPS upstream without
following redirects. For integrate, only the runner's own dependency install may use it: the
RunnerBox writes a per-job npm key (root-only file), the install presents it as its registry
token, and the gateway refuses requests without it.
api.cloudflare.com (release-deploy only): the job's Worker names on the job's account
(/workers/scripts|services|workers/<name>…, /workers/subdomain, assets upload with its own
session JWT, token verify); the deploy token replaces wrangler's placeholder.
unshare --net works in the Sandbox (CAP_SYS_ADMIN; verified live 2026-10-06). Jobs that
can write a repository, and every integrate job (an assets-only one writes nothing), therefore run
checks with no network (checkNetworkFor, src/sandbox/gateway.ts). Before an integrate's checks, the
runner installs each mount's dependencies from its committed pnpm-lock.yaml (--frozen-lockfile,
no install scripts, as the check uid; apps/runner/README.md "Dependency installs"). Read-only build jobs keep the
gateway-limited network for their install steps. In every job the Artifacts and control-plane
gateways also require x-commonforge-runner-key, a per-job secret in a root-only file that only
the runner's own git processes and Worker calls send (review I-3): a check or recipe step reaches
the npm registry and nothing else, so it cannot read canonical history or other refs.
Release deploy gateway (review C-1, m-a): every target name must start with
<COMMONFORGE_DEPLOY_WORKER_PREFIX>-<orgId>- (the org, o-<12 hex>, from the job's output prefix)
and never commonforge. Per script, only the calls of a data-only deploy pass: GET anything but
tails (wrangler reads secret names, never values), PUT (upload), PUT content, POST versions|deployments|assets-upload-session|subdomain,
PUT schedules; services/<n> and workers/<n> are read-only. Settings changes, secrets and tail
consumers are refused. Every request body is inspected whatever its declared content type (matched
case-insensitively): every multipart part and any JSON may carry only plain_text and the job's
own kv_namespace bindings, no tail_consumers, no logpush; other non-empty body types are refused.
Owners, and developers with write on a folder, add files to it from a browser (the web app's
Files tab) or the CLI. A git folder (artifacts resource) also takes git push with git-lfs.
Everything lands as an ordinary accepted version, through a proposal.
File collections (r2-assets) take uploads and changes too, with three differences: their
files are never edited in place (an edit op answers 400 edit_unsupported), the Worker applies
the change itself by writing the collection's next manifest (no runner job, see "File collections"
below), and the LFS routes answer 404 for them (as for any folder that is not a git folder).
The browse routes (GET …/tree, GET …/file, under "Projects, mounts, resources") show people
real files, never LFS pointers: a git file stored in LFS is listed with its real size and
downloads its real bytes.
- Upload.
POST …/uploads starts an R2 multipart upload straight to the object's final key;
the client sends numbered parts (PUT …/parts/:n) and completes (POST …/complete). Each part's
body is streamed through the Worker to R2 as it arrives (checked against its content-length),
never buffered whole.
- Hash. Completing queues the hash step (queue step
lfs.hash): the Worker streams the object
from R2, computes its sha256 and checks its size, and records whether it is binary and the R2
etag of the bytes it hashed. The upload becomes verified (or rejected, with an error). Poll
GET …/uploads/:uploadId. Everything that later serves or uses the bytes (LFS downloads, the
runner's fetch of small text files) reads them pinned to that etag: bytes written again after
verification are never served, and the upload becomes rejected (object_changed).
- Change.
POST …/changes names operations (put verified uploads, edit small text files,
delete, move) and answers 202 with the change in state applying. A change waits until
every upload it names is verified.
- Apply. The Worker stores the resolved operations as an R2 manifest and issues an
apply-files runner job: the runner applies them to the folder's accepted head in a fresh
-tasks repository and pushes one commit authored by the uploader. Large, binary or
LFS-pattern files are committed as LFS pointers; the runner never downloads LFS objects.
- Proposal. The Worker freezes the candidate and opens a proposal like any task candidate, then
prepares it. In a staging-mode project the change's task, now submitted, also lands in
staging exactly as a frozen agent task does (
staging.land, see "Staging"); the change itself
still continues to the folder as below (Phase 1: owner and developer work reaches main through
its proposal). A restarted attempt rolls its own landing back; the next attempt lands instead. A
change that fails rolls its landing back too (best effort): a failed change is final, so
nothing of it stays on staging. A landing created for a task that failed meanwhile is rolled
back by staging.land itself. Changes to file collections never land in staging (staging builds
git candidates only, as for an agent's asset-only task).
- Landing. The proposal is integrated (state
checking): a change whose paths are all
content (not code) runs no checks; a change that touches code runs the project's policy
checks. An LFS path is content, other paths are classified by extension; replacing a code file
that was not stored in LFS is code even when the new version is an LFS object (so replacing
index.html with a large upload runs the checks). Then the uploader's own approval is recorded only if the uploader's role is listed in the
project policy's approvalRoles (owners when the policy sets none; developers count only if
the policy lists them). Otherwise the change waits in waiting_approval for a person to approve
and accept the proposal (proposalId) through the normal approval and accept routes. When the
approval counts, the Worker accepts the proposal itself.
- Added. The accepted version is the folder's new head and the LFS objects it points to are
accepted (promoted; no bytes move). The change is
added.
For a file collection, steps 4 and 5 differ (see "File collections" below): the Worker copies
each upload into the collection's content-addressed key, writes the next manifest and opens the
proposal; every path is content, so step 6 never runs checks. After step 7 the uploads' staged rows
and keys are deleted (the manifest names the copies).
Once a change that puts an upload has been added, that upload cannot be put again (invalid_upload):
upload the file again. Git-lfs pushes are staged the same way (verified, unaccepted) until an
accepted version points to them.
All upload routes need a signed-in member (cfs_ session or cookie; agents never upload). Part,
complete, status and abort answer 404 to anyone but the uploader.
Route
Who
Body → response
POST /api/orgs/:org/projects/:pid/folders/:mount/uploads
Owner, or developer with write on the folder
{ path, size, mime } → 201 { uploadId, partSize, parts, expiresAt }. path is normalized, relative, at most 1,024 characters and never under .git (400 invalid_path); size is 0 to 5,000,000,000 bytes (400 invalid_size, 413 file_too_large, also when it needs more than 10,000 parts). partSize is COMMONFORGE_UPLOAD_PART_BYTES (default 52,428,800): every part except the last is exactly that long. An empty file (size: 0) has parts: 0: no part is sent, and it is completed with parts: []. expiresAt is 24 h after the start.
PUT /api/orgs/:org/uploads/:uploadId/parts/:n
The uploader
Raw bytes, content-length required → 200 { etag }. Part numbers run 1..parts. 409 upload_finished, 400 invalid_part, 411 length_required, 413 part_too_large, 400 part_size_mismatch (wrong length, or a body shorter or longer than content-length). A part can be sent again.
POST /api/orgs/:org/uploads/:uploadId/complete
The uploader
{ parts: [{ n, etag }] }, all parts in order ([] for an empty file, which writes the empty object) → 200 { stagingId, size } and queues the hash step. 400 parts_mismatch, 400 upload_incomplete (an unknown etag or an undersized part: send the missing parts and complete again; the upload stays open), 400 size_mismatch (the assembled object has another size than size: the upload is deleted). Completing again after a lost response answers 200 again.
GET /api/orgs/:org/uploads/:uploadId
The uploader
{ stagingId, state: "uploading"|"uploaded"|"verified"|"rejected", size, sha256, path, error? }. sha256 is set once verified. error on rejected: size_mismatch, hash_mismatch, missing, object_changed (written again after it was verified), hash_failed: … (including hash_failed: gave up after 3 retries, see GC below).
DELETE /api/orgs/:org/uploads/:uploadId
The uploader
204. Aborts an unfinished upload and removes its row; a finished upload is left to expire.
uploadId is the stagingId to name in a change. Unfinished uploads are aborted after 24 h.
/api/lfs/:org/:rid/info/lfs/… implements the Git LFS batch API with the basic transfer adapter
for the git folder :rid (the resource id). Point git-lfs at it with
lfs.url = <COMMONFORGE_PUBLIC_URL>/api/lfs/<org>/<rid>/info/lfs. Folders added with
POST …/folders carry it in their .lfsconfig (and LFS patterns for media, archives, fonts, PDF
and Office files in .gitattributes), so a plain clone uses it. Folders made before phase 4 have no
.lfsconfig: a plain git clone of one does not know the LFS URL (set lfs.url yourself). The URL
in .lfsconfig is COMMONFORGE_PUBLIC_URL at the time the folder was added.
Credentials. HTTP Basic with any user name and a session (cfs_) or task lease (cfl_) token as
password, or Authorization: Bearer <token>.
cforge checkout wires it. The materialization plan gives each git mount its lfsUrl. For a
mount whose lfsUrl is on the checkout's API origin (https, or http on localhost / 127.0.0.1),
cforge checkout records it in the checkout record and sets, in the mount's local git config,
lfs.url = <lfsUrl> (from the plan, so folders without .lfsconfig, or with a stale one, work too),
lfs.fetchexclude = *, and the credential helper scoped to the LFS URL
(credential.<lfsUrl>.helper reset, then cforge credential-helper --record …, and
credential.<lfsUrl>.useHttpPath = true). The clone runs with GIT_LFS_SKIP_SMUDGE=1, so LFS files
stay pointers and a checkout never downloads LFS objects (git -c lfs.fetchexclude= lfs pull fetches them on
demand). When git-lfs asks for the LFS URL (protocol and host of the API, path equal to the
endpoint's or under it, e.g. …/info/lfs/objects/batch), the helper answers username=x-token,
the task lease token as password and password_expiry_utc from the stored lease, without calling
the broker; anything else it refuses (quit=1). Nothing is written to disk. So an agent's
git push of a large file uploads its LFS objects with the lease, into the task's staging area. An
lfsUrl on another origin is not configured at all (cforge checkout warns on stderr).
Responses are application/vnd.git-lfs+json, cache-control: no-store; errors are
{ "message": … }. A 401 carries LFS-Authenticate: Basic realm="CommonForge" (and no
WWW-Authenticate, so browsers do not prompt for or cache credentials). A folder the caller cannot
read, or that is not a git folder, answers 404.
Who.
- Owners and developers with write on the folder may upload,
verify and locks/verify (all three
answer 403 without write). Any owner or developer who can read the folder may also download
objects that are not accepted yet ("staged").
- Clients and viewers read accepted objects only, and never see staged ones.
- An agent works with its task lease: it reads in any workspace state but
abandoned, writes only
in a writable write workspace of this folder, and downloads accepted objects plus its own
task's unaccepted ones.
Route
Body → response
POST …/objects/batch
{ operation: "upload"|"download", objects: [{ oid, size }], hash_algo?: "sha256" } → 200 { transfer: "basic", hash_algo: "sha256", objects }. A body that is not JSON: 400; JSON that is not a valid batch request (not an object, unknown operation, objects not a list): 422; hash_algo other than sha256: 422; more than 100 objects: 413. upload: 403 without write; 503 while the R2 credential secrets are unset. Each object is { oid, size } when that exact object is accepted already, { …, error: { code: 422 } } for a bad oid or a size over 5,000,000,000 bytes, otherwise actions.upload and actions.verify. actions.upload is a presigned PUT for one key and one method, expires_in clamp(ceil(size/2e6), 900, 3600) seconds, that only creates: it signs If-None-Match: * (X-Amz-SignedHeaders=host;if-none-match) and carries header: { "If-None-Match": "*" }, which git-lfs sends; a PUT to a key that already holds an object is refused by R2. A retried batch reuses the caller's pending upload of that oid unless its key already holds bytes (a push interrupted between PUT and verify), in which case a fresh key is offered. download: actions.download for a servable object, error.code 404 for the others.
POST …/verify
{ oid, size } → 200 {}. Checks the object is in R2 with that size and queues the hash step. 404 when nothing of the caller's is pending, 422 for a missing object or a size mismatch. A retry after success answers 200.
GET …/objects/:oid
Streams the object (application/octet-stream, content-disposition: attachment, x-content-type-options: nosniff, cache-control: private, no-store), read pinned to the etag the hash step recorded. 404 for an object the caller may not read, and for one whose bytes were written again after verification (the object is then rejected object_changed, accepted or not; nothing is deleted at that moment).
POST …/locks/verify
→ 200 { ours: [], theirs: [] }; git-lfs calls it before every push. 403 without write.
* …/locks, * …/locks/*
404 "file locking is not supported".
A pushed object is verified by its hash step: its sha256 must equal the oid, otherwise it is
rejected (hash_mismatch) and never served.
Owners, and developers with write on the folder (a git folder or a file collection).
Route
Who
Body → response
POST /api/orgs/:org/projects/:pid/folders/:mount/changes
Owner, or developer with write on the folder
{ ops: Op[], title?, idempotencyKey? } → 202 FileChange (state applying)
GET /api/orgs/:org/file-changes/:changeId
The author; owners and developers who can read the folder
FileChange. Anyone else: 404 change not found.
GET /api/orgs/:org/projects/:pid/file-changes?mount=&limit=
Owners and developers who can read the project
{ changes: FileChange[] }, newest first. Every open change (applying, checking, waiting_approval, staged) on the folders the caller can read is always listed, whatever its age and the limit (up to 100); terminal changes (added, failed) that ended in the last 24 hours fill the rest of limit (1 to 50, default 20; 400 invalid_request otherwise): anyone's for owners, only the caller's own for developers. mount= narrows the list to one folder (404 for a folder the caller cannot read). Up to 10 open changes per request get the read-time recovery (below); the rest are shown as stored. Clients and viewers: 403 insufficient_role.
Op is one of (exact keys; paths as for uploads):
{ put: path, stagingId }: put a verified upload of yours to this folder at path. The same upload may be put at several paths.
{ edit: path, content, baseBlob }: replace a small text file (valid UTF-8 text without NUL, at most 1 MiB, under the LFS threshold) with content. baseBlob is the 40-hex git blob id of the version being edited, from the x-commonforge-blob header of GET …/file (below), or '' for a new file. A file stored in LFS is never edited in place. Git folders only: a file collection answers 400 edit_unsupported (upload the new version instead).
{ delete: path }: delete a file, or everything under a folder path. In a git folder, deleting what is not there is a no-op; in a file collection it fails the change file_missing.
{ move: path, to }: move a file or folder. In a file collection, a missing source fails file_missing (unless the destination already exists: a re-applied move counts as done), and a destination file with other bytes, a path below a file, or a move into itself fails path_exists.
Rules:
- 1 to 1,000 operations (
413 too_many_operations), 20 GB of puts per change (413 change_too_large; counted per put operation), 5 GB per file.
- Edits: at most 1 MiB each (
413 edit_too_large) and 16 MiB together (413 edits_too_large). Edit contents are kept in R2 beside the change; the resolved operations also travel to the runner as an R2 manifest.
ops are checked as a whole: 400 invalid_request, 400 invalid_path, 400 invalid_upload (an upload that is not yours, is for another folder, was made by an agent, is unfinished, was rejected or was added already).
403 no_write_grant, 403 insufficient_role, 400 edit_unsupported (an edit on a file collection), 404 for a folder you cannot read.
title defaults to "Uploaded 30 files to videos/2026", "Edited config.json" or "Changed 3 files".
idempotencyKey (optional; 1 to 128 characters of A-Z a-z 0-9 _ -, else 400 invalid_request): send one key per change you mean to make, and the same key when you retry it (a lost reply, a 5xx). A repeat with a key you used before answers that change, 202 FileChange as GET /file-changes/:id shows it now, and makes nothing new, as long as it names the same folder and the same operations (the title is not compared); it is answered before the uploads are checked, so it works after the change was added too. The same key with another folder or other operations is 409 idempotency_key_reused. Keys are per user: another person's key never matches yours.
FileChange is { changeId, projectId, mountName, title, state, attempt, proposalId, taskId, error?: { code, message }, note?: "no_changes", changedPaths?, createdAt, updatedAt }. taskId is the current attempt's task once it was applied (null before): in a staging-mode project, the task to promote. changedPaths is the runner's list of { path, change: "added"|"modified"|"deleted", lfs: boolean, class: "content"|"code" } once the change was applied (at most 5,000 entries: a change that would touch more fails limit_exceeded). For a file collection the Worker builds the same list from the old and new manifests (every path content, lfs: false).
States.
State
Meaning
applying
Waiting for the uploads' hash step, then the runner applies the operations; the candidate is frozen and the proposal opened and prepared. attempt starts at 1.
checking
The proposal is being integrated (checks only when a changed path is code), then the Worker records the uploader's approval and accepts.
waiting_approval
Integrated, but the uploader's role is not in the project policy's approvalRoles (owners when it sets none). Someone who may approve opens proposalId and approves and accepts it; the change becomes added.
staged
Staging-mode projects only (git folders): the change is on staging and waits for a promotion. It has no proposal of its own (proposalId: null). It becomes added when a promotion accepts its task's landing into main, failed when that landing fails or is rolled back.
added
Accepted: the folder has a new head. With note: "no_changes" the operations changed nothing and no proposal was made.
failed
error: { code, message }. Final. Nothing was added: the change's proposal is withdrawn and, in a staging-mode project, its staging landing is rolled back.
Staging-mode projects. A change to a git folder goes through promotion like an agent task: once its
candidate is frozen, its task lands in staging (a landing like any task's, titled with the change's title),
its candidate is prepared and the change is staged. It is not integrated, approved or accepted by itself;
the project's checks and the uploader's approval do not apply to it (the promotion's build, gate and
clearance do). Promote its task (POST /api/tasks/:taskId/promote; the task id is the landing's in
GET …/staging) to bring it to main and production: at the promotion's accept the change is added and
the LFS objects its receipt named are accepted. A withdrawn, sent-back or superseded promotion leaves it
staged; its landing failing makes it failed staging_failed (the message includes the landing's
reason), a rollback staging_rolled_back, a candidate that could not be prepared prepare_failed (its
landing is rolled back), and a change whose task never got a landing (6 hours after its candidate was
frozen) staging_land_failed. If staging drops the landing after its promotion's accept committed, the
change is failed staging_failed first and then added by the accept (its uploads are kept meanwhile). A staged change is never applied again on a moved head (its
promotion rebuilds on the new main), and its uploads never expire while it is staged. A change that had
already opened its own proposal when staging mode applied finishes on the old path. File collections are
not staged (as before, they land on their own).
Stale base. When another change was accepted first, uploads, deletes and moves are applied again
on the new head (attempt + 1, the old proposal is closed as superseded), up to 3 attempts, then
stale_base. A change with an edit fails file_changed instead (the file may have changed: open it again).
When the accept is refused because the project's policy changed (policy_revision_stale), every
change is applied again under the new policy, edits included (no file changed; each edit's
baseBlob still guards it), counting toward the same 3 attempts.
Failure codes (error.code; the change's error.message says more):
- Upload and request:
invalid_upload (an upload vanished); an upload rejected after the change named it fails with the upload's own reason: hash_mismatch (its bytes do not hash to the oid git-lfs claimed), size_mismatch, upload_missing (its bytes are gone from R2), object_changed (written again after verification) or hash_failed (it could not be hashed); file_changed (an edit's baseBlob no longer matches, or the base moved), stale_base (three attempts), path_conflict (a file below a file, or over a folder), path_not_found, limit_exceeded (also for a path whose .gitattributes entry would be too long, an edit of a file stored in LFS, or more than 5,000 changed paths).
- File collections:
file_missing (a delete or move of a path that is not there), path_exists (a move onto another file, below a file or into itself; a put below a file or over a folder), object_missing (an object of the collection's current version is gone from storage), manifest_missing (the collection's accepted manifest is gone), limit_exceeded (more than 5,000 changed paths, or paths too long to record together), hash_mismatch (an upload's stored bytes changed after they were verified, found while copying it).
- Folder:
folder_missing, no_accepted_head, uploader_missing, no_write_grant (the uploader lost write before landing).
- Runner and receipt:
scope_violation, receipt_mismatch, provider_unavailable, freeze_failed, job_expired.
- Landing:
check_failed (a policy check failed or was unmet), proposal_closed (someone rejected or withdrew the proposal), or the integration's or ledger's own code.
- Staging (staged changes):
staging_failed, staging_rolled_back, staging_land_failed.
A change to a file collection is applied by the Worker in two queue steps (files.apply hands it
over):
- Copy.
files.assets.copy copies one verified upload into the collection's content key
org/<org>/res/<rid>/obj/<sha256>, pinned to the etag the hash step recorded and hashed again
while it is piped (a changed source fails the change hash_mismatch). One step per upload, so a
20 GB change never has to fit in one step; a copy that is already there is not redone, and one
upload is copied by one step at a time (a copy lock, leased 15 minutes).
- Manifest.
files.assets.manifest waits until every upload is verified and copied (each
finished copy wakes it), creates the attempt's task with an R2 workspace on the collection's
accepted manifest (r2m:<id>), applies the operations in order to that manifest, writes the new
manifest, freezes the workspace and opens the proposal. The change then goes on to files.land
and files.accept as a git change does, with no checks. Before the proposal it checks the copies
once more while holding the asset GC's lock, so a GC run that listed an old, unreferenced copy
cannot delete it under the new manifest (a swept copy is made again).
Operations that change nothing (the collection already holds those files) end added with
note: "no_changes": no proposal is made and the uploads are deleted. Stale base works as for git
folders (file collections have no edits, so they always re-apply).
GET /api/orgs/:org/projects/:pid/file?mount=&path=&revision= on a git folder also sends
x-commonforge-blob: the file's git blob id (40 hex, SHA-1 of blob <size>\0<bytes>). An editor
sends it back as baseBlob. Files stored in LFS send x-commonforge-lfs instead and are not
editable. File collections send x-commonforge-sha256 instead.
Name
Kind
Meaning
COMMONFORGE_ASSETS_BUCKET
var
Name of the bucket behind the COMMONFORGE_ASSETS binding; presigned URLs address it through R2's S3 API.
COMMONFORGE_R2_ACCESS_KEY_ID, COMMONFORGE_R2_SECRET_ACCESS_KEY
secrets, optional
An R2 API token (Object Read & Write on that bucket) that signs git-lfs upload URLs. While either is unset, git-lfs uploads answer 503; browser uploads, changes and downloads work.
COMMONFORGE_LFS_THRESHOLD_BYTES
var, default 1048576
Text files above this size, and every binary file, are stored in LFS. Edits must be under it.
COMMONFORGE_STAGING_TTL_DAYS
var, default 7
Unaccepted LFS objects (verified, rejected or never used) are deleted this many days after their last use, unless an open change or a live task needs them. Accepted objects are never deleted by GC.
COMMONFORGE_UPLOAD_PART_BYTES
var, default 52428800
Browser upload part size; values under 5242880 are ignored (R2's minimum).
GC (every 10 minutes, as the last phase of each organization's pass) also aborts unfinished uploads
after 24 h, enqueues the hash step again for uploads left uploaded for 15 minutes (a lost step; at
most 3 times, then the upload is rejected hash_failed: gave up after 3 retries and the changes
waiting for it fail hash_failed), deletes the R2 artifacts of finished changes (operation
manifests, edit contents) after the staging TTL, settles open changes nobody reads (see below),
and gives verified objects hashed before migration 0013 their pointer blob id (from the recorded
sha256 and size, nothing is read; the log line's lfsPointerBackfill), which the tree and file
routes need to recognise their pointers. Each kind handles at most 100 rows per organization per
pass (50 for the settle sweep, 500 for the pointer backfill). Before that phase, a new folder's setup
still pending after 15 minutes with no running job is queued again (the log line's
folderInitRequeued; each job it leads to counts toward the setup's 3 attempts).
Repositories hold LFS pointer files for large and binary files; the bytes are in R2. Runners
never download LFS objects: apply-files writes the pointer from the sha256 and size the hash step
recorded, and fetches only small text files. Checks, integrations and task workspaces therefore see
pointers; a person or agent that needs the bytes runs git lfs pull against the LFS server above
(in a cforge checkout mount, git -c lfs.fetchexclude= lfs pull, since checkout sets lfs.fetchexclude = *).
Every pointer the runner writes gets a .gitattributes entry in the same commit, so it is never
committed untracked.
Recovery. A change that has not moved for 2 minutes is looked at again whenever it is read (and
by GC): a lost queue step is enqueued again, an expired runner job fails the change job_expired,
an accepted proposal finishes landing, and a waiting_approval change whose base moved re-applies.
These match the runner's apps/runner/src/worker-client.ts and blob-store.ts (Task 14).
Job kinds. seed-workspace, prepare-candidate, integrate, reconcile-ref, build-edition, edition-return, apply-files, inventory, export, preview-build, release-build, release-deploy. apply-files (put / edit / delete / move on a folder's accepted head, one commit to a fresh -tasks repository) carries its operations as an R2 manifest, opsManifest { r2Key, sha256, count }, never inline.
Delivery. The runner gets jobs in one of two ways:
- consuming
{ signedJob } messages (RunnerJobDelivery) from commonforge-runner-jobs;
- polling
/api/runner/jobs/next.
The delivery never carries the evidence key. The runner fetches it from /evidence-key, so no
secret ever sits in a queue.
Authentication. The SignedJob itself is the credential. The Worker verifies it with the job
key on every call; the runner never holds that key. It must also be a job this Worker issued
(job_unknown) and, where noted, still open (job_finished).
Route
Body → response
POST /api/runner/credential (= job.credentialBroker)
RunnerCredentialRequest { signedJob, repo: RepoCoords, access } → RunnerCredentialResponse { token, tokenId, expiresAt, remoteUrl } (no-store). Details below.
POST /api/runner/credential/revoke (<credentialBroker>/revoke)
{ signedJob, tokenId } → { ok: true }. Only tokens minted for that job (404 otherwise). Allowed after expiry.
POST /api/runner/results
{ signedJob, result: RunnerResult } → 201 { jobId, replayed: false, status }. Details below.
POST /api/runner/evidence-key
{ signedJob } (integrate jobs, while open) → EvidenceKeyWire, the per-job derived key for signEvidence (no-store)
POST /api/runner/heads
{ signedJob } → { heads: { [resourceId]: RevisionId | null } }, the current ledger heads of the resources the job names. Informational: the ledger decides at accept.
POST /api/runner/jobs/next
Authorization: Bearer <COMMONFORGE_RUNNER_TOKEN>, { runnerId } → 200 { signedJob } or 204. Details below.
GET|PUT /api/runner/blobs/<encodeURIComponent(key)>
Header x-commonforge-signed-job: <base64url JSON SignedJob>. Details below.
Credential requests (/api/runner/credential):
- The broker verifies the job and requires it to be open.
authorizeCredentialRequest decides whether the job may have the repo. Canonical write is
allowed only for prepare-candidate, integrate and reconcile-ref. apply-files reads
canonical and writes only a fresh -tasks fork (like edition-return).
- The repo must sit in the job's org namespace, and its id and remote URL must equal the live
server record (
identity_mismatch).
- The TTL is at most
expiresAt and at most 1 h (409 job_expiring under 60 s).
- The minted token id is recorded and revoked when the result arrives.
Results (/api/runner/results):
- The result must parse for the job's kind.
- Integrate evidence, and any failure evidence, must verify with the per-job derived key
(
403 evidence_invalid).
- A result is accepted until 15 min after
expiresAt.
- The same result again gets
200 { replayed: true }. A different result gets
409 result_conflict; the runner treats 409 as "already applied".
Pull mode (/api/runner/jobs/next):
- The endpoint claims the oldest open, unexpired job. A claim older than 10 min can be re-claimed.
- When
COMMONFORGE_RUNNER_TOKEN is unset the endpoint answers 503 runner_poll_disabled.
Blobs (/api/runner/blobs/<key>):
- GET is allowed only for
inventory and edition-return (job.upload.r2Key), and export
(keys under job.sink.r2Prefix, which must be org/<org>/exports/…).
- GET is also allowed for
release-deploy (keys under org/<org>/releases/<releaseId>/).
- GET is allowed for
apply-files: the job's operations manifest (job.opsManifest.r2Key, under org/<org>/file-changes/<changeId>/) and the small text files its change puts (under org/<org>/lfs/<resourceId>/obj/). LFS objects are never readable by a runner.
- PUT is allowed only for
export (keys under the sink prefix), preview-build
(org/<org>/previews/<previewId>/site/… and …/bundles/…; never meta.json, which only the
Worker writes) and release-build (org/<org>/releases/<releaseId>/…).
- PUT verifies
x-commonforge-sha256 when it is given.
- Any other key gets
403 scope_violation.
Runner contract notes:
integrate.baseSnapshot is the real base snapshot. Evidence must use
snapshotId = job.candidateSnapshotId; the ledger checks it against
proposal.candidateSnapshotId.
- Results are parsed with
parseRunnerResultForJob(signedJob, result), so a receipt for another
job, proposal, ref or sha is refused with 400 invalid_result.
- The integration ref is
refs/commonforge/integrations/<proposalId>/<jobId>, one push per ref,
with --force-with-lease (no --atomic).
seed-workspace must push exactly <baseSha>:<ref> with a must-not-exist lease
(see runner-protocol's README).
Message
Work
{ type: "workspace.create", orgId, taskWorkspaceId }
createIsolatedChange (create the -tasks repo, seed it with a runner job), then record the workspace in the ledger. Serialized per workspace id.
{ type: "intake.import", orgId, intakeId }
Public URL import, 504→depth retry, token revoke, genesis bindHead.
{ type: "candidate.prepare", orgId, candidateId }
Runner prepare-candidate, recordPreparedReceipt, attach the receipt to open proposals.
{ type: "edition.build", orgId, buildId }
Create/adopt the edition repo, issue build-edition.
{ type: "edition.return", orgId, returnId }
Create the return's empty -tasks repo, record the workspace, issue edition-return.
{ type: "edition.candidate", orgId, returnId }
Freeze the returned candidate, open the proposal, queue candidate.prepare.
{ type: "lfs.hash", orgId, stagingId }
Hash a completed upload from R2: sha256, size, binary and the etag of the hashed bytes; verified or rejected; wakes the file changes waiting for it. GC enqueues it again for an upload left uploaded 15 minutes (at most 3 times).
{ type: "files.apply", orgId, changeId }
Wait for the change's uploads, create the attempt's task and workspace, store the operations manifest, issue apply-files. A file collection's change is handed to files.assets.manifest instead.
{ type: "files.assets.copy", orgId, changeId, stagingId }
File collections: copy one verified upload into its content key (adoptVerified, under a per-upload lock), then wake files.assets.manifest. Changed bytes fail the change hash_mismatch.
{ type: "files.assets.manifest", orgId, changeId, copied? }
File collections: once every upload is verified and copied (enqueues the missing copies otherwise), create the task and R2 workspace, write the new manifest, freeze, open the proposal, queue files.land. Waits (retry) while an asset GC run holds its lock.
{ type: "files.candidate", orgId, changeId }
Freeze the applied candidate, open the proposal, queue candidate.prepare.
{ type: "files.land", orgId, changeId }
Once prepared, issue the integration (checks only when a changed path is code) and move the change to checking.
{ type: "files.accept", orgId, changeId, integrationOk, code?, detail? }
After the integration: record the uploader's approval when their role counts, accept, promote LFS objects (a file collection's change deletes its staged uploads instead), or waiting_approval / failed.
- Steps are idempotent.
- When the runner has not answered within 90 s (
deps.runnerWait), the step is retried. The
retry joins the same open runner job, matched by a hash of the job body.
- After 10 attempts the step's record is marked failed.
Canonical-ref projection. The ledger head is the authority; refs/heads/<branch> of the
canonical repo is a projection (plan §A.5 rule 2). Every accept and restore commits a reconcile-ref
outbox event and leaves the head reconciliation: "pending". GET …/resources/:rid shows it.
Outbox consumer (commonforge-outbox, services/outbox-consumer.ts):
- Each event is applied once per
(organizationId, dedupeKey). The dedupe row lives in the
org's ledger (consumed_events). For reconcile-ref it is written in the same DO transaction as
the reconciliation state (OrgLedger.recordReconciliation); projection-refresh and
candidate-superseded write their idempotent effect first, then consumeEvent. Redeliveries are
acked without touching the provider.
reconcile-ref: if the event's target is no longer the head it is consumed as superseded (the
newer head has its own event). Asset (r2m:) heads are synced at once. Otherwise the consumer
reads the resource's recorded branch (already at the head → synced, no job) and then:
- Lease rule (review I-1). It pushes only with a lease on a sha it vouches for: the event's
previous head (
expectedCurrent), the sha last recorded as synced (ledger ref_state), or a
foreign sha already recorded as diverged while this head was current. Any other branch value
is first recorded as diverged (observation) and the message retried; the next attempt may
then lease on it and move the ref to the ledger head.
- It re-reads the head after reading the branch and before submitting (moved →
superseded, no
push), runs reconcileAccepted(…, {branch, expectedCurrentSha}), and re-reads the head after
the job. If the head moved meanwhile, the current head is set back to pending and gets its own
reconcile request leasing on the sha just pushed. So an older job never leaves a newer head
wrongly synced.
- Open-job gate (re-review N-1). A lease is a sha, and a restore can make an old lease sha
the branch value again. So nothing is settled without a push while an older
reconcile-ref job
for the resource is still open (queued, not past expiresAt + 15 min): the branch-already-at-
head path, every superseded exit (and its verifySyncedHead) and the pushed wakeup's
synced all wait (message retried, head left pending) until that job resolved or expired.
After expiry the broker mints no token for it, so it can no longer push. If the waiting message
is dead-lettered meanwhile, the head is still pending and the GC backstop re-requests it.
- Retries join the same open job (the job key is salted with the dedupeKey, so a finished job is
never reused for a later event).
synced → recorded and consumed;
diverged (the lease failed: someone else holds the ref) → recorded, message retried;
- a failed push with the ref unmoved (
provider_unavailable), runner still working, ops gate busy
→ retried, the head stays pending (never diverged);
- permanent provider errors (canonical repo gone or recreated) →
diverged, consumed.
Every state change is audited as head.reconciliation_changed (actor service:org-ledger).
projection-refresh refreshes the D1 projection; candidate-superseded marks a still frozen or
prepared candidate superseded. Malformed messages are dropped.
- After 10 deliveries a message moves to
commonforge-outbox-dlq; its consumer records an
outbox.dead_lettered event. The head stays pending and the GC backstop below re-requests it.
D1 projection resource_heads (org_id, resource_id, revision_id, version_seq, accepted_version_id, accepted_at, reconciliation, refreshed_at): refreshed by the consumer and by
every GC run (never moves backwards in version_seq). List views may read it; the ledger stays the
authority.
Artifacts pushed wakeups (commonforge-artifacts-events, services/artifact-events.ts; spike
A6): wakeups only, never authority. Deduped by (repo, ref, before, after) in the org ledger (with
before, a later move back to an already seen sha is a new wakeup). Only the
canonical repo's recorded branch counts; task repos, retention/integration refs and other
environments are ignored. The consumer reads the branch itself (the event's after is never
trusted): at the head's sha → synced; head still pending → nothing; otherwise → diverged plus a
reconcile-ref request on the outbox queue. repo.forked / repo.imported are never relied on.
GC (cron */10 * * * *, services/gc.ts; plan §C.2 P0 shortcut). One run at a time: the run
holds the OpsGate lock gc/sweep (lease 14 min); a run that finds it held is skipped, and a run
starts no new org pass after 10 min (the rest waits for the next run), so asset passes never
overlap. The cron handler awaits the run. Phases, each idempotent and isolated per org:
- Runner jobs past
expiresAt + 15 min without a result are closed (failed, code
job_expired; a late result gets 409 result_conflict) and their minted tokens revoked.
running tasks with no live lease (expired or released) and no activity for 7 days
(ABANDON_AFTER_SECONDS, from max(lease expiry, last update)) are canceled (generation bumped).
ready tasks are never canceled. Writable workspaces of canceled, failed or submitted
tasks (reference copies included, N-4) are marked abandoned in D1 and the ledger, and every
token on them is revoked.
- Task repos are deleted with
collectWorkspace (repo id checked; canonical never) after a 1 h
grace when abandoned, or frozen with every candidate past frozen (prepared → retained in
canonical; the ledger records retained → collected). The grace counts from the abandonment
or the freeze (not the prepare), so a fork frozen long before its prepare finished goes on the
first run after it. Pushed but unfrozen work: an abandoned written task repo whose branch
moved past its seeded base is kept 7 more days (UNFROZEN_WORK_GRACE_SECONDS) so the work can
still be recovered (clone it with an owner's help) before deletion. Workspaces stuck provisioning for 6 h
are failed. For failed workspaces the recorded repo name is looked up and deleted on every run
for 24 h, because a create that answered 500 can leave a ghost name that materializes later (A9).
task_workspaces.swept_at marks a finished sweep; at most 200 deletions per org per run
(confirming an absent repo is not counted). P0 scale note: every run loads the org's unswept
workspaces (filtered by state in SQL).
- Stale canonical write tokens are revoked (
revokeStaleCanonicalWriteTokens).
- The head projection is refreshed; heads not
synced 10 min after acceptance get a fresh
reconcile-ref request leasing on the previous head (backstop for dead letters and lost
events): pending heads every run, diverged heads with exponential backoff (runs 1, 2, 4, 8, … after the
divergence was observed).
- Assets: the reachable manifest set is computed after the run start from every accepted
version (current and previous heads), open/prepared proposals, asset workspaces of live tasks
(not
failed/canceled) and exports; then R2AssetProvider.collectUnreachable deletes
unreachable objects older than 1 h. Just before deleting it re-lists manifests and spares any
written during the run, with their objects (Task 13 re-review).
Kind
Names
Bindings
ORG_LEDGER, OPS_GATE (DO, migration v2), DB (D1 commonforge-dev, migrations/), COMMONFORGE_ASSETS (R2), EMAIL (send_email), OUTBOX, STEPS, RUNNER_JOBS (queue producers), ARTIFACTS (unused by the API; see the transport note at the top)
Queue consumers
commonforge-outbox (max_batch_size 4, max_retries 10, DLQ commonforge-outbox-dlq), commonforge-outbox-dlq, commonforge-steps, commonforge-artifacts-events
Cron
*/10 * * * * (GC)
Vars
COMMONFORGE_NAMESPACE_PREFIX, COMMONFORGE_APP_URL, COMMONFORGE_PUBLIC_URL (broker URL inside jobs; no default: an https URL per environment, http://localhost:8787 only in .dev.vars; jobs are refused with 503 config_missing while unset), COMMONFORGE_EMAIL_FROM, COMMONFORGE_EMAIL_MODE
Folders with uploads
Var COMMONFORGE_ASSETS_BUCKET; optional secrets COMMONFORGE_R2_ACCESS_KEY_ID, COMMONFORGE_R2_SECRET_ACCESS_KEY; vars COMMONFORGE_LFS_THRESHOLD_BYTES, COMMONFORGE_STAGING_TTL_DAYS, COMMONFORGE_UPLOAD_PART_BYTES (see Folders with uploads)
Secrets
COMMONFORGE_SESSION_SECRET (session, magic-link and lease HMAC; also derives the asset token key), COMMONFORGE_RUNNER_TOKEN (optional; pull-mode runner bearer), COMMONFORGE_RUNNER_JOB_KEY (≥ 32 bytes; signs jobs, kid k1), CF_ACCOUNT_ID, CF_API_TOKEN (Artifacts REST), COMMONFORGE_PREVIEW_KEY (≥ 32 bytes; preview link tokens; same value on commonforge-preview), COMMONFORGE_DEPLOY_API_TOKEN (Workers Scripts: Edit; handed only to open release-deploy jobs)
Risk classifier (staging phase 3)
Optional secrets JEV_API_KEY (TypeSafe API key for provider jev), OPENAI_API_KEY (provider openai-decisions); optional var COMMONFORGE_OPENAI_DECISIONS_MODEL (default gpt-6-luna). Set with wrangler secret put and in .dev.vars; never in policy, logs, events or gate_json. A provider whose key is missing fails closed (not_configured: the promotion waits for a developer).
Task 22 vars
COMMONFORGE_PREVIEW_URL (the preview Worker's origin; must differ from the app/API origin, else 503 config_invalid), COMMONFORGE_DEPLOY_ACCOUNT_ID (optional; default CF_ACCOUNT_ID)
Compatibility flags
global_fetch_strictly_public (the release health check fetches <target>.workers.dev/version; same-account workers.dev fetches otherwise fail with error 1042). Review m-6: the only control-plane fetch to a workers.dev host is the derived health URL of an org Worker (cfapp-<org>-…, never commonforge-*); intake URLs are fetched by Artifacts import, not by this Worker.
Threat
Where the Worker enforces it
Canonical mutation by an agent
Agents get tokens only for their task's -tasks repos (issueWorkspaceCredential): the writable copy, and read-only reference copies. They never get a canonical token, read or write. Canonical write exists only in runner jobs of the three allowed kinds (authorizeCredentialRequest). Tests: tasks.test.ts, runner.test.ts, flow.test.ts.
Stale acceptance
OrgLedger.accept rechecks heads, hash, approvals (current authorization), policy revision, receipts and evidence. Tests: flow.test.ts (stale accept, policy change, no approval).
Token leakage
Agent tokens are capped at min(1 h, lease). Tokens are revoked on freeze, on a new lease, on lease release, and when the holder is removed, demoted or loses a grant. Runner tokens are capped at job expiry and revoked when the result arrives. Lease and session tokens are stored hashed. Credentials are sent with no-store.
Client source disclosure (AT-25, AT-26)
Clients and viewers never receive canonical coordinates, raw revision ids (O-1), canonical credentials, tasks or materialization plans. The only token a client can get is a read token on its edition repo (-editions), which holds one fresh commit of allowlisted files (build-edition). There is no endpoint that mints for a canonical URL without a Worker-issued signed job. Tests: authz-matrix.test.ts, tasks.test.ts, editions.test.ts.
Cross-tenant and cross-resource (AT-16)
Every read resolves (org, project, mount) → resource and checks the resource grant. Non-members get 404. Namespaces and R2 keys carry the org id. Each org has its own ledger DO.
Malicious repository instructions (AT-18, AT-38)
Checks, contracts and approval roles come only from PUT …/policy, which is owner-only. A policy change makes open proposals policy_revision_stale.
Duplicate events / lost responses (AT-14)
Accept and restore idempotency keys. Runner results are applied once per jobId. Steps join open jobs. Outbox events are applied once per (organizationId, dedupeKey) (ledger consumed_events); pushed events are deduped by (repo, ref, after) and are wakeups only. Tests: outbox.test.ts.
Orphaned candidates/assets, partial failure (AT-12/13)
GC: task repos after prepare-candidate or abandonment plus 1 h; ghost names after failed creates; assets by ledger reachability with grace; the head shows pending until reconcile-ref lands. Tests: gc.test.ts, outbox.test.ts.
Rate limits (A9)
OpsGate: at most 25 concurrent create/token/delete/import operations per namespace, with jittered waits, then 503 provider_unavailable. Create tokens and import tokens are never kept.
CSRF / session theft
Origin and Sec-Fetch-Site guard plus JSON-only bodies on bearer-less writes. The browser session lives only in an HttpOnly cookie; bearers come only from the explicit CLI login.
Email abuse
Per-address, per-IP and global magic-link limits (atomic, DO-held).
- Grants and memberships are authoritative in D1 (M-1). The ledger re-checks hashes, heads,
receipts, evidence and policy revision inside its transaction, but not who holds a grant.
Approvals are filtered by current D1 membership just before
accept; a membership change in
that window is not atomic with the accept. Acceptable for P0. A later option is to have the DO
hold a membership revision that accept compares.
- No "list/revoke all my sessions" endpoint yet. Sessions last 30 days; logout revokes the
current one.
- The global magic-link cap (200/h) can be exhausted by a distributed sender. Consider
Turnstile before a public launch.
- Self-approval is allowed. The PRD lets one person hold every role; the records stay
separate (approval ≠ acceptance). Agents can never approve.
- The client snapshot view filters entries but keeps the full
snapshotId (an opaque hash).
- Previews and releases (Task 22):
- All previews share one origin (
commonforge-preview.<sub>.workers.dev, path per preview):
storage (localStorage, IndexedDB) is shared between previews in the same browser. Service
Workers are blocked (worker-src 'none', Service-Worker: script refused,
Service-Worker-Allowed/Clear-Site-Data stripped; review I-1) and cookies are blocked (the
app's session cookie is __Host-; review I-2). Previews, demo targets and the app still share
the registrable domain <sub>.workers.dev unless the deployment puts them on custom domains
(scripts/bootstrap.sh --app-domain/--preview-domain).
- Preview links carry their token in the path;
commonforge-preview keeps observability on but
invocation logs (which record URLs) off (review m-8). At most 3 previews of a project build at
once (409 preview_limit); revoking a preview deletes its objects (review m-1, m-9).
- The preview api runs as a Node "dev mode" host (
preview-host.js), not workerd: a bundle that
needs Workers-only runtime APIs would not run in preview.
- In the Sandbox,
integrate checks run without network. The runner installs dependencies
beforehand, only from a committed pnpm-lock.yaml and without install scripts; a check that
needs anything else from the network (or an install script's output) fails.
- The
/version health check is a single endpoint check (plan §C.1 row 11).
- Editions (Task 21) limits:
sourceReleaseId is the live release when the build was made (null before the first
publish); builds always start from the accepted heads.
- A return against a moved head is refused (
edition_stale); there is no automatic
reintegration of an old working copy yet. The client starts a new update after a rebuild.
- A rebuild replaces the edition repo's single commit, so an older session's ZIP download (and
its git
commit) may become unavailable (409 edition_unavailable).
- Edition repos and staged return ZIPs (
org/<org>/edition-returns/…) are not swept by GC yet;
a rebuild leaves the previous edition commit's objects stored (unreachable) in the edition repo.
- Task 17 scope limits:
pushed subscriptions are not created automatically (deploy step above); without them,
divergence is found only when a reconcile runs (each accept/restore and the GC backstop).
- The ledger does not emit
candidate-superseded events yet (Task 8 follow-up). The consumer
handles them, but other candidates on a resource are not marked superseded on accept; with the
stale-base policy "refuse" they can never be accepted anyway.
- The task view does not show a pending abandonment deadline yet (reviewer suggestion).
- Staged intake ZIPs (
org/<org>/intake/…) and old export bundles are not expired by GC.
- One cron run walks every org sequentially (P0 scale); per-org sharding is a later change.
- Tests run the module through
test/entry.ts, which acks the messages miniflare delivers from
its local queues (the deployed consumer would call the real Artifacts REST API); the consumers
are exercised through createWorker(deps) with the in-memory doubles.
- The REST transport retries a
403 whose envelope code is 10004 ("Access denied" to a valid
token, seen live for seconds at a time) twice (250 ms, 1 s) for every method, then throws
provider_unavailable, which the API answers as 503. Other 403s are not retried.
cforge credential-helper retries /credential on 503 / provider_unavailable 3 times
(1 s, 2 s, 4 s), so a short outage does not kill an agent's push.
- Queue steps treat a runner job that failed
provider_unavailable (for example Artifacts
answering git with "expected flush after ref listing") as transient and retry with a fresh job.
scripts/bootstrap.sh (Task 24) automates items 1–4, 6 and 7 for a named deployment (or --production)
and prints the manual Email Service steps (item 5); scripts/teardown.sh removes a named deployment.
- Queues:
wrangler queues create commonforge-steps
wrangler queues create commonforge-runner-jobs
wrangler queues create commonforge-outbox
wrangler queues create commonforge-outbox-dlq
wrangler queues create commonforge-artifacts-events
Artifacts event subscriptions (optional; wakeups only, the GC backstop covers their absence):
a repo-level pushed subscription per canonical repo, created through the REST API after the
repo exists (POST /accounts/:id/event_subscriptions/subscriptions with
source: {type: "artifacts.repo", namespace, repo_name}, destination the
commonforge-artifacts-events queue; wrangler 4.147 cannot set namespace/repo). Not automated yet.
- D1:
scripts/deploy-live.sh migrate applies every migration in migrations/ to the remote
database. The committed wrangler.jsonc holds a placeholder database_id, so a plain
wrangler d1 migrations apply fails; scripts/deploy-live.sh writes the real one
(COMMONFORGE_D1_DATABASE_ID in apps/worker/.dev.vars) into a git-ignored
wrangler.deploy-live.jsonc and runs
wrangler d1 migrations apply commonforge-dev --remote -c wrangler.deploy-live.jsonc.
- Secrets:
wrangler secret put for each of the following.
COMMONFORGE_SESSION_SECRET and COMMONFORGE_RUNNER_JOB_KEY: random, ≥ 32 bytes.
CF_ACCOUNT_ID.
CF_API_TOKEN: a dedicated token with Artifacts:Edit only, not the broad dev token.
- Optionally
COMMONFORGE_RUNNER_TOKEN, for pull-mode runners.
- Vars: set
COMMONFORGE_PUBLIC_URL to the deployed Worker's https URL; it has no default.
Set COMMONFORGE_APP_URL to the web app's origin. Keep COMMONFORGE_EMAIL_MODE=send.
- Email / DNS (owner): onboarding a domain for Email Sending publishes a
_dmarc record
(for example v=DMARC1; p=reject;) at the apex, plus the cf-bounce MX, SPF and DKIM records.
If that domain sends mail through any other provider (for example Google Workspace) without
SPF/DKIM alignment, that mail is then rejected. Confirm, or relax the policy (for example to
p=none) in DNS.
- Web app (served by this Worker):
wrangler.jsonc assets serves apps/web/dist from the Worker's own origin:
run_worker_first: ["/api/*"], everything else is a static asset with SPA fallback
(/auth/verify, /auth/cli and deep links load index.html). @commonforge-org/web is a
devDependency of the worker so turbo builds the app first.
- Set
COMMONFORGE_APP_URL to the Worker's own origin (for example
https://commonforge-worker.<account>.workers.dev): magic links point at it, and the CSRF
guard and CORS both key on it. The http://localhost:5173 default is for the Vite dev server,
which proxies /api to wrangler dev.
/auth/verify POSTs the token on a user click; it is never consumed on page load.
- Previews and releases (Task 22):
- Build the web app first (
pnpm build at the root; n-2), then deploy in this order:
commonforge-worker (wrangler deploy), commonforge-preview
(wrangler deploy -c wrangler.preview.jsonc) and commonforge-runner
(wrangler deploy -c wrangler.runner.jsonc --var COMMONFORGE_WORKER_URL:<worker url> --var COMMONFORGE_PREVIEW_URL:<preview url>).
The last two build the apps/runner image (Docker; WRANGLER_DOCKER_BIN may point at a
sudo -n docker wrapper). wrangler delete leaves container applications: also run
wrangler containers delete.
- Secrets:
COMMONFORGE_PREVIEW_KEY (random ≥ 32 bytes) on both commonforge-worker and
commonforge-preview; COMMONFORGE_DEPLOY_API_TOKEN on commonforge-worker (a token with
Workers Scripts: Edit on the deploy account, ideally dedicated).
- Vars:
COMMONFORGE_PREVIEW_URL on commonforge-worker.
- Project policy (owner):
preview and release recipes, e.g. installs with
pnpm install --frozen-lockfile --ignore-scripts (the fixtures ship standalone lockfiles; review m-5), builds with step env PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN=false,
the api bundle via pnpm exec wrangler deploy --dry-run --outdir dist, the preview site via
pnpm exec vite build --base ./ with VITE_QUOTE_API_URL=./api; production bindings (the demo
ORDERS KV, created with POST …/deploy-resources) in release.environment; targets named
cfapp-<orgId>-<name>.
- Vars
COMMONFORGE_DEPLOY_WORKER_PREFIX (same value on commonforge-worker and
commonforge-runner; default cfapp) and optionally COMMONFORGE_DEPLOY_ACCOUNT_ID.
- Keep
COMMONFORGE_RUNNER_TOKEN unset on the deployed control plane (pull mode off). Even
with it set, pull-mode runners never receive release-deploy jobs unless
COMMONFORGE_RUNNER_DEPLOY=1 (review m-4).
- Tokens (owner):
CF_API_TOKEN should be a dedicated token with Account › Artifacts: Edit;
COMMONFORGE_DEPLOY_API_TOKEN a dedicated token with Workers Scripts: Edit and Workers KV
Storage: Edit (it creates the org KV namespaces) on the deploy account. Install each with
cd apps/worker && pnpm exec wrangler secret put <NAME>, then roll the broad dev token.
{ "recipe": { /* BuildRecipe; optional, defaults to release.recipe (use it to point the website at <api>-staging) */ }, "environment": { // by release targetId "api": { "vars": { "APP_VERSION": "1.0.0-staging" }, "kv": [{ "binding": "ORDERS", "seed": "seed/orders.json" }], // ≤ 4 "d1": [{ "binding": "DB", "migrationsDir": "migrations", "seed": ["seed/contacts.sql"] }] // ≤ 4, seed ≤ 20 } }, "captureVars": ["PAYMENT_WEBHOOK_URL", "EMAIL_WEBHOOK_URL"], // ≤ 8; set to the sink URL, never also in `vars` "promotion": { "rules": [ /* path rules → self | classify | developer; used from Phase 2 */ ], "classifier": { "provider": "jev" | "openai-decisions" | "none", "threshold": 0.9 } } }