@dragonmastery/tamer 0.1.2 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +569 -18
  2. package/dist/CFApiClient-DhbyyV71.mjs +868 -0
  3. package/dist/CFApiClient-DhbyyV71.mjs.map +1 -0
  4. package/dist/StateManager-DTqtLLVX.mjs +760 -0
  5. package/dist/StateManager-DTqtLLVX.mjs.map +1 -0
  6. package/dist/apply-B0b_jjGv.mjs +423 -0
  7. package/dist/apply-B0b_jjGv.mjs.map +1 -0
  8. package/dist/applyTarget-BetDYdeS.mjs +152 -0
  9. package/dist/applyTarget-BetDYdeS.mjs.map +1 -0
  10. package/dist/bootstrap-CBzPilB1.mjs +33 -0
  11. package/dist/bootstrap-CBzPilB1.mjs.map +1 -0
  12. package/dist/buildDispatchUploadForm-BoUB93b3.mjs +38 -0
  13. package/dist/buildDispatchUploadForm-BoUB93b3.mjs.map +1 -0
  14. package/dist/cloudflareSnapshot-B4FOaNr0.mjs +163 -0
  15. package/dist/cloudflareSnapshot-B4FOaNr0.mjs.map +1 -0
  16. package/dist/deploy-gHEQxhmx.mjs +119 -0
  17. package/dist/deploy-gHEQxhmx.mjs.map +1 -0
  18. package/dist/destroy-B21f3wgq.mjs +215 -0
  19. package/dist/destroy-B21f3wgq.mjs.map +1 -0
  20. package/dist/destroy-tenant-BW2nasnK.mjs +103 -0
  21. package/dist/destroy-tenant-BW2nasnK.mjs.map +1 -0
  22. package/dist/dev-Dt26nzMJ.mjs +103 -0
  23. package/dist/dev-Dt26nzMJ.mjs.map +1 -0
  24. package/dist/dns-records.resolve-C2T0m4NG.mjs +3 -0
  25. package/dist/dns-records.resolve-DwBR_1WI.mjs +47 -0
  26. package/dist/dns-records.resolve-DwBR_1WI.mjs.map +1 -0
  27. package/dist/dns-records.sync-Bpzz9H0s.mjs +75 -0
  28. package/dist/dns-records.sync-Bpzz9H0s.mjs.map +1 -0
  29. package/dist/doctor-C_hs7k2D.mjs +34 -0
  30. package/dist/doctor-C_hs7k2D.mjs.map +1 -0
  31. package/dist/drift-D5qzCTft.mjs +10 -0
  32. package/dist/drift-D8ZrSgTn.mjs +323 -0
  33. package/dist/drift-D8ZrSgTn.mjs.map +1 -0
  34. package/dist/events-BSwGdkGj.mjs +68 -0
  35. package/dist/events-BSwGdkGj.mjs.map +1 -0
  36. package/dist/fetchStackImports-B4ZJahOt.mjs +3817 -0
  37. package/dist/fetchStackImports-B4ZJahOt.mjs.map +1 -0
  38. package/dist/generator-CIMbcPzv.mjs +77 -0
  39. package/dist/generator-CIMbcPzv.mjs.map +1 -0
  40. package/dist/import-BrduwA9Z.mjs +164 -0
  41. package/dist/import-BrduwA9Z.mjs.map +1 -0
  42. package/dist/index.d.mts +5568 -1297
  43. package/dist/index.d.mts.map +1 -1
  44. package/dist/index.mjs +18 -1
  45. package/dist/index.mjs.map +1 -0
  46. package/dist/loader-DP7yXqT6.mjs +518 -0
  47. package/dist/loader-DP7yXqT6.mjs.map +1 -0
  48. package/dist/logpush-job-xS7270FZ.mjs +1106 -0
  49. package/dist/logpush-job-xS7270FZ.mjs.map +1 -0
  50. package/dist/migrate-CahG6BYV.mjs +87 -0
  51. package/dist/migrate-CahG6BYV.mjs.map +1 -0
  52. package/dist/normalize-Bx0bpFop.mjs +236 -0
  53. package/dist/normalize-Bx0bpFop.mjs.map +1 -0
  54. package/dist/plan-DWvsvy1U.mjs +453 -0
  55. package/dist/plan-DWvsvy1U.mjs.map +1 -0
  56. package/dist/planFormat-CJw8Kq2s.mjs +119 -0
  57. package/dist/planFormat-CJw8Kq2s.mjs.map +1 -0
  58. package/dist/provision-tenant-WTKo93Y0.mjs +192 -0
  59. package/dist/provision-tenant-WTKo93Y0.mjs.map +1 -0
  60. package/dist/r2S3EmptyBucket-DD81ZWQ7.mjs +92 -0
  61. package/dist/r2S3EmptyBucket-DD81ZWQ7.mjs.map +1 -0
  62. package/dist/stackOutputs-W9mnnJuj.mjs +69 -0
  63. package/dist/stackOutputs-W9mnnJuj.mjs.map +1 -0
  64. package/dist/status-DLwREPjb.mjs +198 -0
  65. package/dist/status-DLwREPjb.mjs.map +1 -0
  66. package/dist/sync-f2K2blwm.mjs +90 -0
  67. package/dist/sync-f2K2blwm.mjs.map +1 -0
  68. package/dist/tamer.d.mts +1 -0
  69. package/dist/tamer.mjs +4553 -0
  70. package/dist/tamer.mjs.map +1 -0
  71. package/dist/tamerArtifactsR2-Ccgplu2Q.mjs +52 -0
  72. package/dist/tamerArtifactsR2-Ccgplu2Q.mjs.map +1 -0
  73. package/dist/types-CqxqYnrT.mjs +44 -0
  74. package/dist/types-CqxqYnrT.mjs.map +1 -0
  75. package/dist/verifyPlanFile-c16z1AMH.mjs +33 -0
  76. package/dist/verifyPlanFile-c16z1AMH.mjs.map +1 -0
  77. package/dist/wfp-delete-DysvX1u7.mjs +36 -0
  78. package/dist/wfp-delete-DysvX1u7.mjs.map +1 -0
  79. package/dist/wfp-put-jaVd_LjO.mjs +52 -0
  80. package/dist/wfp-put-jaVd_LjO.mjs.map +1 -0
  81. package/dist/worker-route-Be2IvOdr.mjs +263 -0
  82. package/dist/worker-route-Be2IvOdr.mjs.map +1 -0
  83. package/dist/workers-aGILs77X.mjs +87 -0
  84. package/dist/workers-aGILs77X.mjs.map +1 -0
  85. package/dist/wranglerSpawn-DmEz0ldT.mjs +24 -0
  86. package/dist/wranglerSpawn-DmEz0ldT.mjs.map +1 -0
  87. package/dist/zoneResolver-VoxLHM4N.mjs +32 -0
  88. package/dist/zoneResolver-VoxLHM4N.mjs.map +1 -0
  89. package/package.json +38 -3
package/README.md CHANGED
@@ -1,20 +1,32 @@
1
1
  # @dragonmastery/tamer
2
2
 
3
- TypeScript types for Cloudflare Wrangler configuration. Generated from the official wrangler config schema. Supports Tamer and other CF CICD tooling.
3
+ **Tamer tooling monorepo** Cloudflare Workers infrastructure CLI (sync, apply, deploy, migrate, destroy), Wrangler-oriented TypeScript types (generated from the official Wrangler config schema), reference fixtures, and tests for the published **`@dragonmastery/tamer`** npm package.
4
4
 
5
- > **Note:** This package provides types only. The full Tamer CLI (sync, apply, dev, deploy) is in development.
5
+ **Using Tamer in a downstream repo?** Install from npm and follow **[Consumer quickstart](docs/consumer-quickstart.md)**. The rest of this README is for contributors hacking Tamer and for CLI/config reference.
6
6
 
