# Pack: Cloudflare Workers platform

Load when: the touch list exhibits `wrangler.jsonc`/`wrangler.toml`, `env.X` binding reads, Durable Objects (`extends DurableObject`, `[[migrations]]`), KV/R2/D1/Queues bindings, cron triggers, service bindings, or Workers static assets.

## worker-binding-env-rendezvous — HIGH · rendezvous-string
**Contract:** The `binding` name strings in wrangler config (`kv_namespaces`, `r2_buckets`, `d1_databases`, `durable_objects.bindings`, `services`, `queues.producers`, `vars`) and the `env.NAME` property reads in Worker code meet on a literal identifier with no static link.
**Detect:** `"binding":` in `wrangler.jsonc`/`wrangler.toml`, `env\.[A-Z_]+`, `worker-configuration.d.ts`, `wrangler types`
**Ships green, breaks:** Renaming a binding in config (or code) compiles and deploys clean; at request time `env.OLD_NAME` is `undefined`, so the first `.get()`/`.prepare()`/`.send()` throws `TypeError: Cannot read properties of undefined` per-request — the Worker itself deploys and health-checks fine on untouched paths. A stale generated `worker-configuration.d.ts` makes TypeScript actively vouch for the dead name.
**Safe change:** Grep all `env.X` reads before renaming a binding; rename config and code in one commit; re-run `wrangler types` and commit the regenerated `worker-configuration.d.ts`; exercise the specific route that touches the binding, not just deploy success.

## wrangler-env-binding-non-inheritance — HIGH · config-elsewhere
**Contract:** Top-level bindings in wrangler config and the `env.staging`/`env.production` sub-configs are independent — bindings, `vars`, and secrets are explicitly non-inheritable, and each environment deploys a distinct Worker named `<name>-<env>`.
**Detect:** `"env":` block in `wrangler.jsonc`, `[env.` in `wrangler.toml`, `wrangler deploy --env`, `wrangler secret put ... --env`
**Ships green, breaks:** Adding a KV/D1/queue/var binding only at top level works in dev and in the default deploy, then 500s (undefined binding) only in the environment it was never copied into. Secrets are per-Worker: `wrangler secret put KEY` without `--env staging` sets it on the base Worker, and `my-worker-staging` silently has no secret. Routes must also be provided per environment.
**Safe change:** When adding any binding/var, add it to top level AND every `env.*` block in the same edit; run `wrangler secret put KEY --env <e>` for each environment; diff the deployed bindings per env (`wrangler deploy --env <e> --dry-run`); test each environment's URL, not just one.

## do-migrations-ledger — CRITICAL · lifecycle-protocol
**Contract:** Every Durable Object class rename/delete/create must be recorded as an entry in the append-only, ordered `[[migrations]]` list (`new_sqlite_classes`, `new_classes`, `renamed_classes`, `deleted_classes`, `transferred_classes`); once a Worker has a migration tag, all future deploys must carry tags.
**Detect:** `"migrations":` / `[[migrations]]`, `new_sqlite_classes`, `renamed_classes`, `deleted_classes`, `class_name` in `durable_objects.bindings`, `extends DurableObject`
**Ships green, breaks:** Renaming the exported class in code + config without a `renamed_classes` migration orphans every live object and its storage under the old class name — new requests mint fresh empty objects. `deleted_classes` deletes all Durable Objects of that class including all stored data — irreversible. The storage backend is permanent per class: you cannot enable SQLite storage on an existing deployed class — setting `new_sqlite_classes` on later migrations fails; a class born under `new_classes` (KV) is KV forever.
**Safe change:** Treat migrations as an append-only ledger — never edit or reorder past tags; use `new_sqlite_classes` for every new class; for renames, add `renamed_classes = [{from, to}]` in the same deploy as the code rename; never ship `deleted_classes` without confirming the data is disposable.

