This is the single ordered sequence for completion plan Phase 1 (accounts, environments, secrets) and Phase 2 (first staging deployment). It exists because those values are otherwise spread across five runbooks that each describe one slice, and a single wrong secret name burns a reviewer-gated workflow run and can leave half-created cloud resources.
Nothing in this repository has ever been deployed, published, or provisioned. Every command below is written to be run for the first time. The runbooks remain authoritative for why each control exists; this document is authoritative for order and for the exact names:
- Protected first-time staging bootstrap
- Protected staging release and rollback
- Staging qualification
- Protected npm connector release
- Protected production promotion
- Notifications and background liveness
- Clerk and Convex authentication
Where a runbook and the workflow that consumes a value disagree, the workflow wins. The disagreements found while writing this document are listed in Appendix C, and this document already follows the workflow.
| Marker | Meaning |
|---|---|
| Decision | You must choose. The default recorded in the completion plan is given. |
| Verify | Run this before starting the next stage. A stage is not finished until its verification passes. |
| Recovery | What partial state this stage can leave and how to get out of it. |
Placeholders are written in capitals (REPLACE_WITH_..., <40-character-commit>) and are never
real values. Set these two shell variables once; every gh command below uses them:
REPO='spyhack225/agent-controller' # adjust if you forked or renamed
BRANCH='main' # the repository default branchDo not start Stage 1 until completion plan Phase 0 is done, in particular:
- the license is ratified (Apache-2.0 is in place at
LICENSEandpackages/connector/LICENSE); - every credential that was ever tracked has been rotated;
- the repository is public, with private vulnerability reporting on, branch protection on the
default branch requiring the
Hermetic CIchecks, and tag protection forconnector-v*.
Why public matters here: GitHub environment protection rules (required reviewers, deployment branch policies) are not available on private repositories outside paid plans. Every protected workflow in this repository depends on them.
Verify
npm test
npm run security:repo
npm run check:docs
npm run test:workflow
gh repo view "$REPO" --json visibility,defaultBranchRefCreate these before touching GitHub. Each row says what the later stages actually consume from it.
| # | Account | Plan / tier | Why | Produces |
|---|---|---|---|---|
| 1.1 | Cloudflare — staging | Workers Paid | Containers require the Workers Paid plan, and the private control plane runs as a Container. Workers Free cannot host this stack at all. | Account ID, API tokens, R2 access keys, R2 S3 endpoint, the deployed origin |
| 1.2 | Cloudflare — production | Workers Paid | Same reason. It must be a separate account, not a separate name inside the staging account. | Same set, production-scoped |
| 1.3 | Convex — staging deployment | Any plan that allows a deploy key | Durable control-plane state | CONVEX_URL, deploy key |
| 1.4 | Convex — production deployment | Same | Same | Same, production-scoped |
| 1.5 | Clerk — staging instance | Any | Platform user authentication; cloud mode disables dev tokens, so a real Clerk user is required from qualification level 2 onward | Secret key, publishable key, JWT issuer domain |
| 1.6 | Clerk — production instance | Any | Same | Same |
| 1.7 | npm — @agent-controller organisation |
Any | Owning the scope and registering @agent-controller/connector before the first trusted-publisher release |
Package ownership; no token is ever added to GitHub |
| 1.8 | GitHub | The repository itself | Environments, reviewers, protected dispatch | Four environments |
| 1.9 | A T3 host for staging | Your own machine | Qualification levels 2 and 3 need a live T3 Code with an authenticated provider, reachable only through its own connector | The enrolled connector, an environment id, a project id, a provider instance and model |
Account isolation is the real boundary; naming is not. The bootstrap procedure refuses known
production service, Queue, and derived bucket names, and every staging bucket name must match
^[a-z0-9][a-z0-9-]{2,62}-staging$ and must not contain production — but those are guardrails
against a typo, not a security boundary. A token scoped to one account cannot touch the other; a
token scoped to one account with careful naming can. Use two accounts.
Create three tokens, each restricted to a single account:
| Token | Account | Used by | Needs to be able to |
|---|---|---|---|
| staging bootstrap token | staging | staging-bootstrap environment |
Create Queues, create R2 buckets, deploy Workers and Containers, write Worker secrets, list Queues/buckets/deployments/versions/secret names |
| staging release token | staging | staging environment |
Deploy Workers and Containers, read Queues/R2/deployments/versions/secret names (it never creates a resource) |
| production token | production | production environment |
Deploy Workers and Containers, read Queues/R2/secret names |
Grant permission groups in the Cloudflare token screen that cover exactly the operations above; the
group names on that screen change over time, so match the operations rather than copying a name
from here. The scripts call Wrangler (queues create, r2 bucket create, deploy,
secret bulk, secret list, deployments status, versions list) and two read-only REST paths on
the account: /queues and /r2/buckets.
CLOUDFLARE_ACCOUNT_ID must be exactly 32 lowercase hexadecimal characters — the account ID as
Cloudflare prints it. Anything else fails with cloudflare_account_invalid.
Decision — the staging origin. cloudflare/wrangler.jsonc declares no routes and does not
disable workers.dev for the staging environment, so unless you attach a custom domain out of
band the deployed origin is the account's workers.dev URL for the Worker named
agent-controller-cloud-staging. You must know the exact final origin before bootstrap, because
it is both a variable and a Container secret and the two are compared for exact equality. Whatever
you choose, it must be HTTPS, must be an origin with no path and no trailing slash, and must not be
localhost. The completion plan records no default; the repository default behaviour is
workers.dev.
In the staging Cloudflare account, create an R2 API token and record its access key id, secret access key, and the S3 API endpoint R2 shows for your account. Copy the endpoint exactly from the dashboard rather than assembling it. Do not pre-create the buckets: bootstrap creates them and refuses a bucket that already exists.
Verify (Stage 1)
# Cloudflare: the account id is 32 lowercase hex characters
printf '%s' "$CLOUDFLARE_STAGING_ACCOUNT_ID" | grep -Eq '^[0-9a-f]{32}$' && echo 'account id shape ok'Confirm in each provider's own console that: the Cloudflare staging account shows the Workers Paid
plan; the Convex staging deployment exists and you can see its URL; the Clerk staging instance shows
both keys and its issuer domain; the @agent-controller npm scope is owned by you.
Recovery (Stage 1). Nothing is coupled yet. Delete the account, project, or token and start again. If a token leaked, roll it in the provider console before continuing.
Four values are generated on your machine, not by a provider. Generate them once, store them in your password manager, and paste them into GitHub in Stage 4. Generate staging and production sets separately; they must never be the same value.
Run these from a clean checkout of this repository with npm ci already done (the VAPID command
uses the repository's pinned web-push dependency).
src/secretBox.mjs derives an AES-256-GCM key by SHA-256 of whatever string you supply, so any
high-entropy string works. Use 48 random bytes:
node -e "console.log(require('node:crypto').randomBytes(48).toString('base64url'))"
# or: openssl rand -base64 48 | tr -d '\n'; echoSame shape. This one value is entered twice — once as a Cloudflare Worker secret and once as a Convex environment variable — and the two must be identical. Neither provider will show you the stored value again, so your provisioning record is the only evidence they match.
node -e "console.log(require('node:crypto').randomBytes(48).toString('base64url'))"Managed staging and production use the rotation-aware form: a JSON array of at most three
records, each with keyId, publicKey, privateKey, subject, and exactly one entry with
active: true (see notifications.md). It is not a single key, and the
four single-key WEB_PUSH_VAPID_* variables are not used here.
node -e "
const { generateVAPIDKeys } = require('web-push');
const keys = generateVAPIDKeys();
console.log(JSON.stringify([{
keyId: 'staging-REPLACE_WITH_YYYY-MM',
publicKey: keys.publicKey,
privateKey: keys.privateKey,
subject: 'mailto:REPLACE_WITH_OPS_CONTACT@example.com',
active: true,
}]));
" > vapid-staging.jsonsubject must be a mailto: or https: contact you control. Keep vapid-staging.json out of the
repository — write it to a scratch directory, upload it in Stage 4, then delete it. The server
validates the pair (public key must decode to a 65-byte uncompressed P-256 point, private key to 32
bytes) and refuses to send anything if the set is invalid.
When you later rotate, append the new record with active: true, set the old one to active: false,
and keep it until existing browser subscriptions have moved; each subscription records the key id it
used.
A separate high-entropy string. It must be set explicitly: the Container's startup check fails when it is absent even though the code would otherwise fall back to another key.
node -e "console.log(require('node:crypto').randomBytes(48).toString('base64url'))"Verify (Stage 2). Check the VAPID set is one the server will accept, without sending anything:
WEB_PUSH_VAPID_KEYS="$(cat vapid-staging.json)" node -e "
import('./src/webPush.mjs').then((m) => {
const config = m.loadWebPushConfig(process.env);
console.log(config.supported === true ? 'vapid set accepted, active key: ' + config.activeKeyId
: 'REJECTED: ' + config.reason);
});
"Recovery (Stage 2). These values have no external state. If one leaks before deployment, throw
it away and generate another. After deployment, replacing GATEWAY_CONVEX_SECRET means changing it
in Cloudflare and Convex together, and replacing T3_TOKEN_ENCRYPTION_KEY invalidates every stored
T3 access token, which forces re-pairing.
Assemble this table before creating any GitHub environment. Every value has exactly one purpose.
| Value | Where it comes from | Notes |
|---|---|---|
| Staging Cloudflare account ID | Cloudflare dashboard | 32 lowercase hex |
| Staging bootstrap API token | Stage 1 | Never production-scoped |
| Staging release API token | Stage 1 | Deploy plus reads only |
| Convex staging deploy key | Convex project settings | Names only the staging deployment |
CONVEX_URL |
Convex deployment URL | Must start with https:// |
| Clerk staging secret key | Clerk dashboard | |
| Clerk staging publishable key | Clerk dashboard | Must match the same instance |
| Clerk JWT issuer domain | Clerk dashboard | Needed by Convex, see Stage 5 |
| R2 S3 endpoint | Cloudflare R2 dashboard | Must start with https:// |
| R2 access key id / secret | Stage 1 | Staging-only |
| Media bucket name | Your choice | Must match ^[a-z0-9][a-z0-9-]{2,62}-staging$, must not contain production |
| Firmware bucket name | Your choice | Same rule, and must differ from the media bucket |
| Staging origin | Your decision in Stage 1 | HTTPS origin, no path, no trailing slash, not localhost |
| The four Stage 2 secrets | Generated locally |
Three equalities are checked by the bootstrap script and will fail the run if they are wrong:
STAGING_RUNTIME_PUBLIC_BASE_URLmust equalAGENT_CONTROLLER_STAGING_URLexactly;STAGING_RUNTIME_S3_BUCKETmust equalAGENT_CONTROLLER_STAGING_MEDIA_BUCKETexactly;STAGING_RUNTIME_FIRMWARE_S3_BUCKETmust equalAGENT_CONTROLLER_STAGING_FIRMWARE_BUCKETexactly.
Four environments. They are not interchangeable, and the same underlying value appears under
different names in staging-bootstrap and staging — that is the single most common way to
burn a protected run, so the tables below name the environment for every secret.
| Setting | staging-bootstrap | staging | npm-release | production |
|---|---|---|---|---|
| Required reviewers | you | you | you | a second person, not you |
| Prevent self-review | off | off | off | on |
| Deployment branches | default branch only | default branch only | default branch only | default branch only |
| Administrators can bypass | off | off | off | off |
| Environment secrets | see 4.1 | see 4.2 | none | see 4.4 |
Decision — who reviews. Two facts about GitHub environments decide this, and an earlier revision of this runbook had both wrong.
Self-review is allowed unless you switch it off. "Prevent self-review" is optional and defaults to off. With it off, you can be the sole required reviewer and approve your own dispatch. The gate still does its real job: the run halts, tells you what it is about to mutate, and waits for a deliberate click. What it cannot do is make a second person look. A solo maintainer is therefore not blocked from running any protected workflow.
Listing two reviewers does not require two approvals. GitHub proceeds when one of the listed reviewers approves. Two-person control cannot be built by adding names to the list; the only way to guarantee someone other than the dispatcher approves is to switch prevent-self-review on and list only people who are not the dispatcher.
The settings above follow from that. Staging and npm-release are solo-operable now, because staging is isolated and disposable and the npm package is yours. Production keeps prevent-self-review on and must list someone who is not you, so the one irreversible boundary in this system keeps a genuine second pair of eyes. Until that person exists, Phases 1 through 6 of the completion plan run solo and only production promotion waits.
Create an environment (repeat for each of the four names):
REPO="spyhack225/agent-controller"
ME_ID="$(gh api user --jq .id)"
# Staging and npm-release: you are the reviewer, and you may approve your own dispatch.
for ENVIRONMENT in staging-bootstrap staging npm-release; do
gh api --method PUT "repos/$REPO/environments/$ENVIRONMENT" --input - <<JSON
{
"wait_timer": 0,
"prevent_self_review": false,
"can_admins_bypass": false,
"reviewers": [{ "type": "User", "id": $ME_ID }],
"deployment_branch_policy": { "protected_branches": true, "custom_branch_policies": false }
}
JSON
doneProduction is deliberately different, and is the one environment you cannot create alone. Its reviewer must be someone other than you, because with prevent-self-review on your own approval is refused and the run would sit in the queue forever:
REVIEWER_ID="$(gh api users/REPLACE_WITH_SECOND_REVIEWER_LOGIN --jq .id)"
gh api --method PUT "repos/$REPO/environments/production" --input - <<JSON
{
"wait_timer": 0,
"prevent_self_review": true,
"can_admins_bypass": false,
"reviewers": [{ "type": "User", "id": $REVIEWER_ID }],
"deployment_branch_policy": { "protected_branches": true, "custom_branch_policies": false }
}
JSONAdd further production reviewers by repeating that call with more entries in the reviewers array;
remember that any one of them approving is enough. If your GitHub API version rejects can_admins_bypass, remove that field and
untick Allow administrators to bypass configured protection rules in the environment settings UI
instead — do not leave it on.
Already done on 2026-09-09. staging-bootstrap, staging and npm-release exist on
spyhack225/agent-controller with the settings above: one required reviewer (the repository owner),
prevent-self-review off, default-branch-only deployments, and no administrator bypass. Verify with
gh api repos/spyhack225/agent-controller/environments --jq '.environments[].name'. Their secrets
are not set, which is the remaining work in 4.1 through 4.3. production is deliberately absent
until a second reviewer exists.
Consumed by .github/workflows/staging-bootstrap.yml, job bootstrap.
| Secret | Value |
|---|---|
CLOUDFLARE_BOOTSTRAP_API_TOKEN |
Staging bootstrap token |
CLOUDFLARE_STAGING_ACCOUNT_ID |
Staging account ID |
CONVEX_STAGING_DEPLOY_KEY |
Staging Convex deploy key |
STAGING_RUNTIME_PUBLIC_BASE_URL |
The staging origin (equal to AGENT_CONTROLLER_STAGING_URL) |
STAGING_RUNTIME_CONVEX_URL |
Convex deployment URL |
STAGING_RUNTIME_GATEWAY_CONVEX_SECRET |
Stage 2.2 value |
STAGING_RUNTIME_CLERK_SECRET_KEY |
Clerk staging secret key |
STAGING_RUNTIME_CLERK_PUBLISHABLE_KEY |
Clerk staging publishable key |
STAGING_RUNTIME_T3_TOKEN_ENCRYPTION_KEY |
Stage 2.1 value |
STAGING_RUNTIME_S3_ENDPOINT |
R2 S3 endpoint |
STAGING_RUNTIME_S3_BUCKET |
Media bucket name |
STAGING_RUNTIME_FIRMWARE_S3_BUCKET |
Firmware bucket name |
STAGING_RUNTIME_S3_ACCESS_KEY_ID |
R2 access key id |
STAGING_RUNTIME_S3_SECRET_ACCESS_KEY |
R2 secret access key |
STAGING_RUNTIME_WEB_PUSH_VAPID_KEYS |
Stage 2.3 JSON array |
STAGING_RUNTIME_WEB_PUSH_STORAGE_ENCRYPTION_KEY |
Stage 2.4 value |
Each STAGING_RUNTIME_<NAME> secret becomes the Container Worker secret <NAME>. The thirteen
runtime names are exactly PUBLIC_BASE_URL, CONVEX_URL, GATEWAY_CONVEX_SECRET,
CLERK_SECRET_KEY, CLERK_PUBLISHABLE_KEY, T3_TOKEN_ENCRYPTION_KEY, S3_ENDPOINT, S3_BUCKET,
FIRMWARE_S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, WEB_PUSH_VAPID_KEYS, and
WEB_PUSH_STORAGE_ENCRYPTION_KEY.
E=staging-bootstrap
gh secret set CLOUDFLARE_BOOTSTRAP_API_TOKEN --repo "$REPO" --env "$E" --body 'REPLACE_WITH_STAGING_BOOTSTRAP_TOKEN'
gh secret set CLOUDFLARE_STAGING_ACCOUNT_ID --repo "$REPO" --env "$E" --body 'REPLACE_WITH_32_HEX_ACCOUNT_ID'
gh secret set CONVEX_STAGING_DEPLOY_KEY --repo "$REPO" --env "$E" --body 'REPLACE_WITH_CONVEX_STAGING_DEPLOY_KEY'
gh secret set STAGING_RUNTIME_PUBLIC_BASE_URL --repo "$REPO" --env "$E" --body 'https://REPLACE_WITH_STAGING_ORIGIN'
gh secret set STAGING_RUNTIME_CONVEX_URL --repo "$REPO" --env "$E" --body 'https://REPLACE_WITH_CONVEX_DEPLOYMENT.convex.cloud'
gh secret set STAGING_RUNTIME_GATEWAY_CONVEX_SECRET --repo "$REPO" --env "$E" --body 'REPLACE_WITH_STAGE_2_2_VALUE'
gh secret set STAGING_RUNTIME_CLERK_SECRET_KEY --repo "$REPO" --env "$E" --body 'REPLACE_WITH_CLERK_STAGING_SECRET_KEY'
gh secret set STAGING_RUNTIME_CLERK_PUBLISHABLE_KEY --repo "$REPO" --env "$E" --body 'REPLACE_WITH_CLERK_STAGING_PUBLISHABLE_KEY'
gh secret set STAGING_RUNTIME_T3_TOKEN_ENCRYPTION_KEY --repo "$REPO" --env "$E" --body 'REPLACE_WITH_STAGE_2_1_VALUE'
gh secret set STAGING_RUNTIME_S3_ENDPOINT --repo "$REPO" --env "$E" --body 'https://REPLACE_WITH_R2_S3_ENDPOINT'
gh secret set STAGING_RUNTIME_S3_BUCKET --repo "$REPO" --env "$E" --body 'REPLACE_WITH_MEDIA_BUCKET-staging'
gh secret set STAGING_RUNTIME_FIRMWARE_S3_BUCKET --repo "$REPO" --env "$E" --body 'REPLACE_WITH_FIRMWARE_BUCKET-staging'
gh secret set STAGING_RUNTIME_S3_ACCESS_KEY_ID --repo "$REPO" --env "$E" --body 'REPLACE_WITH_R2_ACCESS_KEY_ID'
gh secret set STAGING_RUNTIME_S3_SECRET_ACCESS_KEY --repo "$REPO" --env "$E" --body 'REPLACE_WITH_R2_SECRET_ACCESS_KEY'
gh secret set STAGING_RUNTIME_WEB_PUSH_STORAGE_ENCRYPTION_KEY --repo "$REPO" --env "$E" --body 'REPLACE_WITH_STAGE_2_4_VALUE'
# read the multi-line JSON from the file instead of the command line
gh secret set STAGING_RUNTIME_WEB_PUSH_VAPID_KEYS --repo "$REPO" --env "$E" < vapid-staging.json
rm -f vapid-staging.jsonConsumed by .github/workflows/staging-release.yml, job release. Same values as three of the
bootstrap secrets, under different names. No runtime secrets: the release workflow only reads
Worker secret names from Cloudflare, never values.
| Secret | Value |
|---|---|
CLOUDFLARE_API_TOKEN |
Staging release token (deploy plus reads) |
CLOUDFLARE_ACCOUNT_ID |
Same staging account ID as CLOUDFLARE_STAGING_ACCOUNT_ID |
CONVEX_DEPLOY_KEY |
Same key as CONVEX_STAGING_DEPLOY_KEY |
E=staging
gh secret set CLOUDFLARE_API_TOKEN --repo "$REPO" --env "$E" --body 'REPLACE_WITH_STAGING_RELEASE_TOKEN'
gh secret set CLOUDFLARE_ACCOUNT_ID --repo "$REPO" --env "$E" --body 'REPLACE_WITH_32_HEX_ACCOUNT_ID'
gh secret set CONVEX_DEPLOY_KEY --repo "$REPO" --env "$E" --body 'REPLACE_WITH_CONVEX_STAGING_DEPLOY_KEY'No secrets and no variables. Publication uses npm trusted publishing over OIDC, and the workflow
never reads an npm token. In npm package settings, configure a GitHub Actions trusted publisher for
this repository, workflow filename npm-connector-release.yml, environment npm-release, and the
publish action. Do not add NPM_TOKEN or NODE_AUTH_TOKEN anywhere. After trusted publishing works,
disable token publishing for the package and revoke old automation tokens.
The actual publication is completion plan Phase 3 and is out of scope here; only the environment is created now, because it belongs with the rest of the environment configuration.
Consumed by .github/workflows/production-promotion.yml (four jobs, same environment, approved
separately). Nothing here is used by Phase 2, but configure it now while you have the values.
| Secret | Value |
|---|---|
CLOUDFLARE_PRODUCTION_API_TOKEN |
Production account token |
CLOUDFLARE_PRODUCTION_ACCOUNT_ID |
Production account ID |
CONVEX_PRODUCTION_DEPLOY_KEY |
Production Convex deploy key |
E=production
gh secret set CLOUDFLARE_PRODUCTION_API_TOKEN --repo "$REPO" --env "$E" --body 'REPLACE_WITH_PRODUCTION_TOKEN'
gh secret set CLOUDFLARE_PRODUCTION_ACCOUNT_ID --repo "$REPO" --env "$E" --body 'REPLACE_WITH_PRODUCTION_32_HEX_ACCOUNT_ID'
gh secret set CONVEX_PRODUCTION_DEPLOY_KEY --repo "$REPO" --env "$E" --body 'REPLACE_WITH_CONVEX_PRODUCTION_DEPLOY_KEY'Six non-secret variables. Set them as repository variables rather than environment variables:
the production promotion workflow's first job (verify-evidence) reads four of them and runs
without entering any environment, so environment-only definitions make that job fail with
staging_url_invalid before a reviewer ever sees it. Repository variables are visible to the
environment jobs as well, and an environment-level definition still overrides one if you later want
that.
| Variable | Value | Read by |
|---|---|---|
AGENT_CONTROLLER_STAGING_URL |
Staging origin | bootstrap, release, promotion |
AGENT_CONTROLLER_STAGING_MEDIA_BUCKET |
Media bucket name | bootstrap, release |
AGENT_CONTROLLER_STAGING_FIRMWARE_BUCKET |
Firmware bucket name | bootstrap, release |
AGENT_CONTROLLER_PRODUCTION_URL |
Production origin | promotion |
AGENT_CONTROLLER_PRODUCTION_MEDIA_BUCKET |
Production media bucket name | promotion |
AGENT_CONTROLLER_PRODUCTION_FIRMWARE_BUCKET |
Production firmware bucket name | promotion |
gh variable set AGENT_CONTROLLER_STAGING_URL --repo "$REPO" --body 'https://REPLACE_WITH_STAGING_ORIGIN'
gh variable set AGENT_CONTROLLER_STAGING_MEDIA_BUCKET --repo "$REPO" --body 'REPLACE_WITH_MEDIA_BUCKET-staging'
gh variable set AGENT_CONTROLLER_STAGING_FIRMWARE_BUCKET --repo "$REPO" --body 'REPLACE_WITH_FIRMWARE_BUCKET-staging'
gh variable set AGENT_CONTROLLER_PRODUCTION_URL --repo "$REPO" --body 'https://REPLACE_WITH_PRODUCTION_ORIGIN'
gh variable set AGENT_CONTROLLER_PRODUCTION_MEDIA_BUCKET --repo "$REPO" --body 'REPLACE_WITH_MEDIA_BUCKET-production'
gh variable set AGENT_CONTROLLER_PRODUCTION_FIRMWARE_BUCKET --repo "$REPO" --body 'REPLACE_WITH_FIRMWARE_BUCKET-production'Verify (Stage 4)
gh api "repos/$REPO/environments" --jq '.environments[].name'
for E in staging-bootstrap staging npm-release production; do
echo "== $E"
gh api "repos/$REPO/environments/$E" \
--jq '{protected_branches: .deployment_branch_policy.protected_branches, can_admins_bypass, rules: [.protection_rules[].type]}'
gh secret list --repo "$REPO" --env "$E"
done
gh variable list --repo "$REPO"Check the counts against the tables: 16 secrets in staging-bootstrap, 3 in staging, 0 in
npm-release, 3 in production, and 6 repository variables. gh never shows a secret value, so
compare names only — the values are checked for the first time by the bootstrap run itself.
Recovery (Stage 4). Secrets and variables are overwritten by setting them again. A secret set to
a wrong value is invisible until a run fails; when a bootstrap run fails on a credential, assume the
value is wrong, re-set it, and use resume (Stage 6) rather than a fresh bootstrap.
The bootstrap workflow sets exactly one Convex environment variable: GATEWAY_CONVEX_SECRET. But
convex/auth.config.ts reads process.env.CLERK_JWT_ISSUER_DOMAIN, which Convex evaluates when
functions are pushed. If that variable is missing, the bootstrap's deploy-convex action fails
partway through an otherwise-successful bootstrap.
Set it yourself, once, against the staging deployment, before dispatching bootstrap:
CONVEX_DEPLOY_KEY='REPLACE_WITH_CONVEX_STAGING_DEPLOY_KEY' \
npx convex env set CLERK_JWT_ISSUER_DOMAIN 'https://REPLACE_WITH_CLERK_ISSUER.clerk.accounts.dev'Use the issuer domain your Clerk staging instance actually shows. Do not set
GATEWAY_CONVEX_SECRET by hand — the bootstrap provisions it over standard input, and a value you
type here that differs from the GitHub secret produces a stack that authenticates nothing.
Verify (Stage 5)
CONVEX_DEPLOY_KEY='REPLACE_WITH_CONVEX_STAGING_DEPLOY_KEY' npx convex env list --names-only
# expect CLERK_JWT_ISSUER_DOMAIN, and no GATEWAY_CONVEX_SECRET yetRecovery (Stage 5). npx convex env set is idempotent; run it again with the corrected value.
Full detail: staging-bootstrap.md.
| Input | Rule |
|---|---|
operation |
bootstrap for a fresh empty account |
target_commit_sha |
Exact 40-character lowercase commit, already on the default branch |
bootstrap_id |
Your stable operator ticket, 8 to 64 characters, ^[A-Za-z0-9][A-Za-z0-9_-]{7,63}$. Record it outside logs and artifacts — resume and abort_cleanup need the same value |
current_edge_version |
Empty for a fresh bootstrap |
current_control_plane_version |
Empty for a fresh bootstrap |
confirmation |
bootstrap:staging:<target_commit_sha>:<bootstrap_id> — byte-exact, no spaces |
TARGET_SHA='REPLACE_WITH_40_CHARACTER_COMMIT_SHA'
BOOTSTRAP_ID='stg-bootstrap-001'
gh workflow run staging-bootstrap.yml --repo "$REPO" --ref "$BRANCH" \
-f operation=bootstrap \
-f target_commit_sha="$TARGET_SHA" \
-f bootstrap_id="$BOOTSTRAP_ID" \
-f current_edge_version= \
-f current_control_plane_version= \
-f confirmation="bootstrap:staging:${TARGET_SHA}:${BOOTSTRAP_ID}"The first job runs credential-free and proves the checkout, branch, confirmation, topology, and the repository gates. The second job waits for your reviewer, then performs the whole ordered mutation: three Queues, two R2 buckets, the fail-closed edge stub, the private control plane with the Container rollout disabled, the Worker secrets, the Convex secret and deploy, the final private Worker with an immediate Container rollout, the final edge Worker and Static Assets, and finally the credential-free boundary qualification.
gh run list --repo "$REPO" --workflow staging-bootstrap.yml --limit 1
RUN_ID='REPLACE_WITH_RUN_ID'
gh run download "$RUN_ID" --repo "$REPO" -n "staging-bootstrap-bootstrap-${TARGET_SHA}" -D ./evidence
jq '{schema, result, phases: [.phases[].name]}' ./evidence/staging-bootstrap-evidence.jsonThe artifact schema is agent-controller.staging-bootstrap.v1 and its result must be passed.
Then confirm the origin answers, and record the two Worker version IDs you will need in Stage 7:
curl -fsS 'https://REPLACE_WITH_STAGING_ORIGIN/health' | jq '.'Every binding must report ready. The evidence artifact deliberately excludes raw Worker version
IDs (it stores hashed references), so read the two active 100%-traffic version IDs from the
Cloudflare dashboard — Workers → agent-controller-cloud-staging and
agent-controller-control-plane-staging → Deployments — or with a read-scoped token locally:
npm --prefix cloudflare exec wrangler -- deployments status --env staging --json
npm --prefix cloudflare-control-plane exec wrangler -- deployments status --env staging --jsonRecord both, plus the deployed source commit. Stage 7 cannot be dispatched without them.
There is no automatic rollback. Preserve the evidence artifact, look at the provider state, then:
- Something failed after resources existed — dispatch
resumewith the sametarget_commit_shaandbootstrap_id, plus the exact currently active edge and control-plane version IDs. Confirmation:resume:staging:<target_commit_sha>:<bootstrap_id>. Resume skips completed stages and fails closed if an active version changed, an annotation differs, or provider inventory is ambiguous. - Only the fail-closed edge stub exists and no private control-plane Worker was ever created —
dispatch
abort_cleanupwith the stub's exact active edge version and the same commit and bootstrap ID. Confirmation:abort_cleanup:staging:<target_commit_sha>:<bootstrap_id>. It deletes only the owned stub and the three named Queues and two named buckets it finds. - The private control plane already exists and you want to start over — that is outside this workflow. Durable Object, Convex, and Container state may already be durable, so it needs a separately reviewed incident plan.
Never start a new bootstrap_id over a partial stack.
Full detail: staging-release.md.
The release workflow refuses target_commit_sha == current_commit_sha. So you cannot "release the
commit you just bootstrapped". The two evidence artifacts the completion plan wants — one deploy
and one rollback — come from this sequence:
- Bootstrap deployed commit A (Stage 6).
- Land a trivial follow-on commit B on the default branch and dispatch
deploywithtarget=B,current=A. - Dispatch
rollbackwithtarget=A,current=B.
Rollback is rejected when the two commits differ in the whole convex/ tree, in either Durable
Object migration history, or in staging Durable Object / Service Binding / Queue / Cron / Container
topology. So commit B must not touch convex/ or either wrangler.jsonc topology — a
documentation or console-only change is the right shape.
Record, without copying tokens or resource identifiers into the ticket: both commits, both active
100%-traffic Worker version IDs (Stage 6.3), the passing Hermetic CI run for the candidate, the
reviewed Convex dry-run, and a current Convex backup if the release changes durable schema or data
interpretation.
CURRENT_SHA="$TARGET_SHA" # commit A, currently deployed
TARGET_SHA_B='REPLACE_WITH_40_CHARACTER_COMMIT_B'
EDGE_VERSION='REPLACE_WITH_ACTIVE_EDGE_VERSION_ID'
CONTROL_VERSION='REPLACE_WITH_ACTIVE_CONTROL_PLANE_VERSION_ID'
gh workflow run staging-release.yml --repo "$REPO" --ref "$BRANCH" \
-f operation=deploy \
-f target_commit_sha="$TARGET_SHA_B" \
-f current_commit_sha="$CURRENT_SHA" \
-f current_edge_version="$EDGE_VERSION" \
-f current_control_plane_version="$CONTROL_VERSION" \
-f confirmation="deploy:staging:${TARGET_SHA_B}"The confirmation for this workflow has three parts, not four: deploy:staging:<target>. There is
no bootstrap ID in it.
gh run download REPLACE_WITH_RUN_ID --repo "$REPO" \
-n "staging-release-deploy-${TARGET_SHA_B}" -D ./evidence
jq '{schema, result}' ./evidence/staging-release-evidence.json
curl -fsS 'https://REPLACE_WITH_STAGING_ORIGIN/health' | jq '.'Schema agent-controller.staging-release.v1, result passed. Re-read both active version IDs from
Cloudflare; they changed, and the rollback dispatch needs the new ones.
gh workflow run staging-release.yml --repo "$REPO" --ref "$BRANCH" \
-f operation=rollback \
-f target_commit_sha="$CURRENT_SHA" \
-f current_commit_sha="$TARGET_SHA_B" \
-f current_edge_version='REPLACE_WITH_NEW_ACTIVE_EDGE_VERSION_ID' \
-f current_control_plane_version='REPLACE_WITH_NEW_ACTIVE_CONTROL_PLANE_VERSION_ID' \
-f confirmation="rollback:staging:${CURRENT_SHA}"Keep both artifacts: staging-release-deploy-<B> and staging-release-rollback-<A>.
No automatic rollback runs after a failed release, on purpose — an automatic reversal could cross a
Durable Object or Convex compatibility boundary without anyone seeing which mutation completed. Read
the redacted artifact for the last observed component versions, decide explicitly, and dispatch
rollback to a compatible older ancestor. If the compatibility rule rejects it, you need an
incident-specific plan: Convex data restore, environment-variable restore, and code restore are three
separate operations, and Cloudflare may refuse old Worker versions after a Durable Object change.
Full detail: staging-qualification.md. All three run from your machine against the deployed origin. Each mode refuses to start until the weaker ones pass.
npm run qualify:staging -- --base-url 'https://REPLACE_WITH_STAGING_ORIGIN' \
> staging-boundary-evidence.jsonProves /health reports the private control-plane binding, connector event sink, background Queue,
quarantine Queue and Queue-owned scheduling as ready; that public /v1/auth/config traverses the
Service Binding and Container, reports cloud mode, and confirms development-token issuance is off;
and that the three /v1/internal/* capabilities plus the local connector-router bridge and
development ticket issuer return exactly 404. A 401, 403, redirect, or success where a 404
is required is a failure, not a partial pass.
Verify: the command exits zero and the JSON carries schema
agent-controller.staging-qualification.v1. Keep the file with the release record.
First, get the staging stack a live T3 environment:
-
Sign in to the deployed console as a real Clerk staging user.
-
Follow onboarding to mint a single-use connector enrollment code (
POST /v1/t3/connect-sessionsis what the console calls). -
On the Phase 1.9 T3 host, run the connector against staging. The package is not published until completion plan Phase 3, so at this point run it from a checkout on that host:
node packages/connector/bin/agent-controller-connect.mjs connect \ --server 'https://REPLACE_WITH_STAGING_ORIGIN' \ --code 'REPLACE_WITH_ONE_TIME_ENROLLMENT_CODE' \ --install-service
This is checkout evidence, not the install-free
npxjourney; that proof belongs to Phase 3. -
Read the environment id and project id from the console, or from
GET /v1/t3/environmentswith the same session token, and note the provider instance and model the environment actually offers.
Then run level 2. The token goes in an environment variable or a mode-0600 file — there is no
command-line token option:
export AGENT_CONTROLLER_STAGING_ACCESS_TOKEN='REPLACE_WITH_SHORT_LIVED_PLATFORM_SESSION_TOKEN'
npm run qualify:staging -- \
--base-url 'https://REPLACE_WITH_STAGING_ORIGIN' \
--environment-id 'REPLACE_WITH_ENVIRONMENT_ID' \
--project-id 'REPLACE_WITH_PROJECT_ID' \
--provider-instance 'REPLACE_WITH_PROVIDER_INSTANCE' \
--model 'REPLACE_WITH_MODEL' \
> staging-readiness-evidence.json
unset AGENT_CONTROLLER_STAGING_ACCESS_TOKENIn cloud mode there is no platform API token to mint — POST /v1/users/dev is disabled, and level 1
proves it. The token is a Clerk session token for the signed-in staging user; the console obtains
one with session.getToken(). Clerk session tokens are short-lived, so mint one immediately before
the run, and if a run fails with an authentication error partway through, mint a fresh one and run
again.
Verify: all five layers green — dev tokens disabled, environment connector-backed, connector
online on protocol v1 with fresh presence and fresh ready T3 health, a fresh snapshot traversing
cloud → connector → local T3, and the named project with a currently usable provider and model.
This mode is mutating: it creates one new thread in the named project and sends one prompt. Use an isolated test project.
export AGENT_CONTROLLER_STAGING_ACCESS_TOKEN='REPLACE_WITH_FRESH_SESSION_TOKEN'
export AGENT_CONTROLLER_STAGING_PROMPT='Staging qualification: reply with a short readiness confirmation.'
npm run qualify:staging -- \
--base-url 'https://REPLACE_WITH_STAGING_ORIGIN' \
--environment-id 'REPLACE_WITH_ENVIRONMENT_ID' \
--project-id 'REPLACE_WITH_PROJECT_ID' \
--provider-instance 'REPLACE_WITH_PROVIDER_INSTANCE' \
--model 'REPLACE_WITH_MODEL' \
--exercise-first-command \
> staging-first-command-evidence.json
unset AGENT_CONTROLLER_STAGING_ACCESS_TOKEN AGENT_CONTROLLER_STAGING_PROMPTVerify: the launch response is dispatched and the authenticated command ledger reaches
completed on the same command, retaining the exact environment, thread, project, provider, model
and thread.launch contract. An acknowledgement alone is not a pass, and the bounded three-minute
timeout is a failure.
Recovery (Stage 8). Levels 1 and 2 mutate nothing; rerun them. Level 3 leaves a real thread behind on purpose — the command never archives or deletes it, because cleanup is a separate potentially destructive decision. Archive it from the console if you want the project clean. If level 3 fails, the created thread may still exist; check before running again so you do not accumulate threads.
A green Stage 6 to 8 proves: the named bootstrap orchestration completed, one routine deploy and one compatible rollback completed, the credential-free public/private boundary holds, a connector-backed environment is readable end to end, and one command completed with a live provider reply.
It does not prove hosted Queue retry or DLQ behaviour, R2 or Convex durability, Analytics Engine ingestion or dashboards, WebSocket streaming, approval or structured-input flows, sleep and WAN recovery, load, cost, browser GPU behaviour, or physical controller TLS. Those are completion plan Phases 4 to 6, and none of them may be inferred from a passing resource-name check.
| Thing | Rule |
|---|---|
| Commit SHA inputs | 40 lowercase hexadecimal characters |
| Worker version inputs | ^[0-9a-f-]{16,64}$ |
bootstrap_id |
^[A-Za-z0-9][A-Za-z0-9_-]{7,63}$ |
| Cloudflare account ID | ^[0-9a-f]{32}$ |
| Staging bucket names | ^[a-z0-9][a-z0-9-]{2,62}-staging$, must not contain production, and the two must differ |
| Staging origin | HTTPS, exact origin (no path, no trailing slash), not localhost |
| Bootstrap confirmation | bootstrap|resume|abort_cleanup + :staging: + target SHA + : + bootstrap id |
| Release confirmation | deploy|rollback + :staging: + target SHA |
| npm release confirmation | publish:@agent-controller/connector@X.Y.Z:connector-vX.Y.Z:<commit> |
| Production confirmation | promote:production:<target-commit>:<manifest-sha256> |
| Convex environment variables | GATEWAY_CONVEX_SECRET (set by bootstrap) and CLERK_JWT_ISSUER_DOMAIN (set by you, Stage 5) |
| Decision | Default | Needed by |
|---|---|---|
| License ratification | Apache-2.0, already in place | Stage 0 |
Staging origin: workers.dev or a custom domain |
The repository config attaches no route, so workers.dev is what a deploy produces |
Stage 1 |
| Second reviewer identity (production only; staging and npm-release are solo-operable) | None recorded — needed before Phase 7, not before Phase 2 | Stage 4 |
| npm package name and scope | @agent-controller/connector as committed |
Stage 4.3 |
| Which commit is the trivial follow-on B for the rollback proof | None recorded; it must not touch convex/ or Worker topology |
Stage 7 |
| Where Parakeet inference runs | Operator-run sidecar next to the Container | Phase 4, not this document |
| Container sizing: singleton versus partitioning | Singleton with the provisional 16-environment budget | Phase 4, not this document |
| Hardware scope for the beta | Hosyond only | Phase 6, not this document |
Recorded while cross-checking every name in this document against the workflow that reads it. The workflow is authoritative in all five cases, and this document already follows it.
- Production variables cannot be environment-only.
production-promotion.mdsays to configureAGENT_CONTROLLER_STAGING_URL,AGENT_CONTROLLER_PRODUCTION_URL,AGENT_CONTROLLER_PRODUCTION_MEDIA_BUCKETandAGENT_CONTROLLER_PRODUCTION_FIRMWARE_BUCKETasproductionenvironment variables. The workflow's first job,verify-evidence, reads all four and has noenvironment:block, andnormalizePromotionInputrequires all four to be non-empty and the two URLs to be HTTPS. Environment-only definitions fail that job before any reviewer is asked. Stage 4.5 therefore sets them at repository level. - Convex needs
CLERK_JWT_ISSUER_DOMAINand nothing provisions it.staging-bootstrap.mddescribes the Convex step as "provision the matching Convex gateway secret … then deploy Convex", and the script only ever setsGATEWAY_CONVEX_SECRET. Butconvex/auth.config.tsreadsprocess.env.CLERK_JWT_ISSUER_DOMAIN, which is evaluated on push. Stage 5 adds the missing manual step. - The completion plan's 2.2 wording cannot be dispatched literally. It says to dispatch staging
release "for the same commit".
normalizeReleaseInputrejectstarget_commit_sha == current_commit_shawithtarget_must_differ_from_current. Stage 7 gives the sequence that does produce onedeployand onerollbackartifact. - The same three values live under six different names.
staging-bootstrapusesCLOUDFLARE_BOOTSTRAP_API_TOKEN/CLOUDFLARE_STAGING_ACCOUNT_ID/CONVEX_STAGING_DEPLOY_KEY;stagingusesCLOUDFLARE_API_TOKEN/CLOUDFLARE_ACCOUNT_ID/CONVEX_DEPLOY_KEY. Both runbooks are individually correct and neither mentions the other. The account ID and the Convex deploy key are the same value twice; only the Cloudflare tokens legitimately differ, because bootstrap needs create authority and release does not. npm-connector-release.md's one-time setup steps 1 and 2 are already done. They say the maintainer must still choose a license, replace"license": "UNLICENSED", addLICENSEto thefilesallowlist, and supply repository metadata that "the current manifest has no authoritative repository URL" for.packages/connector/package.jsonnow carries"license": "Apache-2.0",LICENSEinfiles, bothLICENSEfiles exist, andrepository.urlnames the public repository withdirectory: packages/connector. Only steps 3 to 5 — the trusted publisher, thenpm-releaseenvironment, and disabling token publishing — remain.
Two smaller notes, neither a contradiction:
auth-storage.mdtells you to runnpx convex env set T3_TOKEN_ENCRYPTION_KEY …. No Convex function reads it —convex/reads onlyGATEWAY_CONVEX_SECRETandCLERK_JWT_ISSUER_DOMAIN— because in managed cloud mode the Container does the encrypting. That instruction belongs to the self-hosted deployment shape.- The bootstrap evidence artifact excludes raw Worker version IDs by design, and the staging release workflow requires them as inputs. They must be read from Cloudflare between Stage 6 and Stage 7; neither runbook says where the operator gets them.