Evidence: 3 file:line witnesses as of 2026-08-18. Confidence: partial, some rules witnessed, the rest inherited from the seams. What changes it: the first module built against this doc (
../PLAN.mdforge-1) and its instance breakdown.
How many environments a module has, what separates them, how a build gets to
each, and how anyone can tell what is running. Deployment mechanics on GCP
are already written well in versable-builder
(docs/app-patterns/03-deploying-on-gcp.md: branch-to-environment mapping,
/build-info, four traps) and in patterns/02-deploying-a-module.md. This
doc carries the rules those procedures implement.
Audience: anyone provisioning a module or debugging why dev behaves like prod.
What the instances do#
| versable-runner | speedway | walmart-mvp | extractor | |
|---|---|---|---|---|
| envs | prod + dev, one project | prod (+ staging env file, deploy/envs/) | sandbox + prod, one project, main previews to sandbox, release and deploy-prod-* tags to prod | one, per the owner ("only understood one env") |
| identity per env | shared: runner-service-dev runs as runner-service@ | one SA | per stack | one SA extraction-app@ |
| provisioning | deploy.sh, idempotent, env-var parameterized (enable APIs, SA, bucket, queue, --max-concurrent-dispatches) | scripts/release.sh and deploy/deploy.sh, "not interchangeable" | deploy.sh + cloudbuild.deploy.yaml, one image four services (api/worker × prod/non-prod) | 39 revisions, pgbouncer sidecar, VPC connector, minScale 1; CI elsewhere |
| build identity | none | /build-info (commit, branch, kit, built at) | /api/build-info (commit) | image tagged with a git SHA |
| CI | none; deploys from a workstation | Cloud Build files present | Cloud Build, tag-guarded | unproven where |
The rule#
Three environments minimum, each with its own identity, storage, queue, secrets, and issuer client; the environment is named in config and on every record; a deploy is verifiable without credentials.
Three environments#
local, dev, prod, and staging when a module needs a pre-prod soak.
local is the in-process mode with zero cloud
(13-local-dev-and-debugging.md) and it counts: a module that cannot run
locally has two environments and a hope. The extractor's one-environment
history is the owner's named pain point, and App V5's local/dev/prod is what
callers already expect.
Separate identity, separate everything#
Per environment: a service account (or the platform equivalent), a bucket
(or a prefix on a per-env bucket, but a bucket is cleaner for lifecycle
rules), a queue, a secrets namespace, and a client row at the issuer with
the env claim set. A credential from one env is rejected by another even
when its signature is valid (02-identity-and-tenancy.md). Sharing a
service account across dev and prod, as runner-service-dev does, means dev
can read prod's bucket and neither can be audited apart. It is banned.
Ephemeral environments namespace the queue#
A PR preview or a branch deploy that shares a store with its base
environment must not drain the base's queue or be drained by it. App V5's
answer is a queue field on every task matched against
Config.WORKER_QUEUE, set from the Render-injected git branch, so preview
and base share one Mongo without stealing each other's work
(lib/config/__init__.py:88-93). Same idea for Cloud Tasks: a queue per
preview, created and deleted by the deploy. Zero manual config is the bar.
The environment is a value, not a vibe#
One typed config object (09-config-and-secrets.md) has an env field.
The module reports it in /build-info and /health/deep, stamps it on every
job, outcome, usage event, log line, and trace, and refuses to start if a
config value contradicts it (a prod bucket name under env: dev). This is
how "it works on my machine but not on prod" becomes a diff of two
/health/deep responses instead of a week.
Provisioning is a script, idempotent, in the repo#
versable-runner's deploy.sh is the shape: enable APIs, create the service
account, the bucket, the queue with its ceilings, deploy, then re-set the
service URL the OIDC audience needs. Runs twice without harm, parameterized
by environment, and it is the documentation of what the module needs to
exist. Terraform is fine when the estate wants it; the rule is that
provisioning is code in the module's repo, not a memory of clicks.
Branches map to environments#
walmart-mvp's mapping is adopted for modules with CI: main previews to
dev, release (or a deploy-prod-* tag) ships to prod, and a tag can only
deploy an ancestor of main or release
(versable-builder/docs/app-patterns/03-deploying-on-gcp.md). Until a
module has CI, deploy.sh from a workstation is acceptable and its output
must include the commit it deployed.
A deploy is verifiable without credentials#
/build-info reports {commit, built_at, branch?, name, version, contract_version}, snake_case, unauthenticated
(contracts/module-surface.md). It is one shape for modules and apps, so a
deploy check is the same call whatever was deployed. Confirming a deploy is
curl, not a console login. speedway and walmart both serve a build route;
versable-runner does not.
Boot runs a preflight, once per commit#
On start in dev and prod, the module checks its substrate read-only
(storage reachable, queue reachable, reference snapshots present, schema
version expected) and reports the result once per role and commit rather
than on every instance start. App V5 does this with a Redis dedup lock keyed
on RENDER_GIT_COMMIT and a Slack report (lib/config/__init__.py:79-80, 228-237). /health/deep is the same check on demand.
Topology is a per-module choice, stated#
One service that pushes to itself (versable-runner) is fine at small scale
and couples API and worker capacity; an api/worker split from one image
(walmart, the extractor) decouples them at the cost of two services. The
manifest's runtime block says which, and the trigger for splitting is
written down: when an expensive payload starves the submit path, or when
worker memory needs differ from API memory needs (walmart's OOM at 512 Mi).
Cloud Run specifics worth knowing: minScale 1 costs money and buys the
first request its cold start back; the extractor pair is the only instance
that pays it, and it pays more than it looks: the api at minScale 1, the
worker at minScale 10 (../instances/extraction.md). The other side of that trade was measured on 2026-08-18: the first
/health call to a scaled-to-zero services-api timed out at 15 s and the
second returned instantly (../contracts/conformance.md). A module that
scales to zero states its cold start in the manifest, and a caller's timeout
and a conformance runner's warm-up both allow for it; otherwise submit
latency checks fail for a reason unrelated to submit. A pgbouncer sidecar and a VPC connector are what a Cloud SQL or
Memorystore dependency costs; a module on GCS + Cloud Tasks alone needs
neither.
Do-nots#
- Do not share a service account, bucket, queue, or issuer client between
environments. (
runner-service-dev) - Do not ship a module that only knows one environment. (extractor)
- Do not deploy without a
/build-infothat names the commit. (versable-runner) - Do not provision by hand. Script it, idempotent, in the repo.
- Do not let a config value from one env run under another's
env. - Do not promote by rebuilding. A version is built once and an environment
points at it; promoting is moving the pointer, not producing a second
artifact from the same source. Two builds of one commit are two artifacts,
and which one is live becomes unanswerable.
(
evidence/20260820-internal-dashboard-reuse.md) - Do not name a health route
/healthzon Cloud Run. (speedway found this the hard way,server.js:14)