## do-storage-outlives-code — CRITICAL · serialized-shape
**Contract:** A Durable Object's `ctx.storage` key/value entries, its embedded SQLite schema (`ctx.storage.sql`), and its pending alarms persist across every deploy — new code must parse whatever old code wrote, forever.
**Detect:** `ctx.storage.put`, `state.storage.get`, `storage.sql.exec`, `setAlarm`, `blockConcurrencyWhile`, `async alarm(`
**Ships green, breaks:** Changing a stored value's shape or a `sql.exec` schema deploys green; existing objects wake up with old rows/keys and the new code misreads or crashes per-object (only the objects that have old data — staging with fresh objects looks fine). Scheduled alarms survive the deploy and fire into the new code with at-least-once semantics (retried up to 6 times, exponential backoff from 2s) — removing or renaming the `alarm()` handler while alarms are pending produces repeating runtime errors.
**Safe change:** Version stored payloads (a schema-version key) and migrate lazily on read, or run `storage.sql` DDL inside `blockConcurrencyWhile()` in the constructor; keep reads tolerant of the previous shape for at least one deploy; audit `setAlarm` sites before deleting/renaming `alarm()`.

## do-idfromname-key-scheme — CRITICAL · rendezvous-string
**Contract:** `namespace.idFromName(name)` deterministically maps a name string to one object and its storage — the string-construction scheme (e.g. `` `tenant:${id}` ``) IS the address of persisted state.
**Detect:** `idFromName(`, `idFromString(`, `getByName(`, `.get(id)` on a `DurableObjectNamespace`
**Ships green, breaks:** Any change to the name derivation (adding a prefix, switching from email to user-id, changing case/normalization) compiles fine and silently routes to brand-new empty objects; the old objects keep their data (and keep firing their alarms) but are never addressed again — state forks with no error anywhere.
**Safe change:** Centralize name construction in one function and treat it as frozen; if the scheme must change, ship a bridge that reads the old name's object and migrates its storage into the new one; verify against a pre-existing object, not a fresh one.

## compatibility-date-behavior-gate — HIGH · lifecycle-protocol
**Contract:** `compatibility_date` in wrangler config selects which default-on runtime behaviors your code runs under; bumping it is a semantic upgrade of dozens of flags, not metadata.
**Detect:** `compatibility_date`, `compatibility_flags`, `nodejs_compat` in `wrangler.jsonc`
**Ships green, breaks:** Concrete gates: ≥ `2022-10-31` switches to the spec-compliant WHATWG URL parser (`url_standard`) — URLs with odd slashes/whitespace parse differently; ≥ `2024-03-18` flips Queues `send()` default `contentType` from `v8` to `json` — structured-clone-only bodies now serialize differently; ≥ `2024-09-23` with `nodejs_compat` auto-enables `nodejs_compat_v2` — extra polyfills bundled, module resolution changes. A date bump made "to get one new API" silently drags in every other flag between the dates.
**Safe change:** Bump the date as its own commit with nothing else in it; read the compat-flags changelog for every flag whose default-on date falls in the interval; pin unwanted flips with explicit `no_*` flags (e.g. `no_nodejs_compat_v2`); run the full suite under `wrangler dev` (which honors the new date) before deploying.

## cron-triggers-config-drift — HIGH · config-elsewhere
**Contract:** `triggers.crons` in wrangler config and the `scheduled(controller, env, ctx)` handler agree on which schedules exist and (via `controller.cron` string matching) which code each fires — always evaluated in UTC.
**Detect:** `"triggers":`, `"crons":`, `async scheduled(`, `controller.cron ===`
**Ships green, breaks:** Omitting the `triggers`/`crons` key entirely does NOT remove deployed crons — currently deployed Cron Triggers are left in place, so deleted-from-config schedules keep firing zombie jobs forever; only an explicit `"crons": []` removes them. When `triggers` IS present, deploy replaces the whole set. Multiple crons dispatch to the one `scheduled` handler; dispatch is a string compare against `controller.cron` — editing the cron expression in config breaks the `===` in code with no error, the branch just never runs. All expressions are UTC; a "midnight" job drifts an hour across DST in local-time reasoning.
**Safe change:** Keep the `triggers` key always present (empty array when none); when editing an expression, update the matching `controller.cron` comparison in the same commit; reason about schedules in UTC only; after removing a cron, verify in the dashboard that it's gone.

## kv-eventual-consistency — HIGH · lifecycle-protocol
**Contract:** KV writers and readers agree the store is eventually consistent: writes (and deletes) take up to ~60 seconds to be visible in other locations, and reads are served from per-location caches.
**Detect:** `kv_namespaces`, `.put(` / `.get(` / `.delete(` on a KV binding, `cacheTtl`
**Ships green, breaks:** Read-after-write logic works in `wrangler dev` and usually within the same PoP, then intermittently reads stale or missing values in production. Negative lookups are cached too: create-key-then-read from a location that just saw it missing returns `null` for up to 60s. Passing `cacheTtl` above the 60s default extends staleness accordingly. Two concurrent writers last-write-wins with no conflict signal.
**Safe change:** Never gate correctness on KV read-after-write across requests; use a Durable Object or D1 for anything needing coordination or counters; treat KV as a cache of derived, idempotently-rebuildable data; if a key was read-as-missing, expect the miss to be cached ~60s.