7
7
  ## Installation
8
8
 
9
+ **Contributors** (this repo):
10
+
11
+ ```bash
12
+ bun install
13
+ ```
14
+
15
+ Requires [Bun](https://bun.sh) and Node.js **22+** (`engines.node`). Run the CLI with **`bun run tamer -- …`** or **`bun src/cli/index.ts …`** (see [Developing](#developing)).
16
+
17
+ **Published package** (downstream repos):
18
+
9
19
  ```bash
10
- npm install @dragonmastery/tamer
11
- # or
12
- bun add @dragonmastery/tamer
20
+ npm install -D @dragonmastery/tamer wrangler
13
21
  ```
14
22
 
15
- Requires `wrangler` (peer dependency) and Node.js 22+.
23
+ Also requires the **`wrangler`** peer (`>=4.0.0`). Install, scripts, stack layout, and first deploy: **[Consumer quickstart](docs/consumer-quickstart.md)**.
24
+
25
+ ## Consumers (downstream repos)
26
+
27
+ Install from npm and walk through bootstrap → apply → deploy in **[docs/consumer-quickstart.md](docs/consumer-quickstart.md)**. Resource kinds, `${tamer:import:…}`, outputs, and CLI flags remain documented below for reference.
16
28
 
17
- ## Usage
29
+ ## Types usage
18
30
 
19
31
  ```ts
20
32
  import type {
@@ -29,21 +41,560 @@ const config: WranglerConfig = {
29
41
  name: "my-worker",
30
42
  main: "src/index.ts",
31
43
  compatibility_date: "2025-01-01",
32
- d1_databases: [
33
- { binding: "DB", database_id: "xxx" },
34
- ],
44
+ d1_databases: [{ binding: "DB", database_id: "xxx" }],
35
45
  };
36
46
  ```
37
47
 
38
- ## Exports
48
+ | Type | Description |
49
+ | --------------------- | ------------------------------------------- |
50
+ | `WranglerConfig` | Full wrangler config (alias: `RawConfig`) |
51
+ | `WranglerEnvironment` | Environment block (alias: `RawEnvironment`) |
52
+ | `WranglerD1Database` | D1 binding entry |
53
+ | `WranglerR2Bucket` | R2 binding entry |
54
+ | `WranglerKVNamespace` | KV binding entry |
55
+
56
+ ## CLI
57
+
58
+ ```
59
+ tamer bootstrap Create per-env Tamer metadata: `tamer-state-<env>` (D1) and `tamer-artifacts-<env>` (R2)
60
+ tamer sync Sync local state from Cloudflare (no writes)
61
+ tamer apply Provision missing D1 / R2 / KV / Queues / Hyperdrive / Vectorize / AI Gateways / Pipelines / Workflows / Secrets Stores / DNS records / dispatch namespaces
62
+ tamer migrate Run D1 migrations per worker
63
+ tamer deploy wrangler deploy per worker (after sync), then apply zone-name `tamerRoutes` via Workers Routes API
64
+ tamer dev wrangler dev (use --all for every worker)
65
+ tamer status Show config vs state (optional `--tenant product:workspace`)
66
+ tamer drift Compare state vs Cloudflare and report differences (read-only)
67
+ tamer plan CloudFormation-style preview: what `apply`+`deploy` would create (read-only)
68
+ tamer import Register an existing Cloudflare resource into state by logical name
69
+ tamer doctor Verify `CLOUDFLARE_*` credentials against the account API (`--json` supported)
70
+ tamer provision-tenant Runtime: create tenant D1 + upload dispatch script (`--main` or `--artifact-key`)
71
+ tamer destroy-tenant Runtime: remove tenant script + D1 + state (shared envs: `--confirm-tenant`)
72
+ tamer destroy Remove workers + storage + namespaces for an env
73
+ tamer wfp put Upload a single-module Worker to a dispatch namespace
74
+ tamer wfp delete Delete a Worker from a dispatch namespace
75
+ ```
76
+
77
+ Common flags: `--env <name>` (required for every command; `tamer deploy --env <name>` no longer silently defaults to `prod`), `--worker <name>`, `--config <path>`, `--force`, `--confirm-env <name>` (destroy: required for any env in `tenant.protectedEnvs` from `tamer.config.ts` — defaults to `["prod","production"]` when unset; pass `protectedEnvs: ["prod","production","production-eu","qa"]` etc. to widen the gate, or `[]` to opt out — unless `--force`), `--confirm-tenant <workspace>` (destroy-tenant: same rule, sourced from `tenant.protectedEnvs`), `--skip-workers` (destroy), `--wipe-metadata` (destroy: delete shared `tamer-state-<env>` D1 **and** `tamer-artifacts-<env>` R2; use on the last stack in a multi-stack teardown), `--dispatch-namespace <name>` (deploy), `--json` (drift / plan: emit machine-readable JSON), `--detailed-exitcode` (plan: exit `2` instead of `0` when there are pending changes — Terraform-style CI gate), `--destroy` (plan: preview deletions instead of creates/updates — read-only `tamer destroy` dry-run).
78
+
79
+ `tamer import` flags: `--kind d1|r2|kv|queue|hyperdrive|vectorize|ai_gateway|pipeline|workflow|secret_store|dns_record|dispatch_namespace|worker_route`, `--logical <name>` (logical name from `tamer.config.ts`, or worker key for `worker_route`), `--cf-id <id>` (D1 uuid, KV id, R2 bucket name, queue id, hyperdrive config id, vectorize index name, AI gateway id, pipeline id, workflow id, secrets store id, DNS record id, or dispatch namespace name), `--shard-date <YYYY-MM-DD>` (sharded D1 only), `--created-date <YYYY-MM-DD>` (R2: optional, defaults to extracting from name), `--route-id <id>` and `--zone-name <z>` (worker_route).
80
+
81
+ ### Environment variables
82
+
83
+ Tamer uses the same env vars as Wrangler — see [system environment variables](https://developers.cloudflare.com/workers/wrangler/system-environment-variables/):
84
+
85
+ - `CLOUDFLARE_ACCOUNT_ID`
86
+ - `CLOUDFLARE_API_TOKEN`
87
+ - `R2_ACCESS_KEY_ID` / `R2_SECRET_ACCESS_KEY` (optional) — [R2 S3 API](https://developers.cloudflare.com/r2/api/s3/tokens/) credentials. When both are set, `tamer destroy` empties managed R2 buckets (delete all objects, abort incomplete multipart uploads) before removing the bucket. Not the same as `CLOUDFLARE_API_TOKEN`.
88
+ - `TAMER_DEV_BASE_PORT` (default `8787`, used by `dev --all`)
89
+
90
+ Bun loads `.env` from the **current working directory** automatically when you run `bun src/cli/index.ts ...`, so you can keep credentials in a per-project `.env` (gitignored) instead of exporting them.
91
+
92
+ ### API token scopes
93
+
94
+ Custom token, **scoped to the account** you target. Names match Cloudflare’s [API token permissions reference](https://developers.cloudflare.com/fundamentals/api/reference/permissions/):
95
+
96
+ | Permission | Scope | Used by |
97
+ | ------------------------- | ------- | ------------------------------------------------------------------------ |
98
+ | **Workers Scripts: Edit** | Account | `deploy`, `destroy` (worker delete), `wfp put`, dispatch-namespace ops |
99
+ | **Workers KV Storage: Edit** | Account | KV `apply` / `destroy` / `sync` |
100
+ | **Workers R2 Storage: Edit** | Account | R2 `apply` / `destroy` / `sync` |
101
+ | **D1: Edit** | Account | D1 `apply` / `destroy` / `migrate` / `sync` |
102
+ | **Queues: Edit** | Account | Queues `apply` / `destroy` / `sync` / `drift` / `import` |
103
+ | **Hyperdrive: Edit** | Account | Hyperdrive `apply` / `destroy` / `sync` / `drift` / `import` |
104
+ | **Vectorize: Edit** | Account | Vectorize `apply` / `destroy` / `sync` / `drift` / `import` |
105
+ | **AI Gateway: Edit** | Account | AI Gateway `apply` / `destroy` / `sync` / `drift` / `import` |
106
+ | **Pipelines: Edit** | Account | Pipelines `apply` / `destroy` / `sync` / `drift` / `import` |
107
+ | **Workflows: Edit** | Account | Workflows `apply` / `destroy` / `sync` / `drift` / `import` |
108
+ | **Secrets Store: Edit** | Account | Secrets Store `apply` / `destroy` / `sync` / `drift` / `import` |
109
+ | **DNS: Edit** | Zone | DNS records `apply` / `destroy` / `sync` / `drift` / `import` (zones declared in `dnsRecords[]`) |
110
+ | **Logs: Edit** (or **Logpush: Edit**) | Account | `logpushJobs` in `tamer.config.ts`: create/delete Workers trace Logpush jobs ([Workers Logpush](https://developers.cloudflare.com/workers/observability/logs/logpush/)) |
111
+ | **Workers R2 Data Catalog: Edit** | Account | **`pipelinesAuto` (apply token):** calls `POST …/r2-catalog/{bucket}/enable` and `…/credential` on your **CLOUDFLARE_API_TOKEN** — *not* the minted “catalog+sink” sub-token. With **Workers R2 Storage: Edit** on the catalog bucket for Pipelines. |
112
+ | **API Tokens: Edit** (or equivalent) | Account | **`pipelinesAuto` (apply token):** [list permission groups](https://developers.cloudflare.com/api/resources/accounts/subresources/tokens/subresources/permission_groups/methods/list/) and [create / delete](https://developers.cloudflare.com/api/resources/accounts/subresources/tokens/methods/create/) the two **account** sub-tokens only. |
113
+ | **Account Settings: Read** | Account | Account-scoped reads (and required by Wrangler subprocesses) |
114
+ | **Workers Tail: Read** | Account | Optional, only for `wrangler tail` |
115
+ | **Workers Routes: Edit** | Zone | Zone-name `tamerRoutes`: list/create/delete routes (`deploy`, `sync`, `destroy`, `drift`). Skip if you only use wrangler-native `routes` or custom-domain `tamerRoutes`. |
116
+ | **Zone: Read** | Zone | Resolving `zone_name` → zone id for those routes (same zones as above). |
117
+
118
+ The dashboard template **“Edit Cloudflare Workers”** covers most of these; **add D1: Edit** manually.
119
+
120
+ **Workers trace → Pipelines → Iceberg (`pipelinesAuto`):** On **`tamer apply`**, Tamer [mints](https://developers.cloudflare.com/api/resources/accounts/subresources/tokens/methods/create/) two **account** sub-tokens, in line with the dashboard: **Workers R2 Data Catalog** (Edit/Write) only for the stored **catalog** credential and the `r2_data_catalog` sink, and [Workers Pipelines **Send**](https://developers.cloudflare.com/pipelines/streams/writing-to-streams/) only for Logpush stream ingest. **Values are stored in the `logpush_pipelines` state entry**; treat that state as sensitive. Your **`CLOUDFLARE_API_TOKEN`** must *also* be allowed to call the **R2 Data Catalog** account HTTP API (`/accounts/.../r2-catalog/...` for enable + credential) on its own — that uses the apply token, not the sub-tokens. The same apply token also needs the account Token API, Logpush, Pipelines, etc. (table [above](#api-token-scopes), [Account Token API](https://developers.cloudflare.com/api/resources/accounts/subresources/tokens/)). If sink creation fails with **HTTP 422 / code 1012** (*existing catalog table*), change **`pipelinesAuto.tableName`** and/or **`namespace`** to a name that does not already exist in that catalog bucket.
121
+
122
+ For read-only flows (`status`, dry runs), use the **Read** variants instead.
123
+
124
+ ### Resource kinds
125
+
126
+ Tamer-managed resources are declared on a worker under `resources`. Each kind expands to a Wrangler binding plus a state row keyed by derived name.
127
+
128
+ ```ts
129
+ defineWorker({
130
+ resources: {
131
+ d1: [{ logicalName: "settings", type: "single" }],
132
+ r2: [{ logicalName: "assets" }],
133
+ kv: [{ logicalName: "cache" }],
134
+ queues: [
135
+ { logicalName: "events" }, // producer binding emitted
136
+ { logicalName: "audit", consumerOnly: true }, // queue created, no producer binding
137
+ ],
138
+ hyperdrive: [
139
+ {
140
+ logicalName: "primary",
141
+ origin: {
142
+ scheme: "postgres",
143
+ host: "db.example.com",
144
+ port: 5432,
145
+ database: "app",
146
+ user: "app",
147
+ password: { fromEnv: "PG_PW" }, // never persisted in state
148
+ },
149
+ localConnectionString: "postgres://localhost/app",
150
+ },
151
+ ],
152
+ vectorize: [
153
+ { logicalName: "embeddings", dimensions: 768, metric: "cosine" },
154
+ ],
155
+ aiGateway: [
156
+ {
157
+ logicalName: "openai",
158
+ cacheTtl: 60, // seconds; 0 = caching off (default)
159
+ authentication: true, // require Authorization header on gateway endpoint
160
+ rateLimitingInterval: 60,
161
+ rateLimitingLimit: 100,
162
+ },
163
+ ],
164
+ pipelines: [
165
+ {
166
+ logicalName: "events",
167
+ // Arroyo SQL — `events_stream` and `events_sink` must already exist on Cloudflare.
168
+ sql: "insert into events_sink select * from events_stream;",
169
+ },
170
+ ],
171
+ workflows: [
172
+ {
173
+ logicalName: "billing",
174
+ className: "BillingWorkflow", // class exported from this worker's entrypoint
175
+ // scriptName defaults to the owning worker's deployed script — pin it
176
+ // here only to bind to a different worker's exported class.
177
+ limits: { steps: 50 }, // optional — tracked in state for drift
178
+ },
179
+ ],
180
+ secretsStores: [
181
+ // Tamer manages the *store* (account-scoped container). Secret values
182
+ // inside it are written out-of-band via wrangler / dashboard / CI.
183
+ { logicalName: "apiKeys" },
184
+ ],
185
+ secretsStoreSecrets: [
186
+ // Wire one previously-uploaded secret into this worker. The wrangler
187
+ // generator resolves `store: "apiKeys"` to the live store_id from state.
188
+ { binding: "STRIPE_KEY", store: "apiKeys", secretName: "stripe_key" },
189
+ ],
190
+ },
191
+ vars: {
192
+ // No wrangler binding for AI Gateway — pull the derived id into env vars instead.
193
+ AI_GATEWAY_ID: "${tamer:ai_gateway:openai.name}",
194
+ },
195
+ });
196
+ ```
197
+
198
+ Notes:
199
+
200
+ - **Queues**: Tamer creates the queue itself on `apply` and emits `queues.producers[]` in the generated wrangler config. Consumer subscriptions stay wrangler-side — set them on the worker config under `queues.consumers` (passed through verbatim).
201
+ - **Hyperdrive**: the origin connection (including `password` and optional `access_client_secret`) is sent to Cloudflare on `apply` and discarded — Tamer's state only records the resulting config id, scheme, host and database name. Use `{ fromEnv: "VAR" }` to source secrets from the shell environment so they never enter `tamer.config.ts`. `localConnectionString` is written to the generated wrangler config for `wrangler dev` only.
202
+ - **Vectorize**: `dimensions` and `metric` (`cosine` | `euclidean` | `dot-product`) are immutable once the index is created. To change them, run `tamer destroy --resource <logicalName>` (or scope to the kind) and re-apply. The generated wrangler config emits `vectorize[]` bindings using the derived index name.
203
+ - **AI Gateway**: gateways are account-scoped and have **no Wrangler binding kind** — Workers reference them per-request via `env.AI.run(model, opts, { gateway: { id } })` (or by URL on the OpenAI-compatible endpoint). Tamer therefore emits no wrangler fragment for AI Gateway; use the cross-resource ref `${tamer:ai_gateway:<logical>.name}` (or `.id`) in worker `vars` to inject the derived gateway id. Derived id pattern: `aigw-{logical}-t-{tenantId}-{env}`. All listed `cacheTtl` / `rateLimiting*` / `authentication` / `cacheInvalidateOnUpdate` / `collectLogs` fields are optional with sensible defaults (caching off, rate limiting off, logs on, fixed-window).
204
+ - **Pipelines (V1, SQL)**: Tamer creates the pipeline against `/accounts/{id}/pipelines/v1/pipelines` and emits `pipelines[]` in the generated wrangler config. The `sql` field is mandatory and references streams (sources) and sinks (destinations) **by name** — those upstream/downstream resources are not Tamer-managed in this iteration; create them via the Cloudflare dashboard or Wrangler before applying, otherwise the pipeline will exist in non-running status. Pipeline SQL changes on existing pipelines require `tamer destroy --resource <logicalName>` followed by re-apply (Cloudflare V1 has no PATCH endpoint today). Derived name pattern: `pipe-{logical}-t-{tenantId}-{env}`. Default binding key: `PIPE_{LOGICAL}_T_{TENANT}` (override with `binding`).
205
+ - **Workflows**: Tamer registers the workflow with Cloudflare via `PUT /accounts/{id}/workflows/{name}` and emits `workflows[]` in the generated wrangler config. `className` is the exported class on the owning worker; `script_name` defaults to that worker's deployed script (env-suffixed for shared envs), so workflow classes co-located with their owning worker need no extra config. Set `scriptName` explicitly to bind to a different worker. Optional `limits.steps` (positive integer) is tracked in state and reapplied on drift. Derived name pattern: `wf-{logical}-t-{tenantId}-{env}`. Default binding key: `WF_{LOGICAL}_T_{TENANT}` (override with `binding`). Workflow registrations are immutable in shape but mutable in target — class-name or script-name changes are issued as in-place PUTs; deletes go through `DELETE /accounts/{id}/workflows/{name}` and `tamer destroy` removes them transactionally.
206
+ - **DNS records**: declared at the **stack root** (not per worker), since the same record shouldn't be redeclared by every worker that happens to live behind it. Add `dnsRecords[]` directly on the `defineConfig({ ... })` document. Tamer creates each record via `POST /zones/{zoneId}/dns_records` and stamps a stable attribution comment (`tamer:<tenantId>:<env>:<logicalName>`) so subsequent `sync` / `apply` runs can re-adopt the record from a Cloudflare listing even after state loss. Mutable-field drift (`content`, `ttl`, `proxied`, `priority`, `comment`) is patched in place via `PATCH /zones/{zoneId}/dns_records/{recordId}`; `type` changes follow Cloudflare's delete-and-recreate convention. State key `dns_record:{zoneId}:{type}:{name}`. The `local` env is always implicitly skipped (DNS is a real-world side effect — `wrangler dev` does not own real DNS); use `skipEnvs` to opt out of additional envs. Set `preserveOnDestroy: true` on a record to keep it on Cloudflare past `tamer destroy` (the state row is dropped either way for clean teardown). Requires the **DNS: Edit** zone-scoped permission for every zone referenced in `dnsRecords[]`.
207
+ - **Secrets Store**: Tamer manages account-scoped **stores** (containers) — never the secret values inside them. On `apply`, each declared `secretsStores[]` entry is created via `POST /accounts/{id}/secrets_store/stores` (idempotent — a matching name in `secretsStoreListAll` short-circuits to the existing `id`) and the assigned id is recorded in state. To wire a stored secret into a worker, declare a `secretsStoreSecrets[]` entry referencing the store by its logical name (`store: "apiKeys"`); the wrangler generator resolves it to the live `store_id` and emits a `secrets_store_secrets[]` row with `{ binding, store_id, secret_name }`. The named secret itself must already exist in the store — create it out-of-band via `wrangler secrets-store secret create` (or the dashboard) so secret material never enters `tamer.config.ts` or `tamer-state-*`. Derived store name: `sec-{logical}-t-{tenantId}-{env}`. Cross-resource refs supported (`${tamer:secret_store:<logical>.name|id|binding}`). `tamer destroy` removes managed stores transactionally — Cloudflare cascades the deletion to all contained secrets, so make sure no live worker still binds into the store first.
208
+
209
+ ### Stack-scoped DNS records (`dnsRecords`)
210
+
211
+ Declared once on the `defineConfig({ ... })` root, not per worker. Each entry pins the zone (`zoneId`), the record `type`, the DNS `name`, and the `content`; everything else is optional.
212
+
213
+ ```ts
214
+ import { defineConfig } from "@dragonmastery/tamer";
215
+
216
+ export default defineConfig({
217
+ workers: { /* ... */ },
218
+ dnsRecords: [
219
+ {
220
+ logicalName: "apex",
221
+ zoneId: "0123456789abcdef0123456789abcdef",
222
+ type: "A",
223
+ name: "todo.com",
224
+ content: "192.0.2.1",
225
+ proxied: true,
226
+ },
227
+ {
228
+ logicalName: "www",
229
+ zoneId: "0123456789abcdef0123456789abcdef",
230
+ type: "CNAME",
231
+ name: "www.todo.com",
232
+ content: "todo.com",
233
+ proxied: true,
234
+ },
235
+ {
236
+ logicalName: "spf",
237
+ zoneId: "0123456789abcdef0123456789abcdef",
238
+ type: "TXT",
239
+ name: "todo.com",
240
+ content: "v=spf1 -all",
241
+ ttl: 3600,
242
+ comment: "anti-spoofing baseline", // appended after Tamer's attribution comment
243
+ },
244
+ {
245
+ logicalName: "mailRouting",
246
+ zoneId: "0123456789abcdef0123456789abcdef",
247
+ type: "MX",
248
+ name: "todo.com",
249
+ content: "mail.todo.com",
250
+ priority: 10,
251
+ preserveOnDestroy: true, // survive `tamer destroy`
252
+ },
253
+ {
254
+ logicalName: "stagingApex",
255
+ zoneId: "0123456789abcdef0123456789abcdef",
256
+ type: "A",
257
+ name: "todo.com",
258
+ content: "192.0.2.2",
259
+ skipEnvs: ["dev", "prod"], // only created in `staging`
260
+ },
261
+ ],
262
+ });
263
+ ```
264
+
265
+ ### Cross-resource references (`${tamer:...}`)
266
+
267
+ CloudFormation `!Ref` / `!GetAtt` analogue. Embed `${tamer:<kind>:<logicalName>.<field>}` in worker `vars` or in `tamerRoutes[].host` / `tamerRoutes[].zone` to interpolate values from already-applied state at wrangler-config generation time.
268
+
269
+ | Kind | `name` (default) | `id` | `binding` |
270
+ | --------------------- | ----------------------------- | --------------------------------- | ---------------------- |
271
+ | `d1` | derived database name | D1 UUID | wrangler binding key |
272
+ | `r2` | derived bucket name | *(unsupported — buckets use name)*| wrangler binding key |
273
+ | `kv` | derived namespace title | KV namespace id | wrangler binding key |
274
+ | `queue` | derived queue name | queue id | wrangler binding key |
275
+ | `hyperdrive` | derived config name | hyperdrive config id | wrangler binding key |
276
+ | `vectorize` | derived index name | vectorize index id | wrangler binding key |
277
+ | `ai_gateway` | derived gateway id | derived gateway id | stable cross-ref key (no wrangler binding) |
278
+ | `pipeline` | derived pipeline name | server-assigned pipeline id | wrangler binding key |
279
+ | `workflow` | derived workflow name | server-assigned workflow id | wrangler binding key |
280
+ | `secret_store` | derived store name | server-assigned store id | stable cross-ref key (no wrangler binding) |
281
+ | `dispatch_namespace` | resolved per-env namespace | namespace name | — |
282
+ | `worker` | env-suffixed deployed script | — | — |
283
+
284
+ Examples:
285
+
286
+ ```ts
287
+ defineWorker({
288
+ resources: { r2: [{ logicalName: "assets" }], queues: [{ logicalName: "events" }] },
289
+ vars: {
290
+ ASSETS_BUCKET: "${tamer:r2:assets.name}",
291
+ EVENTS_QUEUE: "${tamer:queue:events.name}",
292
+ INTERPOLATED: "queue=${tamer:queue:events.name};kv=${tamer:kv:cache.id}",
293
+ },
294
+ tamerRoutes: [
295
+ // hosts/zones can also embed references resolved against state
296
+ { host: "api-${tamer:worker:edge.name}.todo.com", zone: "todo.com" },
297
+ ],
298
+ });
299
+ ```
300
+
301
+ Resolution behavior:
302
+
303
+ - **Strict mode** (`apply`, `deploy`, `destroy`): unresolved references throw `TamerReferenceError` with the field path (e.g. `worker[default].vars.ASSETS_BUCKET`) and a "run apply first" hint, so a typo in `<logicalName>` or a forgotten apply fails fast.
304
+ - **Tolerant mode** (`plan`, `drift`, `status`, `sync`): unresolved references are left in place as their original `${tamer:...}` placeholder, so read-only commands work on a fresh checkout before the first apply.
305
+
306
+ ### Stack outputs (`outputs`)
307
+
308
+ CloudFormation `Outputs` analogue: declare named values your stack publishes after a successful `apply`. Each value is a Tamer reference string (`${tamer:<kind>:<logical>.<field>}`) — full-string OR interpolated — resolved against the just-completed state and persisted under `CfiState.stackOutputs` so it survives across runs and is visible to `tamer status` (and to sibling stacks via `${tamer:import:<stack>.<output>}`, shipping next).
309
+
310
+ ```ts
311
+ export default defineConfig({
312
+ tenant: { id: "platform", name: "Platform", slug: "ext" },
313
+ worker: { main: "src/index.ts", resources: { d1: [{ logicalName: "users", type: "single" }] } },
314
+ outputs: {
315
+ usersDbId: "${tamer:d1:users.id}",
316
+ eventsQueue: "${tamer:queue:events.name}",
317
+ adminUrl: "https://admin.example.com/?db=${tamer:d1:users.id}",
318
+ pinnedLiteral: "v1.0.0",
319
+ },
320
+ });
321
+ ```
322
+
323
+ Behavior:
324
+
325
+ - Output **names** must match `^[a-zA-Z][a-zA-Z0-9_-]*$` (CloudFormation-style identifier — keeps them safe in env-var exports, filenames, and the `${tamer:import:…}` parser). Validated at config-load time.
326
+ - Resolution runs at the **end** of a successful `apply`, after every resource is in state. A typo in any output (unknown logical name, unknown field, unknown kind) fails the apply with `TamerReferenceError` tagged `outputs.<name>` and rolls back when `--rollback-on-failure` is set.
327
+ - Persisted entry shape: `{ value, source, resolvedAt }` — keeps both the resolved literal and the original `${tamer:...}` source so `status` can flag drift.
328
+ - `tamer status` prints a per-output row tagged `resolved` (declared source matches persisted source), `pending` (declared but never applied), `stale` (config edit since last apply — re-run apply), or `orphan` (persisted but no longer declared in config).
329
+ - `tamer destroy` clears `stackOutputs` after teardown so a future `status` doesn't surface refs to deleted resources.
330
+ - Re-applies are structurally idempotent: when value + source are unchanged, `resolvedAt` is updated in memory but state isn't dirtied (no gratuitous `revision` bump).
331
+
332
+ ### Cross-stack imports (`${tamer:import:<stack>.<output>}`)
333
+
334
+ CloudFormation `Fn::ImportValue` analogue. One stack publishes named values via `outputs:` (above); a sibling stack consumes them by referencing `${tamer:import:<stack>.<output>}` anywhere a regular `${tamer:...}` reference is allowed (worker `vars`, `tamerRoutes[].host` / `.zone`, **`services[].service`**, **`dispatch_namespaces[].namespace`**, even another stack's `outputs`). Multiple stacks now coexist in the same `tamer-state-<env>` D1 by namespacing the state row as `cfi_state:{stackName}`, so the consumer just reads the producer's row and looks up the named output.
335
+
336
+ ```ts
337
+ // fixtures/network/tamer.config.ts (the "producer")
338
+ export default defineConfig({
339
+ stack: { name: "net", description: "Network plane: shared edge router + queue" },
340
+ tenant: { id: "platform", name: "Platform", slug: "net" },
341
+ worker: { main: "src/edge.ts", resources: { queues: [{ logicalName: "events" }] } },
342
+ outputs: {
343
+ edgeQueue: "${tamer:queue:events.name}",
344
+ region: "iad",
345
+ },
346
+ });
347
+
348
+ // fixtures/app/tamer.config.ts (the "consumer")
349
+ export default defineConfig({
350
+ stack: { name: "app" },
351
+ tenant: { id: "platform", name: "Platform", slug: "app" },
352
+ worker: {
353
+ main: "src/index.ts",
354
+ vars: {
355
+ EDGE_QUEUE: "${tamer:import:net.edgeQueue}",
356
+ REGION: "${tamer:import:net.region}",
357
+ QUEUE_URL: "https://${tamer:import:net.region}.example.com/q/${tamer:import:net.edgeQueue}",
358
+ },
359
+ tamerRoutes: [{ host: "${tamer:import:net.region}.app.example.com", zone: "example.com" }],
360
+ },
361
+ outputs: {
362
+ region: "${tamer:import:net.region}", // republish a sibling's value
363
+ },
364
+ });
365
+ ```
366
+
367
+ Behavior:
368
+
369
+ - **Stack identity.** `stack: { name?, description? }` pins the producer's row key; `name` defaults to `tenant.slug`, so existing single-stack configs keep working unchanged. Output names match `^[a-zA-Z][a-zA-Z0-9_-]*$` (same as `outputs:`).
370
+ - **Pre-fetch once per command.** At command start (`apply`, `deploy`, `dev`, `migrate`, `types`, `plan`, `drift`, `sync`, `status`) Tamer scans the config for `${tamer:import:…}` sites, hydrates a read-only `StateManager` per referenced sibling, and flattens every `stackOutputs.<output>.value` into an in-memory `imports` map. Subsequent reference resolution is a pure map lookup — no D1 round-trips mid-build, no partial-failure modes.
371
+ - **Strict at write time** (`apply`, `deploy`): a typo, a sibling that hasn't been applied yet, or an output the sibling never published all fail with `TamerReferenceError` tagged with the field path (e.g. `worker[default].vars.EDGE_QUEUE`) and an `Available outputs on stack "<name>": …` hint. **Tolerant at read time** (`plan`, `drift`, `status`, `sync`): unresolved import refs stay in place as their literal `${tamer:import:…}` placeholder so a fresh checkout works before the first apply.
372
+ - **Self-imports are filtered.** A stack that mistakenly references its own name in `${tamer:import:…}` is dropped at scan time so the typo doesn't silently resolve via the row apply is about to write — it surfaces as the same "no imported stack available" error a missing sibling would produce.
373
+ - **`tamer status` shows imports.** A new "Inbound imports" panel lists every `${tamer:import:…}` site grouped by source stack, with the resolved value (or `(missing — run \`tamer apply\` on that stack)`) and a per-output `resolved` / `unresolved` tag — the operator-facing equivalent of CloudFormation's stack-export view.
374
+ - **Greenfield row key.** No compat alias for the legacy literal `cfi_state` row key — first run on an env D1 created by an older `bootstrap` will see no state and rebuild from `sync`.
375
+ - `local` env skips import pre-fetch entirely (no shared state DB exists), so cross-stack composition is a non-`local` deployment concern.
376
+
377
+ ### Adding a new resource kind (resource registry)
378
+
379
+ Every Tamer-managed Cloudflare resource (D1, R2, KV, Queues, Hyperdrive, Vectorize, ...) is wired through a single registry at `src/core/registry/registry.ts`. The CLI commands (`apply`, `sync`, `drift`, `destroy`, `status`, `import`) and the wrangler generator iterate `resourceModules` — none of them special-case any kind.
380
+
381
+ To add a new kind (e.g. Pipelines, Workflows, Pages):
382
+
383
+ 1. Add the config + state types to `src/types.ts` (`<Kind>ResourceConfig`, `<Kind>StateEntry`, append to `WorkerResources` and `StateEntry`).
384
+ 2. Implement the per-feature module under `src/features/<kind>/` (`<kind>.apply.ts`, `<kind>.sync.ts`, `<kind>.drift.ts`, `<kind>.destroy.ts`, `<kind>.status.ts`, `<kind>.generate.ts`).
385
+ 3. Author `src/features/<kind>/<kind>.module.ts` exporting a `ResourceModule` (see `src/core/registry/types.ts`); it wires the per-feature functions into the generic `apply` / `sync` / `drift` / `destroy` / `status` / `generate` / `importOne` / `pickResources` / `fetchAll` shape and declares its `kind`, `label`, `configKey`, and `stateEntryType`.
386
+ 4. Append the module to `resourceModules` in `src/core/registry/registry.ts`.
387
+
388
+ That's it — every command picks the kind up automatically and `tamer status`, `tamer drift`, `tamer plan`, `tamer import --kind <kind>`, and the generated `wrangler.json` will all include it.
389
+
390
+ ### Routes (`tamerRoutes`)
391
+
392
+ Workers can declare HTTP routes that Tamer expands per env per [handoff §6](docs/handoff.md):
393
+
394
+ - **prod / production** → bare apex (`todo.com`, `admin.platform.com`).
395
+ - **other shared envs** (`dev`, `staging`, …) → `{env}.{apex}` (`dev.todo.com`).
396
+ - **ephemeral envs** (any env matching `tenant.ephemeralEnvPattern` in `tamer.config.ts` — e.g. `"^pr-"` for `pr-1234`) → `{env}.{apex}` (`pr-1234.todo.com`).
397
+ - **local** → no route.
398
+
399
+ ```ts
400
+ defineWorker({
401
+ scriptName: "portal-ui",
402
+ tamerRoutes: [
403
+ { host: "portal.todo.com" }, // → {env}.portal.todo.com/* (zone_name=portal.todo.com)
404
+ { host: "api.todo.com", zone: "todo.com" }, // explicit parent zone
405
+ { host: "todo.com", customDomain: true }, // CustomDomainRoute
406
+ ],
407
+ });
408
+ ```
409
+
410
+ How routes land in Wrangler vs the Cloudflare API:
411
+
412
+ - **Zone-name routes** (`zone_name` + `pattern`, including default `tamerRoutes` expansion) are **not** written into generated `wrangler.json`. They are tracked as `worker_route` entries in `tamer-state-{env}` and applied with the [Workers Routes API](https://developers.cloudflare.com/api/resources/workers/subresources/routes/) **after** a successful `wrangler deploy` for that script (so the worker exists before the route binds). `tamer sync` reconciles listings into state; `tamer destroy` removes API routes before deleting workers; `tamer drift` reports mismatches for these routes.
413
+ - **Custom-domain `tamerRoutes`** (`customDomain: true`) and any **static `routes`** you declare in worker config are still merged into generated `wrangler.json` and deployed by Wrangler only.
414
+
415
+ To opt out per env, set `skipEnvs` (default `["local"]`) or `prodEnvs` (default `["prod", "production"]`). Tamer never strips or rewrites the wrangler-native `routes` field — declare both if you need static escape-hatch routes alongside `tamerRoutes`.
416
+
417
+ ### Workspace tenants (runtime)
418
+
419
+ `CfiState` (D1 `tamer-state-{env}`) includes a `tenants` map keyed `product:workspace` for signup-time resources **not** declared in `tamer.config.ts`.
420
+
421
+ - `tamer provision-tenant --env dev --product todo --workspace acme --main ./worker.js` — for every role declared in `tenant.d1Shards` in `tamer.config.ts`, creates the per-tenant D1 (`db_{role}_{w}_{p}_t_{tid}_{env}`); then uploads the dispatch script to the first configured dispatch namespace and records `TenantStateEntry` as `ready`. Tamer ships **no built-in shard layout** — the engine is opinion-free, so a Dragoncore-style product picks `d1Shards: ["system", "app", "history"]`, a single-DB tenant picks `["main"]`, a billing/content split picks `["billing", "content"]`, and a tenant that needs no per-tenant DB at all simply omits `d1Shards` and gets the dispatch script alone. Use `--artifact-key <path/in/tamer-artifacts-{env}>` instead of `--main` to deploy from R2. Pass `--shards a,b` to **trim** the configured layout to a subset for ephemeral previews; the CLI flag cannot extend the configured set (config is the source of truth — typos surface with the configured roles in the error message). Re-runs are idempotent: existing shards are adopted, missing shards are added in place, so editing `tenant.d1Shards` to add a new role and re-running picks up the new shard without disturbing the others. Pass `--json` for a single trailing JSON line on stdout `{ status: "ready"|"noop"|"failed", tenantKey, scriptName, dispatchNamespaceName, shards: [{role, derivedName, cfId}] }` — designed for the Cloudflare Container caller (`provision-workflow`, see [container invocation contract](#container-invocation-contract)).
422
+ - `tamer destroy-tenant --env dev --product todo --workspace acme --confirm-tenant acme` — deletes the dispatch script, every tenant D1 shard recorded in state, and removes the tenant record (shared envs require confirmation or `--force`). Pass `--json` for `{ status: "destroyed"|"noop"|"failed", removed: { scriptName, dispatchNamespaceName, shards }, errors }`.
423
+ - `tamer status --env dev --tenant todo:acme` — shows provisioning status and bound resources.
424
+
425
+ #### Container invocation contract
426
+
427
+ The tenant runtime commands are designed to be invoked by a Cloudflare Container (`provision-workflow` per [`docs/handoff.md`](docs/handoff.md) §7) — same image CI uses (`Dockerfile`). Build and run:
428
+
429
+ ```bash
430
+ docker build -t tamer .
431
+ docker run --rm \
432
+ -e CLOUDFLARE_ACCOUNT_ID -e CLOUDFLARE_API_TOKEN \
433
+ -v "$PWD":/work -w /work \
434
+ tamer provision-tenant --env dev --product todo --workspace acme \
435
+ --artifact-key todo/worker.js --json
436
+ ```
437
+
438
+ Contract:
439
+
440
+ - **Exit code is the source of truth.** `0` on success or no-op, non-zero on failure. `provision-workflow` should branch on this, not on log scraping.
441
+ - **Stdout's last line is a JSON envelope** when `--json` is passed (other lines remain human-readable). Failure path also emits an envelope (`status: "failed"`, `error: <message>`) before the non-zero exit.
442
+ - **No interactive prompts.** Shared-env safety still applies — destroy callers must pass `--confirm-tenant <workspace>` (or `--force`) explicitly.
443
+ - **Idempotent.** Workflow retries are safe: re-invoking `provision-tenant` with the same args produces `status: "noop"` once the tenant is `ready`; partial-failure resumes pick up from the last persisted shard.
444
+
445
+ If two writers update state concurrently, `persist` throws `StateConflictError` and the CLI exits with code **3**; re-run after refreshing state.
446
+
447
+ ### Plan (`tamer plan`)
448
+
449
+ CloudFormation-style preview of `apply` + `deploy`. Reads only — never mutates state or Cloudflare. Shows every D1 / R2 / KV / dispatch namespace / Workers zone route declared in `tamer.config.ts` that isn't yet on Cloudflare, plus declared **global Worker scripts** that have no deployment (queried directly via `GET /accounts/.../workers/scripts/{name}`; dispatch-namespace tenant scripts are skipped — those belong to `provision-tenant` / WFP).
450
+
451
+ ```bash
452
+ tamer plan --env dev # human-readable summary, exit 0
453
+ tamer plan --env dev --json # machine-readable for CI
454
+ tamer plan --env dev --detailed-exitcode
455
+ # ↑ exit 0 if no changes, 2 if changes pending (Terraform convention),
456
+ # non-zero on errors as usual
457
+
458
+ tamer plan --env dev --out plan.json # also persist a verifiable plan file
459
+
460
+ tamer plan --env dev --destroy # preview what `tamer destroy` would remove
461
+ tamer plan --env dev --destroy --json --detailed-exitcode
462
+ # ↑ exit 2 when there is anything to delete (CI gate before stack teardown)
463
+ ```
464
+
465
+ **Scoped plan / apply (`--target`)** — Limit preview or provisioning to a single declared resource: `--target <kind>:<logicalName>` (e.g. `d1:settings`, `r2:assets`, `dispatch_namespace:workspace`, `dns_record:apex_txt`). Uses the same kind strings as `tamer import --kind`. A scoped **`tamer apply`** still runs sync, still regenerates **every** worker’s `wrangler.json`, and still resolves the full `outputs:` block at the end so state stays coherent. **`--target` is incompatible with `apply --plan` and `plan --out`** — saved plan files attest the whole stack. `worker_route` and `worker_script` are not supported here (zone routes apply after **`tamer deploy`**; scripts are deployed by wrangler).
466
+
467
+ The plan reuses the `drift` engine for "would create" detection, so the rules are the same: drift filters by current-stack `logicalName`s in shared-state setups, and `local` envs only show storage that would be created in-memory.
468
+
469
+ For tracked resources whose recorded state has drifted from the declared config, `tamer plan` also emits `update` and `replace` items with terraform-style attribute change detail:
470
+
471
+ ```text
472
+ DNS records (1):
473
+ ~ apex -> A todo.com (content: 192.0.2.1 -> 192.0.2.99, proxied: false -> true)
474
+ Vectorize (1):
475
+ ± embeddings -> vec-embeddings-t-acme-dev (dimensions: 768 -> 1536, metric: cosine -> euclidean)
476
+
477
+ Summary: 0 to create, 1 to update, 1 to replace.
478
+ ```
479
+
480
+ `+` is `create`, `~` is `update` (in-place PATCH on apply), `±` is `replace` (Cloudflare's API rejects PATCH on the changed field, so apply must delete + recreate — DNS record `type` change, Vectorize `dimensions` / `metric`, Pipelines V1 `sql`), `-` is `delete` (destroy plan only). `--json` includes the same diffs as `items[].changes[]: { field, from, to, kind: "mutable" | "immutable" }`. Today the `update` / `replace` engine covers DNS records, Workflows, Vectorize, and Pipelines; other kinds emit only `create` / `delete`.
481
+
482
+ #### Destroy preview (`tamer plan --destroy`)
483
+
484
+ Mutually exclusive with the forward plan. Walks `tamer-state-{env}`, filters to entries owned by the current `tamer.config.ts` stack (same scoping as `tamer destroy` / `tamer drift`), and emits a `delete` `PlanItem` per managed kind — D1, R2, KV, Queues, Hyperdrive, Vectorize, AI Gateway, Pipelines, Workflows, Secrets Stores, dispatch namespaces, DNS records, and zone `tamerRoutes`. Declared global Worker scripts that currently exist on Cloudflare also appear as `worker_script` deletes. Dispatch-namespace tenant scripts are intentionally excluded — those are managed via `provision-tenant` / `destroy-tenant`. Read-only: never mutates state or Cloudflare.
485
+
486
+ `--out destroy.json` writes the destroy plan with the same `(config, state, cloudflare)` attestation as forward plans (`report.mode: "destroy"`); `tamer destroy --plan destroy.json` then recomputes the three hashes and refuses to proceed unless they all match (override with `--allow-stale`). Mode mismatch is **non-overridable**: `apply --plan` rejects destroy plans and `destroy --plan` rejects forward plans regardless of `--allow-stale`, so a saved plan can only ever execute the operation the operator reviewed.
487
+
488
+ ```bash
489
+ tamer plan --env dev --destroy --out destroy.json
490
+ git diff destroy.json && code review ...
491
+ tamer destroy --env dev --confirm-env dev --plan destroy.json
492
+ # Refuses with one of:
493
+ # "config has changed since plan was generated; ..."
494
+ # "recorded state has changed since plan was generated; ..."
495
+ # "Cloudflare-side resources drifted since plan was generated (out-of-band create/delete); ..."
496
+ tamer destroy --env dev --confirm-env dev --plan destroy.json --allow-stale
497
+ ```
498
+
499
+ #### Saved plans + transactional apply
500
+
501
+ `--out plan.json` writes a `tamer-plan/v1` document containing the plan report and an **attestation**: SHA-256 hashes of (a) the parsed config, (b) the recorded state's resources/tenants/stack (timestamps and revision counters are excluded so legitimate noise doesn't trigger false mismatches), and (c) the **live Cloudflare snapshot** at plan time — the per-kind `fetchAll` + `sync` result against a fresh in-memory state, with timestamps stripped. `cloudflareHash` is omitted for `env: local` and for legacy plans written before drift-aware refresh.
502
+
503
+ `tamer apply --plan plan.json` recomputes all three hashes against the current `(config, state, live Cloudflare)` triple and refuses to proceed if any drifted, mirroring `terraform apply <planfile>` plus `terraform refresh`. Out-of-band creates or deletes on Cloudflare between plan and apply are caught here; the recorded state alone wouldn't notice. Pass `--allow-stale` only when you've reviewed the diff and want to override:
504
+
505
+ ```bash
506
+ tamer plan --env dev --out plan.json
507
+ git diff plan.json && code review ...
508
+ tamer apply --env dev --plan plan.json
509
+ # Refuses with one of:
510
+ # "config has changed since plan was generated; ..."
511
+ # "recorded state has changed since plan was generated; ..."
512
+ # "Cloudflare-side resources drifted since plan was generated (out-of-band create/delete); ..."
513
+ tamer apply --env dev --plan plan.json --allow-stale # opt-in override
514
+ ```
515
+
516
+ `tamer apply --rollback-on-failure` snapshots state-entry keys at start and, on any failure, walks every key created during the run in reverse insertion order and asks the owning module's `destroyOne` to delete the underlying Cloudflare resource (best-effort; failures log a warning and never mask the original error). Use it in CI to keep your account clean when an apply fails halfway:
517
+
518
+ ```bash
519
+ tamer apply --env dev --plan plan.json --rollback-on-failure
520
+ ```
521
+
522
+ ### Import (`tamer import`)
523
+
524
+ CloudFormation `import_resources` analogue. When state is missing one specific resource that already exists on Cloudflare (created out-of-band, or after a partial restore), `import` registers it under its logical name without touching the resource itself. Verifies the Cloudflare object exists and that the cf-side name matches Tamer's derived name for `--logical` before writing the state entry.
525
+
526
+ ```bash
527
+ tamer import --env dev --kind d1 --logical settings --cf-id <d1-uuid>
528
+ tamer import --env dev --kind d1 --logical events --shard-date 2026-01-15 --cf-id <d1-uuid>
529
+ tamer import --env dev --kind r2 --logical assets --cf-id r2-assets-t-acme-dev
530
+ tamer import --env dev --kind kv --logical cache --cf-id <kv-namespace-id>
531
+ tamer import --env dev --kind queue --logical events --cf-id <queue-id>
532
+ tamer import --env dev --kind hyperdrive --logical primary --cf-id <hyperdrive-config-id>
533
+ tamer import --env dev --kind dispatch_namespace --logical workspace-workers --cf-id workspace-workers-dev
534
+ tamer import --env dev --kind worker_route --logical portal_ui --route-id <route-id> --zone-name portal.todo.com
535
+ tamer import --env dev --kind dns_record --logical apex --cf-id <dns-record-id>
536
+ ```
537
+
538
+ Use `tamer sync` for bulk discovery instead. `import` refuses to overwrite an existing state row that points at a different Cloudflare id.
539
+
540
+ ### Drift (`tamer drift`)
541
+
542
+ Read-only diff between Tamer state, Cloudflare reality, and the current `tamer.config.ts`. Useful for CI guards and pre-deploy sanity checks; never writes to state or to Cloudflare.
543
+
544
+ For each managed resource kind (D1, R2, KV, **Queues**, **Hyperdrive**, dispatch namespaces, **HTTP routes** when you use zone-name `tamerRoutes`, and **Worker scripts** for declared global workers) the report lists three buckets:
545
+
546
+ - **`missingFromCloudflare`** — state references a resource that no longer exists on Cloudflare (deleted out-of-band; consider rerunning `apply` or pruning state).
547
+ - **`unrecordedInState`** — Cloudflare has a resource that matches a declared logical name in this stack's config, but no state entry tracks it (run `tamer sync`).
548
+ - **`undeployed`** — declared in this stack's config, present in neither state nor Cloudflare (run `tamer apply`).
549
+
550
+ ```bash
551
+ tamer drift --env dev # human-readable; non-zero exit when drift
552
+ tamer drift --env dev --json # machine-readable for CI
553
+ ```
554
+
555
+ Drift filters by the current config's `logicalName`s, so multi-stack environments sharing one `tamer-state-<env>` only report on resources owned by the current stack. Zone-name `tamerRoutes` appear under **HTTP routes (Workers Routes API)**; custom-domain and wrangler-only `routes` are not checked here (Wrangler remains their source of truth). The **Worker scripts** section only emits `undeployed` (declared but missing on Cloudflare) since global script ids aren't tracked in state today.
556
+
557
+ ### State and artifacts
558
+
559
+ For non-`local` envs, `tamer bootstrap --env <env>` provisions two pieces of per-env metadata Tamer owns directly:
560
+
561
+ - **`tamer-state-<env>` (D1):** stores deployment state as JSON (logical resources mapped to Cloudflare IDs — D1 UUIDs, KV IDs, dispatch namespaces, Workers zone route ids for API-managed `tamerRoutes`, etc.) plus optional CloudFormation-style **stack metadata** (`stack.name`, `stack.owner`) and a **last operation marker** (`lastOperation`: command name, `in_progress` / `succeeded` / `failed`, timestamps, error message) updated by `bootstrap` / `apply` / `deploy` / `destroy` / `import`. Each successful or failed run also appends a snapshot to **operationHistory** (newest first, capped at 50 entries) so operators can review recent changes. **`tamer events`** (read-only) prints that timeline, optional **`--limit N`**, and **`--json`** for automation. `sync` merges API listings into this document; Cloudflare remains the source of truth for what exists. State schema is on **v4** (auto-migrates v2/v3 in place on read). Each stack's document lives at row key `cfi_state:{stackName}` (where `stackName` comes from `stack: { name? }` in `tamer.config.ts`, defaulting to `tenant.slug`), so multiple stacks can share the same `tamer-state-<env>` D1 without overwriting each other — required by `${tamer:import:<stack>.<output>}` cross-stack references.
562
+ - **`tamer-artifacts-<env>` (R2):** holds Tamer-managed bundles keyed by `{resource-type}/{name}/{version}/...`. Used by future runtime provisioning paths (read-only at deploy time today).
563
+
564
+ Multi-stack flows (`fixtures/platform`, `fixtures/portal`, and `fixtures/internal`) share one `tamer-state-<env>` and one `tamer-artifacts-<env>` per account when you use the normal bring-up scripts; `tamer:down` passes `--wipe-metadata` only on the **platform** destroy (`fixtures/platform`) so the metadata DB and artifacts bucket are removed once at the end. The bucket delete is best-effort — if it still has objects, the operator must clean them and re-run `--wipe-metadata`.
565
+
566
+ See **[Runbook](docs/runbook.md)** for the **platform + portal + internal** fixtures (spin-up, tear-down, flags).
567
+
568
+ ## Developing
569
+
570
+ From the repo root after **`bun install`**:
571
+
572
+ - **CLI:** **`bun run tamer -- <command>`** (root `package.json` → `bun src/cli/index.ts`) or invoke **`bun src/cli/index.ts`** directly.
573
+ - **Fixtures:** reference stacks under **`fixtures/platform`**, **`fixtures/portal`**, and **`fixtures/internal`**. Bring up / tear down with **`bun run tamer:up`** / **`bun run tamer:down`** — see **[Runbook](docs/runbook.md)**.
574
+ - **Tests:** **`bun run typecheck`** and **`bun run test`**. CI uses **`bun run test:report`** (JUnit → **`reports/junit.xml`**, dots output). Coverage: **`bun run test:coverage`** → **`coverage/lcov.info`**. Details: **[Testing](docs/testing.md)**.
575
+ - **CI:** **[`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)** — typecheck + unit tests on pushes and PRs to **`main`** / **`apex`**.
576
+ - **Publish:** npm releases via **[`.github/workflows/publish-npm.yaml`](.github/workflows/publish-npm.yaml)** (Trusted Publishing / OIDC). Packaging: **[npm-cli-packaging-strategy.md](docs/npm-cli-packaging-strategy.md)**. Cross-repo contracts: **[publish.md](docs/publish.md)**.
577
+
578
+ Fixture smoke test (requires **`.env`** under each fixture directory):
579
+
580
+ ```bash
581
+ bun run tamer:up -- --env dev
582
+ # …
583
+ bun run tamer:down -- --env dev --confirm-env dev
584
+ ```
585
+
586
+ ## Documentation
587
+
588
+ | Doc | Audience |
589
+ | --- | --- |
590
+ | **[Consumer quickstart](docs/consumer-quickstart.md)** | Downstream repos — install from npm, first deploy, version pinning |
591
+ | **[Testing](docs/testing.md)** | Contributors — test runners, JUnit, coverage |
592
+ | **[Publish / integration requirements](docs/publish.md)** | Maintainers — cross-repo contracts for DragonMastery stacks |
593
+ | **[npm CLI packaging strategy](docs/npm-cli-packaging-strategy.md)** | Maintainers — npm package build and release layout |
594
+ | **[Runbook](docs/runbook.md)** | Contributors — platform + portal + internal fixture walkthrough |
595
+ | **[Platform architecture](docs/platform-arch.md)** | Platform owners — repos, stacks, runtime model, rollout |
39
596
 
40
- | Type | Description |
41
- | -------------------- | ------------------------------ |
42
- | `WranglerConfig` | Full wrangler config (alias: `RawConfig`) |
43
- | `WranglerEnvironment`| Environment block (alias: `RawEnvironment`) |
44
- | `WranglerD1Database` | D1 binding entry |
45
- | `WranglerR2Bucket` | R2 binding entry |
46
- | `WranglerKVNamespace`| KV binding entry |
597
+ Other material lives under [`docs/`](docs/).
47
598
 
48
599
  ## License
49
600