## secrets-vars-dual-authority — HIGH · dual-authority
**Contract:** A Worker's plaintext `vars` are owned by wrangler config on every deploy, while secrets are owned by the out-of-band `wrangler secret put` channel (dashboard edits form a third writer) — all landing in the same `env` namespace.
**Detect:** `"vars":`, `keep_vars`, `wrangler secret put`, `.dev.vars`, `--secrets-file`, `"secrets":` (required-secrets validation)
**Ships green, breaks:** Dashboard-edited vars are silently wiped by the next `wrangler deploy` unless `keep_vars = true`. `.dev.vars` makes a never-uploaded secret work perfectly in `wrangler dev` while `env.KEY` is `undefined` in production — first noticed when auth/API calls fail (or worse, when code falls back via `env.KEY ?? devDefault`). A `vars` entry colliding with an existing secret name fails deploy with API error 10053 — loud, but only at deploy of the colliding env.
**Safe change:** Declare required secret names under the `secrets` config key so wrangler validates them at deploy; pick one writer for vars (config, with `keep_vars` decided explicitly); for every new `.dev.vars` entry, immediately run `wrangler secret put` per environment; never default-fallback a missing secret.

## service-binding-rpc-surface — HIGH · serialized-shape
**Contract:** A service binding's `service` + `entrypoint` names in the caller's config must match a separately-deployed Worker that exports that `WorkerEntrypoint` class, and every RPC method the caller invokes must exist on whatever version of the target is currently live.
**Detect:** `"services":` with `"service":`/`"entrypoint":` in `wrangler.jsonc`, `extends WorkerEntrypoint`, `env\.[A-Z_]+\.\w+\(` RPC calls
**Ships green, breaks:** The two Workers compile, typecheck, and deploy independently — renaming/removing an RPC method (or the entrypoint class) in the target deploys green, then every caller invocation throws `TypeError: The RPC receiver does not implement the method "x"` at runtime. Arguments/returns must be structured-clone-serializable (plus RPC stubs); passing a class instance throws `DataCloneError` only when the call executes. Deploy order is a live window: between target and caller deploys, one side speaks a method surface the other lacks.
**Safe change:** Treat the entrypoint as a public API — additive changes only, remove methods one release after all callers stop calling; share the entrypoint's type via a common package so typecheck spans the gap; deploy target-with-both-methods first, then callers, then remove; smoke-test the actual RPC call cross-worker, not each Worker alone.

## queues-body-serialization — HIGH · serialized-shape
**Contract:** Producer (`queues.producers` binding) and consumer (`queues.consumers` config) rendezvous on the queue name string, and the message body shape is an unversioned schema crossing between two independently-deployed Workers via serialization.
**Detect:** `"queues":` with `"producers":`/`"consumers":`, `.send(` / `.sendBatch(`, `contentType`, `message.body`
**Ships green, breaks:** Default `contentType` is `"json"` (compat date ≥ 2024-03-18; older dates default `"v8"`): `Date` silently becomes an ISO string, `Map`/`Set` become `{}`, `undefined` fields vanish — the consumer reads mangled data with no error. `"v8"`-serialized bodies are unreadable by pull-based HTTP consumers. Messages already sitting in the queue were serialized by the OLD producer — a consumer deploy expecting the new shape must still parse days-old messages (retention default 4 days, up to 14). Max message size 128 KB — a payload that grew past it fails only at `send()` time in production.
**Safe change:** Version the message envelope (`{v: 2, ...}`) and keep the consumer parsing v(n-1) until retention drains; deploy the tolerant consumer before the new producer; pin `contentType` explicitly; keep payloads small (IDs, not blobs).

## queues-retry-dlq-lifecycle — CRITICAL · lifecycle-protocol
**Contract:** Consumer batch semantics: a thrown handler fails the entire batch, each message retries up to `max_retries` (default 3), and messages exhausting retries go to the configured `dead_letter_queue` — or are permanently discarded if none is configured.
**Detect:** `max_retries`, `dead_letter_queue`, `max_batch_size`, `max_batch_timeout`, `message.ack()`, `message.retry(`, `batch.ackAll()`
**Ships green, breaks:** With no DLQ, messages that repeatedly fail are eventually discarded — one poison message plus 3 retries equals silent permanent data loss. A handler that processes messages 1–9 then throws on message 10 retries the WHOLE batch (default `max_batch_size` 10): messages 1–9 are redelivered and double-processed unless each was explicitly `ack()`ed. `ack()` is final regardless of whether the handler later throws.
**Safe change:** Always configure a `dead_letter_queue` (and a consumer or alert on it); `ack()` each message individually after its side effects commit; make handlers idempotent (dedupe on message id) — redelivery is at-least-once; use `retry({delaySeconds})` for backoff instead of throwing.

## workers-runtime-limits — HIGH · config-elsewhere
**Contract:** Code's resource consumption must fit caps declared nowhere in the code — plan tier plus optional `limits` keys in wrangler config: CPU 10 ms free / 30 s paid default (raise via `limits.cpu_ms`), 128 MB memory per isolate, subrequest caps per plan, script size 3 MB free / 10 MB paid after gzip, 6 simultaneous open connections, env vars 5 KB each.
**Detect:** `"limits":` / `cpu_ms` / `subrequests` in `wrangler.jsonc`, new heavy dependency in `package.json`, fan-out `Promise.all(...fetch`, large `vars` values
**Ships green, breaks:** All of these pass typecheck, tests, and `wrangler dev` (which doesn't enforce production caps) and fail only under production load: CPU overage kills the request mid-flight; a fan-out that grew past the subrequest cap throws "Too many subrequests"; the 6-connection cap silently queues the 7th concurrent `fetch` — un-consumed response bodies hold connections open and can deadlock a request that then awaits another fetch. A dependency bump pushing past the gzip size cap fails at deploy — but only on the free-plan project.
**Safe change:** After adding a dependency, check `wrangler deploy --dry-run --outdir` bundle size; consume or `cancel()` every response body; count worst-case subrequests per request path against the plan cap; load-test CPU-heavy paths on the real plan.

## d1-transaction-and-replica-consistency — HIGH · lifecycle-protocol
**Contract:** D1 client code and the database agree that atomicity exists ONLY via `batch()` — SQL `BEGIN TRANSACTION`/`SAVEPOINT` are rejected at runtime — and that with read replication, read-your-writes exists only inside a Sessions-API session.
**Detect:** `.batch(`, `.prepare(`, `.exec(`, `db.transaction(` (ORM), `withSession(`, `first-primary`, `first-unconstrained`
**Ships green, breaks:** ORM interactive-transaction APIs (e.g. Drizzle `db.transaction()` on D1) compile fine and fail at runtime. The tempting workaround — sequential awaited statements — is silently non-atomic: a mid-sequence failure leaves half-applied writes. `batch()` IS transactional (aborts or rolls back the entire sequence). With read replication enabled, `withSession()` defaults to `first-unconstrained` — write-then-read across separate requests can miss the write unless the bookmark is carried forward or `first-primary` is used.
**Safe change:** Route every multi-statement invariant through `batch()`; never emulate transactions with sequential awaits; when enabling read replication, thread session bookmarks through request flows or pin consistency-critical reads to `first-primary`.

## static-assets-shadow-worker-routes — HIGH · config-elsewhere
**Contract:** With `assets.directory` configured, the platform serves a matching static asset BEFORE invoking the Worker script — the asset manifest (build output) and the Worker's route handlers implicitly partition the URL space.
**Detect:** `"assets":` with `"directory":`, `not_found_handling`, `run_worker_first`, `"binding": "ASSETS"`
**Ships green, breaks:** A build-output file landing at the same path as a Worker route (e.g. a generated `/api/status` or a new `sitemap.xml` route vs an old static one) silently shadows the handler — the Worker code never runs for that path, no error anywhere. With `not_found_handling: "single-page-application"`, unmatched paths get `index.html` served as a fallback without invoking the Worker, with a 200 — so a new server-rendered or OAuth-callback route works in unit tests but browser navigations to it receive the SPA shell instead of the handler.
**Safe change:** List Worker-owned path prefixes in `run_worker_first: ["/api/*", "/oauth/*", ...]` and keep it updated with every new server route; check the build output directory for filename collisions with handler paths; verify new routes with a real browser navigation (not just curl of an XHR path).
