Skip to main content

bynk/deploy/
plan.rs

1use super::*;
2
3/// #1662: the diagnostic code a contract skew is reported under — a literal so
4/// the registry test sees it, and `bynk explain` knows it.
5const CONTRACT_SKEW: &str = "bynk.deploy.contract_skew";
6
7#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
8pub enum DeployFormat {
9    #[default]
10    Short,
11    Json,
12}
13
14#[derive(Debug, Clone, Default)]
15pub struct DeployOptions {
16    pub dry_run: bool,
17    pub format: DeployFormat,
18    pub yes: bool,
19    /// `--context NAME` — deploy this context alone, assuming the contexts it
20    /// consumes are already live (slice 2, D4). Absent deploys the whole
21    /// project in dependency order.
22    pub context: Option<String>,
23    /// `--env NAME` — slice 4. Selects the `bynk.deploy.lock` section; for any
24    /// value other than `"default"` also drives synthesis of an environment-
25    /// scoped Wrangler config section, since Cloudflare does not inherit
26    /// bindings into a named environment (confirmed against Cloudflare's own
27    /// docs — see #835). `DeployOptions::default()`'s empty string is a
28    /// test-only artefact of `#[derive(Default)]`; every real invocation goes
29    /// through the CLI, whose `default_value = "default"` guarantees this is
30    /// never empty.
31    pub environment: String,
32    /// `--secrets-file` — a dotenv-style source of `NAME=value` pairs. Supplies
33    /// **names and values** (slice 3, ADR 0195 D3).
34    pub secrets_file: Option<std::path::PathBuf>,
35    /// `--secret NAME` — a name whose *value* comes from the environment or a
36    /// prompt. The environment is never scanned for names, so this is how a
37    /// `bynk.Secrets` name reaches `deploy` without a file.
38    pub secrets: Vec<String>,
39    /// `--force` — overwrite a secret already set, rather than skipping it.
40    pub force: bool,
41    /// `--prune` (slice 5) — delete every reported KV/queue orphan, behind
42    /// its own confirmation. Never deletes a Worker (DECISION C). Defaults
43    /// to report-only, matching the track's "report or prevent, never
44    /// silently share/destroy" posture (§6).
45    pub prune: bool,
46    /// State-migrations S3 (#2248): `--confirm-delete <context>.<Class>` —
47    /// each Durable Object class whose data a `removed agent` deletes, named
48    /// back. `--yes` never stands in for it (Decision E).
49    pub confirm_delete: Vec<String>,
50    pub wrangler_args: Vec<String>,
51}
52
53/// The whole-project plan (slice 2). `order` is the upload order, dependencies
54/// first; `contexts` carries it with each context's own actions. Slice 0's
55/// single-worker `Plan` is the one-element case of this.
56#[derive(Debug, Serialize)]
57pub(crate) struct Plan<'a> {
58    environment: &'a str,
59    /// Slice 5: every ledger entry this environment no longer declares, by
60    /// its ledger key, which is what `--prune` deletes by. Not serialised:
61    /// the plan shows [`Plan::shown_orphans`].
62    #[serde(skip)]
63    orphans: Orphans,
64    /// The same orphans as the account names them (#2028): a queue or Worker
65    /// under its [`env_qualify`]d name. Printed before the per-context
66    /// breakdown, so an orphan is seen before what's actually being pushed.
67    #[serde(rename = "orphans")]
68    shown_orphans: Orphans,
69    /// The resolved upload order — the plan's headline, since Cloudflare
70    /// rejects a Worker uploaded before its binding target. Each Worker by
71    /// its [`deployed_worker_name`] (#2028).
72    order: Vec<String>,
73    contexts: Vec<ContextPlan<'a>>,
74}
75
76#[derive(Debug, Serialize)]
77struct ContextPlan<'a> {
78    /// The Worker this context deploys as: its [`deployed_worker_name`] in
79    /// this environment, the name the push hands Wrangler (#2028).
80    worker: String,
81    /// One line per KV namespace the Worker binds: the bare `KV` first, then
82    /// each derived instance in binding order (S4b, #1967). Empty for a
83    /// context with no KV.
84    kv: Vec<PlanKv<'a>>,
85    /// P5 (#2323): one line per R2 bucket the Worker binds, in binding order.
86    #[serde(skip_serializing_if = "Vec::is_empty")]
87    r2: Vec<PlanR2<'a>>,
88    /// P3 (#2342): one line per rate limiter the Worker binds, in binding
89    /// order.
90    #[serde(skip_serializing_if = "Vec::is_empty")]
91    ratelimits: Vec<PlanRateLimit<'a>>,
92    /// One line per queue this context consumes, in name order.
93    queues: Vec<PlanQueue>,
94    /// #1796: one line per Durable Object class the push declares in
95    /// `exports`, in class-name order. Empty for a context with no agent and
96    /// no events fan-out.
97    durable_objects: Vec<PlanDurableObject<'a>>,
98    /// State-migrations S3 (#2248): each class an `agent New replaces Old`
99    /// renames — the push moves its stored data. In old-name order.
100    #[serde(
101        rename = "durable_objects_renamed",
102        skip_serializing_if = "Vec::is_empty"
103    )]
104    renamed_durable_objects: Vec<PlanDurableObjectRename<'a>>,
105    /// State-migrations S3 (#2248): each class a `removed agent` deletes, as
106    /// `<context>.<Class>` — the name `--confirm-delete` takes. Irreversible.
107    #[serde(
108        rename = "durable_objects_deleted",
109        skip_serializing_if = "Vec::is_empty"
110    )]
111    deleted_durable_objects: Vec<PlanDeletion>,
112    /// One line per secret this run will set on this context, in name order.
113    secrets: Vec<PlanSecret>,
114    /// False when this context reads at least one secret the compiler cannot
115    /// name — a computed `Secrets.get` argument, or an adapter binding `given`
116    /// `bynk.Secrets` (#1976) — so `secrets` is **not** everything it reads
117    /// (ADR 0196 D2).
118    ///
119    /// Carried in the machine surface as well as the human one, because this is
120    /// the field that stops a CI job trusting a short list — the failure the
121    /// whole increment exists to prevent is a reader taking silence for absence.
122    secrets_complete: bool,
123    /// `deploy` first time, `redeploy` when the ledger has pushed it before —
124    /// the honest word, since a re-run re-pushes rather than skipping.
125    action: &'static str,
126    /// The workers this one binds to, in the emitted config, each by its
127    /// [`deployed_worker_name`] — the target the push's Service Binding names.
128    binds_to: Vec<String>,
129    /// Interop I5 (#1972): its Service Bindings to Workers outside the
130    /// project, in binding order. Bound, never provisioned.
131    foreign_services: Vec<PlanForeignService<'a>>,
132}
133
134/// Interop I5 (#1972): one Service Binding to a foreign Worker. No action:
135/// `bynk deploy` neither creates nor changes the Worker. Whether it exists is
136/// asked of the account after authenticating, so `--dry-run` stays offline
137/// and the plan never claims it.
138#[derive(Debug, Serialize)]
139struct PlanForeignService<'a> {
140    binding: &'a str,
141    worker: &'a str,
142}
143
144#[derive(Debug, Serialize)]
145struct PlanKv<'a> {
146    /// `KV` for the bare `Kv`, `KV_<SNAKE>` for a derived instance.
147    binding: &'a str,
148    action: &'static str,
149    /// The namespace's account-facing title ([`kv_title`]).
150    namespace: String,
151}
152
153/// P3 (#2342): one rate limiter. Its `namespace_id` is the ledger's to
154/// allocate, so the plan can only say whether one is recorded; the push
155/// allocates and records the missing ones.
156#[derive(Debug, Serialize)]
157struct PlanRateLimit<'a> {
158    /// `RATELIMIT_<SNAKE>`.
159    binding: &'a str,
160    /// `allocate` or `reuse`.
161    action: &'static str,
162    /// The recorded id, when `reuse`.
163    #[serde(skip_serializing_if = "Option::is_none")]
164    namespace_id: Option<String>,
165}
166
167/// P5 (#2323): one R2 bucket. The name is the identity, so there is no id to
168/// mint and `reuse` is a plain statement of the ledger.
169#[derive(Debug, Serialize)]
170struct PlanR2<'a> {
171    /// `R2_<SNAKE>`.
172    binding: &'a str,
173    /// `create`, `reuse`, or `recreate`: a changed `@jurisdiction` makes a new
174    /// bucket, and the objects of the old one do not move (see [`r2_action`]).
175    action: &'static str,
176    /// The bucket's account-facing name, [`r2_bucket_name`]d.
177    bucket: String,
178    #[serde(skip_serializing_if = "Option::is_none")]
179    jurisdiction: Option<&'a str>,
180}
181
182#[derive(Debug, Serialize)]
183struct PlanQueue {
184    /// `create` when this project has never made the queue, `reuse` when the
185    /// ledger has it. Either way the provision step attempts the create and
186    /// treats an existing queue as success, so `reuse` is a forecast — "expect
187    /// nothing new" — not a promise to stay silent (ADR 0194 D2).
188    action: &'static str,
189    /// The queue's account name, [`env_qualify`]d as the provision step
190    /// creates it (#2028).
191    queue: String,
192}
193
194/// One secret the run intends to set on one context.
195///
196/// There is deliberately **no presence field**. Presence is a live question
197/// (`wrangler secret list`), and the plan is derived before `deploy`
198/// authenticates — which is what keeps `--dry-run` working offline. So the plan
199/// says what it will *try*, and the run reports the skip when a secret turns out
200/// to be there. The ledger cannot help: it records no secret at all, because a
201/// recorded presence could only ever be a stale one (ADR 0195 D1/D4).
202#[derive(Debug, Serialize)]
203struct PlanSecret {
204    /// Owned: the name set is derived (declared ∪ supplied) rather than
205    /// borrowed from any one source.
206    name: String,
207    /// `declared` — the compiler proved a handler reads it. `supplied` — the
208    /// user named it. The mark is the floor-not-census contract made legible:
209    /// no `declared` line for a `bynk.Secrets` name does **not** mean the
210    /// context needs none (ADR 0195 D2).
211    origin: Origin,
212    /// `set`, or `overwrite` under `--force`. A `set` line may still report a
213    /// skip at wire time — see the type's note.
214    action: &'static str,
215}
216
217/// One Durable Object class the push declares (#1796). It replaces the
218/// migration-tag line ADR 0194 D1 described: with `exports` there is no tag,
219/// only a declared set that Cloudflare reconciles on every deploy.
220#[derive(Debug, Serialize)]
221struct PlanDurableObject<'a> {
222    class: &'a str,
223    /// The backend the config declares (`sqlite`), read from the file rather
224    /// than assumed, so the plan and the upload can't disagree.
225    storage: &'a str,
226    /// Always `Cloudflare`, and that is the point: the field names an owner
227    /// other than `bynk`, which is the whole content of the advisory (ADR 0194
228    /// D1's principle). A consumer reading the plan learns that this line is
229    /// not a claim that the namespace exists or doesn't, without having to
230    /// know the ADR.
231    reconciled_by: &'static str,
232}
233
234/// State-migrations S3 (#2248): one Durable Object class rename the push
235/// applies through an `exports` tombstone. `already_applied` when an earlier
236/// push in this environment carried it (#2276 Decision D): the data has moved,
237/// and the declaration is waiting for Cloudflare's `stale_tombstone`.
238#[derive(Debug, Serialize)]
239struct PlanDurableObjectRename<'a> {
240    from: &'a str,
241    to: &'a str,
242    already_applied: bool,
243}
244
245/// State-migrations S3 (#2248): one `deleted` tombstone the push carries.
246/// `already_applied` when an earlier push in this environment carried it (the
247/// ledger says so, #2253 review): the declaration is waiting for Cloudflare's
248/// `stale_tombstone`, nothing more is destroyed, and no confirmation is asked.
249#[derive(Debug, Serialize)]
250struct PlanDeletion {
251    class: String,
252    already_applied: bool,
253}
254
255/// State-migrations S3 (#2248): a class's name as `--confirm-delete` takes it,
256/// `<context>.<Class>`. A Worker's directory name is its context's qualified
257/// name with `.` replaced by `-` (`worker_dir_name`), and an identifier holds
258/// no `-`, so the context name is recovered exactly.
259fn qualified_class(worker: &str, class: &str) -> String {
260    format!("{}.{class}", worker.replace('-', "."))
261}
262
263/// The `exports` tombstones a successful push of `worker` carried, recorded
264/// so later plans show them as applied: its deletions (State-migrations S3,
265/// #2248, #2253 review) and its renames (#2276 Decision D). Each replaces what
266/// was recorded for the Worker.
267fn record_pushed_tombstones(
268    lock: &mut DeployLock,
269    environment: &str,
270    worker: &str,
271    pushed: &Resources,
272) {
273    lock.record_deleted_classes(
274        environment,
275        worker,
276        pushed.deleted_durable_objects.iter().cloned().collect(),
277    );
278    lock.record_renamed_classes(
279        environment,
280        worker,
281        pushed.renamed_durable_objects.iter().cloned().collect(),
282    );
283}
284
285/// State-migrations S3 (#2248, Decision E): every class this plan deletes must
286/// be confirmed by name — by `--confirm-delete`, or by typing it at an
287/// interactive prompt. `--yes` never stands in, and a confirmation for a class
288/// the plan does not delete is refused, so a stale CI flag cannot approve a
289/// different deletion.
290fn confirm_deletions(plan: &Plan<'_>, confirmed: &[String], lock: &DeployLock) -> bool {
291    let all: Vec<&PlanDeletion> = plan
292        .contexts
293        .iter()
294        .flat_map(|c| c.deleted_durable_objects.iter())
295        .collect();
296    // Only a pending deletion destroys anything, so only it is confirmed.
297    let deletions: Vec<&String> = all
298        .iter()
299        .filter(|d| !d.already_applied)
300        .map(|d| &d.class)
301        .collect();
302    for name in confirmed {
303        // A confirmation for a deletion already applied — in this plan, or
304        // recorded by the ledger after its declaration was removed — is
305        // harmless, so a CI flag left in place doesn't fail the next deploy.
306        let applied = all.iter().any(|d| &d.class == name)
307            || name.rsplit_once('.').is_some_and(|(context, class)| {
308                lock.has_deleted_class(plan.environment, &context.replace('.', "-"), class)
309            });
310        if !deletions.contains(&name) && !applied {
311            eprintln!("bynk: --confirm-delete {name} names no class this plan deletes; refusing");
312            return false;
313        }
314    }
315    let unconfirmed: Vec<&&String> = deletions
316        .iter()
317        .filter(|d| !confirmed.iter().any(|c| c == **d))
318        .collect();
319    if unconfirmed.is_empty() {
320        return true;
321    }
322    if !io::stdin().is_terminal() {
323        eprintln!(
324            "bynk: this deploy deletes stored agent data; confirm each class with {}",
325            unconfirmed
326                .iter()
327                .map(|d| format!("--confirm-delete {d}"))
328                .collect::<Vec<_>>()
329                .join(" ")
330        );
331        return false;
332    }
333    for name in unconfirmed {
334        eprint!(
335            "Deleting `{name}` destroys all of its stored data, irreversibly. Type `{name}` to \
336             confirm: "
337        );
338        let _ = io::stderr().flush();
339        let mut answer = String::new();
340        if io::stdin().read_line(&mut answer).is_err() || answer.trim() != name.as_str() {
341            eprintln!("bynk: `{name}` not confirmed; nothing was deployed");
342            return false;
343        }
344    }
345    true
346}
347
348/// The `--` passthrough argument that conflicts with the driver's own
349/// `--env`, if any (slice 4, DECISION E) — bare or `=`-joined, mirroring
350/// `dev.rs`'s `passthrough_has` matching rule for the same class of clash
351/// (`--port`/`--inspector-port` there). Returns the matched literal, not just
352/// a bool, so the error can name what it conflicts with. Pure, so the rule is
353/// tested without touching `DeployOptions`.
354///
355/// `pub(crate)`: `dev.rs` reuses this for the identical clash between its own
356/// `--env` (which environment's ledger section `--remote` reads) and a
357/// `-- --env`/`-- --environment` passthrough to `wrangler dev` (which
358/// environment Wrangler actually connects to) — the same "two explicit,
359/// conflicting environment selections, one of them silent" shape, just
360/// without a value `dev` forwards to wrangler itself.
361pub(crate) fn conflicting_env_passthrough(wrangler_args: &[String]) -> Option<&str> {
362    wrangler_args
363        .iter()
364        .find(|arg| {
365            ["--env", "--environment"]
366                .iter()
367                .any(|flag| arg.as_str() == *flag || arg.starts_with(&format!("{flag}=")))
368        })
369        .map(String::as_str)
370}
371
372/// #2055: the refusal for a `--secrets-file` passed through after `--` when
373/// the run includes a first deploy. That deploy uploads the Worker's secrets
374/// with its own `wrangler deploy --secrets-file`, so the passthrough would be
375/// a second, contradictory one. Matched bare or `=`-joined, as
376/// [`conflicting_env_passthrough`] matches. `None` when there is no such
377/// argument or every Worker in `order` has been deployed in `environment`
378/// (a redeploy passes nothing of its own). Pure, so the wording is tested.
379pub(crate) fn secrets_file_passthrough_refusal(
380    wrangler_args: &[String],
381    order: &[String],
382    lock: &DeployLock,
383    environment: &str,
384) -> Option<String> {
385    let arg = wrangler_args
386        .iter()
387        .find(|arg| arg.as_str() == "--secrets-file" || arg.starts_with("--secrets-file="))?;
388    let first: Vec<String> = order
389        .iter()
390        .filter(|worker| !lock.is_deployed(environment, worker))
391        .map(|worker| format!("`{}`", deployed_worker_name(environment, worker)))
392        .collect();
393    if first.is_empty() {
394        return None;
395    }
396    Some(format!(
397        "bynk: `{arg}` after `--` conflicts with the secrets file `bynk deploy` uploads with a new Worker's first version ({}).\n  \
398         Pass it to bynk instead, before `--`: `bynk deploy --secrets-file <file>`.\n",
399        first.join(", ")
400    ))
401}
402
403/// Run the slice-0 single-context deployment pipeline.
404pub fn run(
405    tb: &dyn Toolbox,
406    compiler: &Compiler,
407    project_root: &Path,
408    node_floor: u32,
409    opts: &DeployOptions,
410) -> ExitCode {
411    // Slice 4 (DECISION E), and the first check of all: once `--env` is a
412    // real, driver-curated concept, a conflicting `-- --env`/`-- --environment`
413    // would otherwise reach `wrangler deploy` as a second, contradictory flag —
414    // Wrangler's own last-wins parsing deciding silently which one actually
415    // deploys, while the ledger records the driver's choice regardless. Reject
416    // before any other work, rather than pick a winner between two explicit,
417    // conflicting inputs.
418    if let Some(conflict) = conflicting_env_passthrough(&opts.wrangler_args) {
419        eprintln!(
420            "bynk: `--env {}` conflicts with `{conflict}` after `--` — pass one or the other, not both",
421            opts.environment
422        );
423        return ExitCode::FAILURE;
424    }
425
426    let preflight_opts = DoctorOptions {
427        only: Some(Capability::Deploy),
428        strict: false,
429    };
430    let report = doctor::diagnose(
431        tb,
432        compiler,
433        &Context {
434            project_root: Some(project_root.to_path_buf()),
435            in_repo: false,
436            node_floor,
437        },
438        &preflight_opts,
439    );
440    if report.exit_nonzero(&preflight_opts) {
441        eprint!("{}", preflight_failure_message(&report));
442        return ExitCode::FAILURE;
443    }
444
445    let build_dir = project_root.join(".bynk").join("deploy");
446    if let Err(e) = workers::prepare_build_dir(project_root, &build_dir) {
447        eprintln!("bynk: could not prepare build directory: {e}");
448        return ExitCode::FAILURE;
449    }
450    if !workers::compile_once(compiler, project_root, &build_dir, true) {
451        return ExitCode::FAILURE;
452    }
453    // Slice 2: every context, ordered — not the one context slice 0 demanded.
454    let workers_dir = build_dir.join("workers");
455    let available = workers::discover_workers(&workers_dir);
456    let selected = match workers::select_contexts(&available, opts.context.as_slice()) {
457        Ok(selected) => selected,
458        Err(e) => {
459            eprintln!("bynk: {e}");
460            return ExitCode::FAILURE;
461        }
462    };
463    // Read spans the *whole* project even under `--context`: D4 needs the
464    // selected context's binding targets to check they are live, and they are
465    // by definition outside the selection.
466    let resources = match project_resources(&workers_dir, &available) {
467        Ok(resources) => resources,
468        Err(e) => {
469            eprintln!("bynk: {e}");
470            return ExitCode::FAILURE;
471        }
472    };
473    let graph = service_graph(&resources);
474    // Read before the plan: a malformed `--secrets-file` is the user's typo, and
475    // it should surface as one now rather than as a missing-secret failure
476    // partway through a run that has already pushed a Worker.
477    let secret_source = match SecretSource::read(opts) {
478        Ok(source) => source,
479        Err(e) => {
480            eprintln!("bynk: {e}");
481            return ExitCode::FAILURE;
482        }
483    };
484    let lock_path = project_root.join(LOCK_FILE);
485    let mut lock = match read_lock(&lock_path) {
486        Ok(lock) => lock,
487        Err(e) => {
488            eprintln!("bynk: could not read {}: {e}", lock_path.display());
489            return ExitCode::FAILURE;
490        }
491    };
492
493    // (D4) `--context` does not deploy a dependency closure. A binding to a
494    // Worker that has never been pushed fails at upload, so say which one
495    // rather than letting Cloudflare's own error carry it.
496    if opts.context.is_some()
497        && let [worker] = selected.as_slice()
498    {
499        let absent = absent_dependencies(worker, &graph, &lock, &opts.environment);
500        if !absent.is_empty() {
501            eprint!(
502                "{}",
503                absent_dependencies_refusal(&opts.environment, worker, &absent)
504            );
505            return ExitCode::FAILURE;
506        }
507
508        // v0.177 (#643): the other half of D4. The dependency exists — but does
509        // it still provide the contract this worker was compiled against?
510        // Without this, the push succeeds and production discovers the skew by
511        // 409ing. `--context` is precisely the flag that makes this reachable.
512        let manifest = match read_contracts_manifest(&workers_dir.join(worker)) {
513            Ok(m) => m,
514            Err(e) => {
515                eprintln!(
516                    "bynk: could not read `{worker}`'s {}: {e}",
517                    bynk_emit::emitter::contracts::CONTRACTS_MANIFEST
518                );
519                return ExitCode::FAILURE;
520            }
521        };
522        let skews = contract_skews(
523            &manifest.expects,
524            &lock,
525            bynk_emit::project::worker_dir_name,
526            &opts.environment,
527        );
528        if !skews.is_empty() {
529            eprint!(
530                "{}",
531                contract_skew_refusal(&opts.environment, worker, &skews)
532            );
533            return ExitCode::FAILURE;
534        }
535
536        // #2077: and the callee half. Does this build still provide what every
537        // *other* live Worker was compiled against? Without this, pushing a
538        // callee whose contract changed answers each live caller 409.
539        let callee_skews = callee_contract_skews(
540            worker,
541            &manifest.provides,
542            &lock,
543            bynk_emit::project::worker_dir_name,
544            &opts.environment,
545        );
546        if !callee_skews.is_empty() {
547            // A caller this build no longer has gets its own remedy: a
548            // whole-project deploy would not move it.
549            let (built, orphaned) = split_orphaned_callers(callee_skews, &graph);
550            if !built.is_empty() {
551                eprint!(
552                    "{}",
553                    callee_contract_skew_refusal(&opts.environment, worker, &built)
554                );
555            }
556            if !orphaned.is_empty() {
557                eprint!(
558                    "{}",
559                    orphaned_caller_skew_refusal(&opts.environment, worker, &orphaned)
560                );
561            }
562            return ExitCode::FAILURE;
563        }
564        for caller in unrecorded_callers(worker, &graph, &lock, &opts.environment) {
565            eprint!(
566                "{}",
567                unrecorded_caller_warning(&opts.environment, worker, &caller)
568            );
569        }
570    }
571
572    let order = match deploy_order(&selected, &graph) {
573        Ok(order) => order
574            .into_iter()
575            // A whole-project run orders every worker; `--context` orders only
576            // the selection, but the DFS reaches its (already-live) targets —
577            // drop them, D4 having already checked them.
578            .filter(|worker| selected.contains(worker))
579            .collect::<Vec<_>>(),
580        Err(e) => {
581            eprintln!("bynk: {e}");
582            let OrderError::Cycle(path) = &e;
583            let publishers = cycle_publishers(path, &resources);
584            if !publishers.is_empty() {
585                eprintln!(
586                    "  The cycle runs through event delivery: {} {} events, so {} to each subscriber, and a subscriber on this cycle also calls back into {} services.",
587                    publishers
588                        .iter()
589                        .map(|p| format!("`{p}`"))
590                        .collect::<Vec<_>>()
591                        .join(", "),
592                    if publishers.len() == 1 {
593                        "publishes"
594                    } else {
595                        "publish"
596                    },
597                    if publishers.len() == 1 {
598                        "it binds"
599                    } else {
600                        "each binds"
601                    },
602                    if publishers.len() == 1 {
603                        "its"
604                    } else {
605                        "their"
606                    },
607                );
608                eprintln!(
609                    "  A subscriber that calls its publisher cannot be deployed yet; move the services it calls out of the publishing context."
610                );
611            }
612            return ExitCode::FAILURE;
613        }
614    };
615
616    // I4 (#1970, track §11 Q6): a declared foreign caller's pin. Refused
617    // before the plan, `--dry-run` included, as a Bynk caller's skew is: the
618    // foreign caller's client would be refused on every call.
619    let pins = match read_pins(project_root) {
620        Ok(pins) => pins,
621        Err(e) => {
622            eprintln!("bynk: {e}");
623            return ExitCode::FAILURE;
624        }
625    };
626    let mut built_foreign: BTreeMap<String, BTreeMap<String, BTreeMap<String, String>>> =
627        BTreeMap::new();
628    for worker in &order {
629        match read_contracts_manifest(&workers_dir.join(worker)) {
630            Ok(m) if !m.foreign.is_empty() => {
631                built_foreign.insert(context_of(worker), m.foreign);
632            }
633            Ok(_) => {}
634            Err(e) => {
635                eprintln!(
636                    "bynk: could not read `{worker}`'s {}: {e}",
637                    bynk_emit::emitter::contracts::CONTRACTS_MANIFEST
638                );
639                return ExitCode::FAILURE;
640            }
641        }
642    }
643    let in_run: BTreeSet<String> = order.iter().map(|w| context_of(w)).collect();
644    let (broken, unpinned) = check_pins(&pins, &built_foreign, &in_run, opts.context.is_none());
645    if !broken.is_empty() {
646        eprint!("{}", refusal_message(&broken));
647        return ExitCode::FAILURE;
648    }
649    if !unpinned.is_empty() {
650        eprint!("{}", unpinned_message(&unpinned));
651    }
652
653    // Review of #1974: namespace titles are account-wide, and a derived
654    // instance's joins two names with the separator Worker names already use,
655    // so two can coincide. Refused before the plan, `--dry-run` included,
656    // rather than met as wrangler's own error partway through a deploy.
657    if let Some(message) = kv_title_collision(&resources, &lock, &opts.environment) {
658        eprintln!("bynk: {message}");
659        return ExitCode::FAILURE;
660    }
661    // P5 (#2323): likewise a bucket's derived name, which R2 scopes to the
662    // account and holds to its own character rules.
663    if let Some(message) = r2_bucket_problem(&resources, &lock, &opts.environment) {
664        eprintln!("bynk: {message}");
665        return ExitCode::FAILURE;
666    }
667    // #2055: a first deploy hands `wrangler deploy` Bynk's own
668    // `--secrets-file`, so a passthrough one would be a second, with
669    // wrangler deciding which wins. Refused before the plan, as `--env`'s
670    // clash is.
671    if let Some(refusal) =
672        secrets_file_passthrough_refusal(&opts.wrangler_args, &order, &lock, &opts.environment)
673    {
674        eprint!("{refusal}");
675        return ExitCode::FAILURE;
676    }
677
678    let plan = derive_plan(
679        &order,
680        &resources,
681        &lock,
682        &secret_source,
683        opts.force,
684        &opts.environment,
685    );
686    print_plan(&plan, opts.format);
687    if opts.dry_run {
688        return ExitCode::SUCCESS;
689    }
690    // P5 (#2323): a changed `@jurisdiction` is a new bucket beside the old,
691    // whose objects do not move, so it is only made on an explicit `--yes`.
692    if let Some(refusal) = recreate_refusal(&plan, opts.yes) {
693        eprintln!("bynk: {refusal}");
694        return ExitCode::FAILURE;
695    }
696
697    // The CI gate is KV's alone, deliberately. A namespace id is *minted* by
698    // Cloudflare, so a CI job that creates one and cannot commit the result
699    // leaves an orphan nobody can find again. A queue's name comes from the
700    // source, so CI creating one loses nothing: the next run derives the same
701    // name and finds the same queue (ADR 0194 D2).
702    // Per namespace (S4b, #1967): each derived instance's id is minted too.
703    if is_ci()
704        && let Some((worker, binding)) =
705            first_unrecorded_kv(&order, &resources, &lock, &opts.environment)
706    {
707        eprintln!(
708            "bynk: {} is unrecorded; provision locally first and commit {LOCK_FILE}",
709            kv_namespace_noun(worker, binding)
710        );
711        return ExitCode::FAILURE;
712    }
713    // P3 (#2342): a rate limiter's id is allocated by the ledger, so one
714    // allocated in CI and never committed would be allocated again next run —
715    // possibly another number, resetting the limiter's counters.
716    if is_ci()
717        && let Some((worker, binding)) =
718            first_unrecorded_ratelimit(&order, &resources, &lock, &opts.environment)
719    {
720        eprintln!(
721            "bynk: the rate limiter `{binding}` of `{worker}` is unrecorded; deploy locally first and commit {LOCK_FILE}"
722        );
723        return ExitCode::FAILURE;
724    }
725    let probe = probe::detect(
726        tb,
727        "wrangler",
728        DetectOpts {
729            project_root: Some(project_root),
730            allow_npx: true,
731        },
732    );
733    // #1796: before authenticating, so the refusal costs no network call.
734    if let Some(refusal) = wrangler_floor_refusal(&probe, &order, &resources) {
735        eprintln!("{refusal}");
736        return ExitCode::FAILURE;
737    }
738    if !whoami(&probe.provenance) {
739        eprintln!(
740            "bynk: Cloudflare authentication is unavailable; run `wrangler login` or set CLOUDFLARE_API_TOKEN"
741        );
742        return ExitCode::FAILURE;
743    }
744    // Interop I5 (#1972, track §11 Q8): a foreign Worker a Service Binding
745    // names is not Bynk's to create, so a missing one is a warning, never a
746    // refusal from the driver. Asked once per Worker, before the
747    // confirmation, so the user decides with it in view.
748    for warning in foreign_worker_warnings(&order, &resources, |name| {
749        worker_presence(&probe.provenance, name, project_root)
750    }) {
751        eprintln!("{warning}");
752    }
753    // State-migrations S3 (#2248): a deletion is confirmed by name, apart
754    // from — and before — the ordinary confirmation.
755    if !confirm_deletions(&plan, &opts.confirm_delete, &lock) {
756        return ExitCode::FAILURE;
757    }
758    if !confirm(opts.yes) {
759        return ExitCode::FAILURE;
760    }
761
762    // Slice 5, DECISION B: once per run, not once per context — an
763    // account-wide list, fetched only when it could actually change an
764    // outcome (a context needs KV and already has a recorded id worth
765    // checking; a first deploy has nothing to check drift against). `None`
766    // (no wrangler, or the call failed) falls back to trusting the ledger
767    // unconditionally, exactly as every deploy before this slice did.
768    let live_kv_ids = if order.iter().any(|w| {
769        kv_bindings(&resources[w])
770            .any(|b| recorded_kv_binding(&lock, w, b, &opts.environment).is_some())
771    }) {
772        live_kv_namespace_ids(&probe.provenance, project_root)
773    } else {
774        None
775    };
776
777    // Provision → wire → push, per context, in dependency order. Each context's
778    // state is written to the ledger as it lands (ADR 0180's incremental
779    // posture), so an interrupted multi-context run is resumable rather than
780    // restartable — and never rolled back (D2): a half-deployed project is a
781    // real state the next plan will show, not an error to unwind.
782    // Shared across the loop: a queue two contexts consume is one queue, so it
783    // wants one create attempt per run, not one per consumer (ADR 0194 D2).
784    let mut attempted_queues = BTreeSet::new();
785    // I2 (#1971): each edge's key is decided over both its ends at once, so
786    // each Worker's `secret list` is asked here, before anything is pushed,
787    // and the per-context step reuses the answer. That covers every Worker
788    // this run deploys and the far end of each edge it touches, which a
789    // `--context` run does not deploy. Asked before the KV ids are
790    // materialised and any `[env.<name>]` table is synthesised: wrangler loads
791    // that config (a placeholder id included) and addresses the same Worker
792    // either way, since the `name` the synthesised table sets (#2028) is the
793    // one Wrangler's `--env` default gives a table without one.
794    let in_run: BTreeSet<&str> = order.iter().map(String::as_str).collect();
795    let edges: Vec<Edge> = project_edges(&resources)
796        .into_iter()
797        .filter(|e| in_run.contains(e.caller.as_str()) || in_run.contains(e.callee.as_str()))
798        .collect();
799    let mut asked: BTreeSet<&str> = in_run.clone();
800    for e in &edges {
801        asked.insert(&e.caller);
802        asked.insert(&e.callee);
803    }
804    let listed: BTreeMap<String, Option<BTreeSet<String>>> = asked
805        .iter()
806        .map(|worker| {
807            let answer = list_secrets(
808                &probe.provenance,
809                &workers_dir.join(worker),
810                &opts.environment,
811            );
812            (worker.to_string(), answer)
813        })
814        .collect();
815    let mut edge_keys: BTreeMap<Edge, EdgeKey> = BTreeMap::new();
816    for edge in &edges {
817        let ends = match edge.ends() {
818            Ok(ends) => ends,
819            Err(e) => {
820                eprintln!("bynk: {e}");
821                return ExitCode::FAILURE;
822            }
823        };
824        let [callee, caller] = ends.map(|(worker, key)| {
825            let p = presence_of(
826                &key,
827                listed[worker].as_ref(),
828                !lock.is_deployed(&opts.environment, worker),
829            );
830            // #2004 review: name the Workers whose answer could not be read,
831            // since the decision counts them as holding the key.
832            if p == Presence::Unknown {
833                eprint!(
834                    "{}",
835                    unknown_edge_key_notice(&opts.environment, worker, &key, edge)
836                );
837            }
838            p
839        });
840        let both_in_run =
841            in_run.contains(edge.caller.as_str()) && in_run.contains(edge.callee.as_str());
842        match decide_edge_key(
843            edge,
844            caller,
845            callee,
846            both_in_run,
847            opts.force,
848            &mut mint_edge_key,
849            &opts.environment,
850        ) {
851            Ok(decision) => {
852                edge_keys.insert(edge.clone(), decision);
853            }
854            Err(e) => {
855                eprintln!("bynk: {e}");
856                return ExitCode::FAILURE;
857            }
858        }
859    }
860    // I3 (#1971): each foreign caller's secret, on the context in the run that
861    // admits it. Only that end is ours, so a `--context` run decides it too.
862    let foreign_secrets: BTreeSet<String> = resources
863        .values()
864        .flat_map(|r| r.foreign_callers.iter().map(|f| f.binding.clone()))
865        .collect();
866    let mut foreign_keys: BTreeMap<(String, String), ForeignKey> = BTreeMap::new();
867    for worker in &order {
868        for caller in &resources[worker].foreign_callers {
869            let presence = presence_of(
870                &caller.binding,
871                listed[worker].as_ref(),
872                !lock.is_deployed(&opts.environment, worker),
873            );
874            let supplied = value_from_source(&caller.binding, &secret_source);
875            match decide_foreign_key(supplied, presence, opts.force, &mut mint_edge_key) {
876                Ok(decision) => {
877                    foreign_keys.insert((worker.clone(), caller.binding.clone()), decision);
878                }
879                Err(e) => {
880                    eprint!(
881                        "{}",
882                        foreign_binding_refusal(&opts.environment, worker, caller, &e)
883                    );
884                    return ExitCode::FAILURE;
885                }
886            }
887        }
888    }
889    // Shared across the loop so two contexts wanting the same secret prompt
890    // once. Dropped with the run — nothing here is ever written (ADR 0195 D1).
891    let mut resolved_secrets = BTreeMap::new();
892    // #2055: a first deploy's secrets file that a SIGKILLed run left behind
893    // goes before this run might write one of its own.
894    let stale_removed = sweep_stale_upload_dirs(&std::env::temp_dir());
895    if stale_removed > 0 {
896        eprintln!(
897            "bynk: removed {stale_removed} secrets file(s) an interrupted deploy left in the temp directory"
898        );
899    }
900    for (i, worker) in order.iter().enumerate() {
901        if order.len() > 1 {
902            eprint!(
903                "{}",
904                deploying_notice(&opts.environment, worker, i + 1, order.len())
905            );
906        }
907        match deploy_one(
908            &probe.provenance,
909            project_root,
910            &workers_dir,
911            worker,
912            &resources[worker],
913            &resources,
914            &mut lock,
915            &lock_path,
916            &mut attempted_queues,
917            &mut Secrets {
918                source: &secret_source,
919                force: opts.force,
920                edge_keys: &edge_keys,
921                foreign_keys: &foreign_keys,
922                foreign_secrets: &foreign_secrets,
923                listed: &listed,
924                resolved: &mut resolved_secrets,
925                with_upload: secrets_file_supported(&probe),
926            },
927            &opts.wrangler_args,
928            &opts.environment,
929            live_kv_ids.as_ref(),
930        ) {
931            Ok(Pushed::Ok) => {
932                // v0.177 (#643): record what this Worker now *provides*, so a
933                // later `--context` push of one of its callers can be refused
934                // before it ships a caller that would 409.
935                // `Some` even when empty: this build *knows* what the Worker
936                // provides, and "knows it provides nothing" must not read as
937                // "no record" at the next gate.
938                let provided = read_contracts_manifest(&workers_dir.join(worker))
939                    .map(|m| Some(m.provides))
940                    .unwrap_or(None);
941                lock.record_deployed(&opts.environment, worker, provided);
942                record_pushed_tombstones(&mut lock, &opts.environment, worker, &resources[worker]);
943                // P3 (#2342): the pushed Worker no longer binds a rate limiter
944                // its source dropped, so that id is free again.
945                lock.forget_undeclared_ratelimits(
946                    &opts.environment,
947                    worker,
948                    &resources[worker].ratelimits,
949                );
950                // #2077: and what it was compiled against, so a later
951                // `--context` push of one of its callees can be refused
952                // before it ships a callee this Worker would 409 against.
953                if let Ok(m) = read_contracts_manifest(&workers_dir.join(worker)) {
954                    lock.record_expects(&opts.environment, worker, m.expects);
955                }
956                // I4 (#1970): and what its declared foreign callers may call
957                // it at, so the ledger knows the consumers no manifest of
958                // their own records.
959                lock.record_foreign(
960                    &opts.environment,
961                    worker,
962                    read_contracts_manifest(&workers_dir.join(worker))
963                        .map(|m| m.foreign)
964                        .unwrap_or_default(),
965                );
966                // I3 (#1971): the secrets just minted for this context's
967                // foreign callers, handed to their operators.
968                for caller in &resources[worker].foreign_callers {
969                    if let Some(ForeignKey::Set {
970                        value,
971                        minted: true,
972                    }) = foreign_keys.get(&(worker.clone(), caller.binding.clone()))
973                    {
974                        eprint!(
975                            "{}",
976                            foreign_hand_off(&opts.environment, worker, caller, value)
977                        );
978                    }
979                }
980                // #1883, slice 1b: and the static files it now serves.
981                lock.record_assets(
982                    &opts.environment,
983                    worker,
984                    served_assets(&workers_dir.join(worker)),
985                );
986                if let Err(e) = write_lock(&lock_path, &lock) {
987                    eprint!(
988                        "{}",
989                        unrecorded_deploy_failure(
990                            &opts.environment,
991                            worker,
992                            &lock_path.display().to_string(),
993                            &e.to_string()
994                        )
995                    );
996                    return ExitCode::FAILURE;
997                }
998            }
999            // A shared Ctrl-C. Stop, but report nothing and exit cleanly: the
1000            // user asked for this, and the terminal signalled us too. `worker`
1001            // is deliberately *not* recorded as deployed — the push was cut
1002            // short, so whether it landed is unknown, and the ledger only ever
1003            // claims what it watched succeed.
1004            Ok(Pushed::Interrupted) => return ExitCode::SUCCESS,
1005            Err(f) => {
1006                eprintln!("bynk: {}", f.message);
1007                // Stop rather than push on: everything left in the order either
1008                // binds to what just failed or would be uploaded into a
1009                // topology that is not what the plan described. `worker` itself
1010                // is excluded — the line above already named it as the failure,
1011                // and listing it here again as "not deployed" would double-count
1012                // it against the number.
1013                eprint!("{}", stopped_report(&opts.environment, &order[i + 1..]));
1014                // #2004 review: a run that re-keyed an edge and stopped between
1015                // its two ends leaves them refusing each other. The next plain
1016                // run sees a key on each end and keeps both, so say how to
1017                // repair it.
1018                if let Some(hint) =
1019                    interrupted_rotation_hint(&opts.environment, &edge_keys, &order[..i])
1020                {
1021                    eprint!("{hint}");
1022                }
1023                // I3 (#1971): a secret minted for this context's foreign
1024                // caller may already be set, and the next run would keep it
1025                // without ever showing it, so hand it off now.
1026                for caller in &resources[worker].foreign_callers {
1027                    if let Some(ForeignKey::Set {
1028                        value,
1029                        minted: true,
1030                    }) = foreign_keys.get(&(worker.clone(), caller.binding.clone()))
1031                    {
1032                        eprint!(
1033                            "{}",
1034                            foreign_binding_maybe_set_notice(&opts.environment, worker, caller)
1035                        );
1036                        eprint!(
1037                            "{}",
1038                            foreign_hand_off(&opts.environment, worker, caller, value)
1039                        );
1040                    }
1041                }
1042                // Wrangler's own code, not a flat 1 (slice 0's contract).
1043                return ExitCode::from(f.code);
1044            }
1045        }
1046    }
1047
1048    // Slice 5: pruning is project-wide, independent of `--context` — the
1049    // orphan report already is (DECISION A), so pruning follows it. Runs
1050    // only after every selected context has pushed cleanly: a failed deploy
1051    // above already returned, so a mid-flight ledger never reaches this.
1052    if opts.prune && plan.orphans.has_prunable() {
1053        if !confirm_prune(opts.yes, &plan.orphans, &opts.environment) {
1054            return ExitCode::FAILURE;
1055        }
1056        if let Err(e) = prune_orphans(
1057            &probe.provenance,
1058            project_root,
1059            &mut lock,
1060            &lock_path,
1061            &opts.environment,
1062            &plan.orphans,
1063        ) {
1064            eprintln!("bynk: {}", e.message);
1065            return ExitCode::from(e.code);
1066        }
1067    }
1068    ExitCode::SUCCESS
1069}
1070
1071/// Interop I5 (#1972): a warning for each foreign Worker that the pushed
1072/// contexts bind to and `presence` does not find, each Worker asked once,
1073/// in name order. Cloudflare resolves a Service Binding at upload, so a push
1074/// binding a missing Worker is rejected there: the warning says so ahead of
1075/// it, and the driver itself refuses nothing (a Worker created between now
1076/// and the push is fine).
1077pub(crate) fn foreign_worker_warnings(
1078    order: &[String],
1079    resources: &BTreeMap<String, Resources>,
1080    mut presence: impl FnMut(&str) -> WorkerPresence,
1081) -> Vec<String> {
1082    let mut bound: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
1083    for worker in order {
1084        for f in &resources[worker].foreign_services {
1085            bound.entry(f.worker.as_str()).or_default().push(worker);
1086        }
1087    }
1088    let mut out = Vec::new();
1089    for (name, by) in bound {
1090        let by = by
1091            .iter()
1092            .map(|w| format!("`{w}`"))
1093            .collect::<Vec<_>>()
1094            .join(", ");
1095        match presence(name) {
1096            WorkerPresence::Present => {}
1097            WorkerPresence::Missing => out.push(format!(
1098                "bynk: warning: {by} binds to Worker `{name}`, which this account does not have — \
1099                 Cloudflare rejects a Service Binding to a missing Worker at upload, so deploy `{name}` first."
1100            )),
1101            WorkerPresence::Unknown(said) => out.push(format!(
1102                "bynk: warning: could not check that Worker `{name}` (bound by {by}) exists: {said}"
1103            )),
1104        }
1105    }
1106    out
1107}
1108
1109/// #2004 review, I2 (#1971): what to tell the user when a run that re-keyed
1110/// edges stopped before every end of them was deployed. `completed` is the
1111/// Workers that finished (not the one that failed, which may or may not hold
1112/// its new keys). An edge whose ends had no key before cannot be split: the
1113/// next run finds an end without one and re-keys it. One that was rotating
1114/// can: each end may hold a different key, and a plain rerun keeps both.
1115/// `None` when no such edge was left incomplete.
1116pub(crate) fn interrupted_rotation_hint(
1117    environment: &str,
1118    edge_keys: &BTreeMap<Edge, EdgeKey>,
1119    completed: &[String],
1120) -> Option<String> {
1121    let done = |w: &String| completed.contains(w);
1122    let split: Vec<String> = edge_keys
1123        .iter()
1124        .filter(|(edge, key)| {
1125            matches!(key, EdgeKey::Set { rotating: true, .. })
1126                && !(done(&edge.caller) && done(&edge.callee))
1127        })
1128        .map(|(edge, _)| edge.describe(environment))
1129        .collect();
1130    if split.is_empty() {
1131        return None;
1132    }
1133    Some(format!(
1134        "bynk: this run was setting new keys on {} and stopped before both ends of each were deployed, so each end may hold a different key and calls along it are refused.\n  \
1135         Rerun `{} --force` (the whole project) to set one key on both ends; a plain rerun would keep both.\n",
1136        split.join(", "),
1137        deploy_command(environment)
1138    ))
1139}
1140
1141/// v0.177 (#643): the `--context` refusal for a Worker compiled against a
1142/// contract its live dependencies no longer provide. The Worker by its
1143/// [`deployed_worker_name`], the remedy repeating the run's `--env` (#2047).
1144/// Each skew names a contract (`<context>.<service>`), not a Worker.
1145pub(crate) fn contract_skew_refusal(
1146    environment: &str,
1147    worker: &str,
1148    skews: &[ContractSkew],
1149) -> String {
1150    let mut out = format!(
1151        "bynk: `{}` was compiled against a contract its live dependencies no longer provide ({CONTRACT_SKEW}):\n",
1152        deployed_worker_name(environment, worker)
1153    );
1154    for s in skews {
1155        out.push_str(&format!(
1156            "  {}.{} — compiled against {}, live is {}\n",
1157            s.dependency, s.service, s.expected, s.live
1158        ));
1159    }
1160    out.push_str(
1161        "  Deploying this would ship a caller its callee rejects (409 ContractMismatch) on every call.\n",
1162    );
1163    out.push_str(&format!(
1164        "  Deploy the whole project (`{}`) so both sides move together.\n",
1165        deploy_command(environment)
1166    ));
1167    out
1168}
1169
1170/// #2004 review: an end of `edge` whose secrets could not be listed, counted
1171/// as holding `key`. The Worker and the edge by their deployed names, the
1172/// remedy repeating the run's `--env` (#2047).
1173pub(crate) fn unknown_edge_key_notice(
1174    environment: &str,
1175    worker: &str,
1176    key: &str,
1177    edge: &Edge,
1178) -> String {
1179    format!(
1180        "bynk: could not ask Cloudflare whether `{}` holds {key}; assuming it does. If it does not, the edge {} refuses every call — `{} --force` sets a fresh key on every edge.\n",
1181        deployed_worker_name(environment, worker),
1182        edge.describe(environment),
1183        deploy_command(environment)
1184    )
1185}
1186
1187/// The progress line of a multi-context run: `n` of `total`, the Worker by
1188/// its [`deployed_worker_name`] (#2047).
1189pub(crate) fn deploying_notice(environment: &str, worker: &str, n: usize, total: usize) -> String {
1190    format!(
1191        "bynk: deploying `{}` ({n}/{total})…\n",
1192        deployed_worker_name(environment, worker)
1193    )
1194}
1195
1196/// A push that landed but could not be written to the ledger at `lock_path`.
1197/// The Worker by its [`deployed_worker_name`] (#2047).
1198pub(crate) fn unrecorded_deploy_failure(
1199    environment: &str,
1200    worker: &str,
1201    lock_path: &str,
1202    error: &str,
1203) -> String {
1204    format!(
1205        "bynk: deployed `{}` but could not record it in {lock_path}: {error}\n",
1206        deployed_worker_name(environment, worker)
1207    )
1208}
1209
1210/// I3 (#1971): the run could not decide `caller`'s binding on `worker`
1211/// (`reason`, from `decide_foreign_key`). The Worker by its
1212/// [`deployed_worker_name`] (#2047).
1213pub(crate) fn foreign_binding_refusal(
1214    environment: &str,
1215    worker: &str,
1216    caller: &ForeignCaller,
1217    reason: &str,
1218) -> String {
1219    format!(
1220        "bynk: `{}` (the foreign caller `{}` of `{}`): {reason}\n",
1221        caller.binding,
1222        caller.actor,
1223        deployed_worker_name(environment, worker)
1224    )
1225}
1226
1227/// I3 (#1971): a failed push may have set a secret minted for `caller`. The
1228/// Worker by its [`deployed_worker_name`], which the suggested `wrangler
1229/// secret list` names too, so it asks about the right Worker (#2047).
1230pub(crate) fn foreign_binding_maybe_set_notice(
1231    environment: &str,
1232    worker: &str,
1233    caller: &ForeignCaller,
1234) -> String {
1235    let worker = deployed_worker_name(environment, worker);
1236    format!(
1237        "bynk: `{worker}` may already hold the {} minted for its foreign caller `{}`. If `wrangler secret list --name {worker}` shows it, hand this off; if not, the next deploy mints another.\n",
1238        caller.binding, caller.actor
1239    )
1240}
1241
1242/// #1796: the refusal a deploy gets when a context it pushes declares a
1243/// Durable Object class and the wrangler that would push it is older than
1244/// [`bynk_emit::WRANGLER_MIN`].
1245///
1246/// Below that floor wrangler doesn't read the `exports` map the emitter
1247/// declares every class in, so the push would carry Durable Object bindings
1248/// with no namespace behind them and fail with Cloudflare's own error, which
1249/// says nothing about the version. `doctor`'s wrangler row only *warns* about
1250/// the floor, because a project with no agent still deploys on an older
1251/// wrangler. This is where the project is known, so this is where the floor
1252/// becomes a hard requirement. `None` when nothing pushed declares a class, or
1253/// when the version can't be judged without running npx
1254/// ([`doctor::wrangler_below_min`]'s own rule).
1255pub(crate) fn wrangler_floor_refusal(
1256    probe: &probe::Probe,
1257    order: &[String],
1258    resources: &BTreeMap<String, Resources>,
1259) -> Option<String> {
1260    let version = probe
1261        .version
1262        .filter(|_| doctor::wrangler_below_min(probe))?;
1263    let worker = order
1264        .iter()
1265        .find(|worker| !resources[*worker].durable_objects.is_empty())?;
1266    Some(format!(
1267        "bynk: wrangler {version} is older than {}, the first that reads the Durable Object \
1268         `exports` `{worker}` declares; the push would fail. Upgrade with `{}`.",
1269        bynk_emit::WRANGLER_MIN,
1270        doctor::wrangler_upgrade_remedy(probe)
1271    ))
1272}
1273
1274pub fn preflight_failure_message(report: &Report) -> String {
1275    format!(
1276        "bynk: environment not ready for `deploy` — see below.\n\n{}",
1277        report::render(report, Format::Human)
1278    )
1279}
1280
1281/// The D4 refusal: `--context` selected `worker`, which binds to `absent`
1282/// Workers the ledger has never recorded as deployed in `environment`.
1283///
1284/// Every Worker is named by its [`deployed_worker_name`], as the plan names it
1285/// (#2038): under `--env staging` the missing target is `payment-staging`, not
1286/// the logical `payment`. Pure, so the wording is goldened rather than
1287/// described.
1288pub(crate) fn absent_dependencies_refusal(
1289    environment: &str,
1290    worker: &str,
1291    absent: &[String],
1292) -> String {
1293    format!(
1294        "bynk: `{}` binds to {}, which {} never been deployed — a Service Binding to a Worker that does not exist fails at upload.\n  Deploy the whole project once (`{}`) to bring the topology up.\n",
1295        deployed_worker_name(environment, worker),
1296        absent
1297            .iter()
1298            .map(|a| format!("`{}`", deployed_worker_name(environment, a)))
1299            .collect::<Vec<_>>()
1300            .join(", "),
1301        if absent.len() == 1 { "has" } else { "have" },
1302        deploy_command(environment)
1303    )
1304}
1305
1306/// #2077: the `--context` refusal for a callee whose new build no longer
1307/// provides the contract its live callers were compiled against — the callee
1308/// half of [`contract_skew_refusal`], under the same code. Each live caller and
1309/// the callee by their [`deployed_worker_name`]; each finding names the
1310/// contract (`<context>.<service>`) and both hashes; the remedy repeats the
1311/// run's `--env` ([`deploy_command`]). Pure, so the wording is goldened.
1312pub(crate) fn callee_contract_skew_refusal(
1313    environment: &str,
1314    worker: &str,
1315    skews: &[CalleeSkew],
1316) -> String {
1317    let mut out = format!(
1318        "bynk: `{}` no longer provides the contract its live callers were compiled against ({CONTRACT_SKEW}):\n",
1319        deployed_worker_name(environment, worker)
1320    );
1321    for s in skews {
1322        out.push_str(&format!(
1323            "  `{}` calls {}.{} — compiled against {}, this build provides {}\n",
1324            deployed_worker_name(environment, &s.caller),
1325            s.dependency,
1326            s.service,
1327            s.expected,
1328            s.provided
1329        ));
1330    }
1331    out.push_str(
1332        "  Deploying this would ship a callee that rejects its live callers (409 ContractMismatch) on every call.\n",
1333    );
1334    out.push_str(&format!(
1335        "  Deploy the whole project (`{}`) so both sides move together.\n",
1336        deploy_command(environment)
1337    ));
1338    out
1339}
1340
1341/// #2077 review: the refusal for a callee whose new build breaks a live caller
1342/// this project no longer builds — a Worker of a removed context, still
1343/// recorded in the ledger (the plan's `orphan worker`). A whole-project deploy
1344/// neither redeploys nor checks it, and `--prune` never deletes a Worker or its
1345/// record, so the remedy is to retire it (delete the Worker, then drop its
1346/// ledger entry, without which this gate keeps refusing) or to restore its
1347/// context and deploy the whole project ([`deploy_command`]). Pure, so the
1348/// wording is goldened.
1349pub(crate) fn orphaned_caller_skew_refusal(
1350    environment: &str,
1351    worker: &str,
1352    skews: &[CalleeSkew],
1353) -> String {
1354    let mut out = format!(
1355        "bynk: `{}` no longer provides the contract a live Worker this project no longer builds was compiled against ({CONTRACT_SKEW}):\n",
1356        deployed_worker_name(environment, worker)
1357    );
1358    for s in skews {
1359        out.push_str(&format!(
1360            "  `{}` (orphaned) calls {}.{} — compiled against {}, this build provides {}\n",
1361            deployed_worker_name(environment, &s.caller),
1362            s.dependency,
1363            s.service,
1364            s.expected,
1365            s.provided
1366        ));
1367    }
1368    out.push_str(
1369        "  Deploying this would ship a callee that rejects it (409 ContractMismatch) on every call, and a whole-project deploy would not move it: that reports an orphaned Worker but never redeploys or deletes it, and `--prune` never deletes a Worker either.\n",
1370    );
1371    let mut callers: Vec<&str> = skews.iter().map(|s| s.caller.as_str()).collect();
1372    callers.dedup();
1373    out.push_str(&format!(
1374        "  If it is unused, delete it ({}) and remove its entry ({}) from {LOCK_FILE}. If it is still needed, restore its context and deploy the whole project (`{}`).\n",
1375        callers
1376            .iter()
1377            .map(|c| format!(
1378                "`npx wrangler delete --name {}`",
1379                deployed_worker_name(environment, c)
1380            ))
1381            .collect::<Vec<_>>()
1382            .join(", "),
1383        callers
1384            .iter()
1385            .map(|c| format!("`[environments.{environment}.workers.{c}]`"))
1386            .collect::<Vec<_>>()
1387            .join(", "),
1388        deploy_command(environment)
1389    ));
1390    out
1391}
1392
1393/// #2077: the warning for a live caller of the pushed `worker` whose ledger
1394/// record predates recorded `expects`, so the callee gate cannot check it.
1395/// Never a refusal: silence is not a match.
1396pub(crate) fn unrecorded_caller_warning(environment: &str, worker: &str, caller: &str) -> String {
1397    format!(
1398        "bynk: warning: could not check that `{}` still provides what the live `{}` was compiled against — its deploy record predates recorded expectations ({CONTRACT_SKEW}). A whole-project deploy (`{}`) records them.\n",
1399        deployed_worker_name(environment, worker),
1400        deployed_worker_name(environment, caller),
1401        deploy_command(environment)
1402    )
1403}
1404
1405/// What the run did **not** get to, once a context failed. `rest` is the order
1406/// *beyond* the failure, so the last context failing reports nothing — there was
1407/// nothing left to withhold, and the failure itself has already been named.
1408/// Each context is named by its [`deployed_worker_name`] in `environment`, as
1409/// the plan's `order` line names it (#2038).
1410///
1411/// Pure, so the wording — and the count's agreement with the list — is goldened
1412/// rather than described.
1413pub(crate) fn stopped_report(environment: &str, rest: &[String]) -> String {
1414    if rest.is_empty() {
1415        return String::new();
1416    }
1417    format!(
1418        "bynk: stopping — {} not deployed: {}. Re-run `{}` to resume; what already landed is kept.\n",
1419        if rest.len() == 1 {
1420            "1 more context was".to_string()
1421        } else {
1422            format!("{} further contexts were", rest.len())
1423        },
1424        rest.iter()
1425            .map(|worker| deployed_worker_name(environment, worker))
1426            .collect::<Vec<_>>()
1427            .join(", "),
1428        deploy_command(environment)
1429    )
1430}
1431
1432/// Render the plan exactly as the user sees it. Pure, so the output surface the
1433/// deploy guide documents is goldened rather than described — `print_plan` is
1434/// the transport.
1435pub(crate) fn plan_report(plan: &Plan<'_>, format: DeployFormat) -> String {
1436    match format {
1437        DeployFormat::Short => {
1438            let mut out = String::new();
1439            // Before the per-context section, deliberately: an orphan is a
1440            // fact about the account regardless of what this run is about to
1441            // do, and a reader should see it before the noise of what's being
1442            // pushed (slice 5).
1443            for kv in &plan.shown_orphans.kv {
1444                match &kv.binding {
1445                    None => out.push_str(&format!("orphan kv {}\n", kv.worker)),
1446                    Some(binding) => {
1447                        out.push_str(&format!("orphan kv {} {binding}\n", kv.worker));
1448                    }
1449                }
1450            }
1451            for r2 in &plan.shown_orphans.r2 {
1452                out.push_str(&format!(
1453                    "orphan r2 {} {} ({}{}; not deleted)\n",
1454                    r2.worker,
1455                    r2.binding,
1456                    r2.bucket,
1457                    r2.jurisdiction
1458                        .as_deref()
1459                        .map(|j| format!(", jurisdiction {j}"))
1460                        .unwrap_or_default()
1461                ));
1462            }
1463            for worker in &plan.shown_orphans.workers {
1464                out.push_str(&format!("orphan worker {worker}\n"));
1465            }
1466            for queue in &plan.shown_orphans.queues {
1467                out.push_str(&format!("orphan queue {queue}\n"));
1468            }
1469            for context in &plan.contexts {
1470                for kv in &context.kv {
1471                    // The bare line is unchanged; an instance names its binding.
1472                    if is_bare_kv(kv.binding) {
1473                        out.push_str(&format!("kv {} {}\n", kv.action, kv.namespace));
1474                    } else {
1475                        out.push_str(&format!(
1476                            "kv {} {} ({})\n",
1477                            kv.action, kv.namespace, kv.binding
1478                        ));
1479                    }
1480                }
1481                for r2 in &context.r2 {
1482                    out.push_str(&format!("r2 {} {} ({})", r2.action, r2.bucket, r2.binding));
1483                    if let Some(jurisdiction) = r2.jurisdiction {
1484                        out.push_str(&format!(" in jurisdiction {jurisdiction}"));
1485                    }
1486                    if r2.action == "recreate" {
1487                        out.push_str(" — the old bucket and its objects stay where they are");
1488                    }
1489                    out.push('\n');
1490                }
1491                for limiter in &context.ratelimits {
1492                    match &limiter.namespace_id {
1493                        Some(id) => out.push_str(&format!(
1494                            "ratelimit {} {} (namespace {id})\n",
1495                            limiter.action, limiter.binding
1496                        )),
1497                        None => out.push_str(&format!(
1498                            "ratelimit {} {}\n",
1499                            limiter.action, limiter.binding
1500                        )),
1501                    }
1502                }
1503                for queue in &context.queues {
1504                    out.push_str(&format!("queue {} {}\n", queue.action, queue.queue));
1505                }
1506                // Interop I5 (#1972): bound, not provisioned — the Worker is
1507                // not Bynk's, so the line says so rather than an action.
1508                for service in &context.foreign_services {
1509                    out.push_str(&format!(
1510                        "service {} {} (foreign Worker; not provisioned)\n",
1511                        service.binding, service.worker
1512                    ));
1513                }
1514                // Between the provisioning lines and the push, because that is
1515                // where it happens: the `exports` declaration rides the config
1516                // `wrangler deploy` reads rather than being a step of its own.
1517                // Flagged advisory in place — a reader must not take it for a
1518                // claim that the namespace does or doesn't exist yet (#1796,
1519                // ADR 0194 D1's principle).
1520                for object in &context.durable_objects {
1521                    out.push_str(&format!(
1522                        "durable object {} ({}; advisory — {} reconciles it)\n",
1523                        object.class, object.storage, object.reconciled_by
1524                    ));
1525                }
1526                // State-migrations S3 (#2248): the declared class renames and
1527                // deletions, stated for what they do to stored data.
1528                for rename in &context.renamed_durable_objects {
1529                    out.push_str(&if rename.already_applied {
1530                        format!(
1531                            "rename durable object {} → {} (already applied by an earlier deploy; the tombstone stays until Cloudflare reports it stale)\n",
1532                            rename.from, rename.to
1533                        )
1534                    } else {
1535                        format!(
1536                            "rename durable object {} → {} (moves its stored data)\n",
1537                            rename.from, rename.to
1538                        )
1539                    });
1540                }
1541                for deletion in &context.deleted_durable_objects {
1542                    out.push_str(&if deletion.already_applied {
1543                        format!(
1544                            "delete durable object {} (already deleted by an earlier deploy; the tombstone stays until Cloudflare reports it stale)\n",
1545                            deletion.class
1546                        )
1547                    } else {
1548                        format!(
1549                            "delete durable object {} (all stored data; irreversible)\n",
1550                            deletion.class
1551                        )
1552                    });
1553                }
1554                // Before the lines it qualifies, not after: a reader who takes
1555                // the list for the whole story is the failure this increment
1556                // exists to prevent (ADR 0196 D2).
1557                if !context.secrets_complete {
1558                    out.push_str(&format!(
1559                        "secrets incomplete {} (reads at least one name the compiler cannot see)\n",
1560                        context.worker
1561                    ));
1562                }
1563                // Names only, never values (ADR 0195 D1). The origin rides each
1564                // line because the three are not equally known: `declared` is
1565                // required, `read` is advisory, `supplied` is the user's word.
1566                for secret in &context.secrets {
1567                    out.push_str(&format!(
1568                        "secret {} {} ({})\n",
1569                        secret.action,
1570                        secret.name,
1571                        secret.origin.label()
1572                    ));
1573                }
1574                out.push_str(&format!("{} {}\n", context.action, context.worker));
1575            }
1576            // The order is the plan's load-bearing claim once there is more
1577            // than one context, so state it rather than leaving it implied by
1578            // the line order above.
1579            if plan.order.len() > 1 {
1580                out.push_str(&format!("order {}\n", plan.order.join(" → ")));
1581            }
1582            out
1583        }
1584        DeployFormat::Json => {
1585            format!(
1586                "{}\n",
1587                serde_json::to_string_pretty(plan).expect("plan serialises")
1588            )
1589        }
1590    }
1591}
1592
1593fn print_plan(plan: &Plan<'_>, format: DeployFormat) {
1594    print!("{}", plan_report(plan, format));
1595}
1596
1597/// Derive the plan over the resolved order. Pure, so the per-context breakdown
1598/// and the ordering claim are unit-tested without a Cloudflare account.
1599///
1600/// Indexes `resources` rather than defending a miss: `order` ⊆ the workers it
1601/// was read for, and the deploy loop indexes the same map anyway — so a
1602/// tolerated miss here would only understate the plan a moment before the run
1603/// panicked on it regardless.
1604pub(crate) fn derive_plan<'a>(
1605    order: &'a [String],
1606    resources: &'a BTreeMap<String, Resources>,
1607    lock: &DeployLock,
1608    // Not borrowed into the plan: a `PlanSecret` owns its name, because the set
1609    // is derived (declared ∪ supplied) rather than taken from either source.
1610    secrets: &SecretSource,
1611    force: bool,
1612    environment: &'a str,
1613) -> Plan<'a> {
1614    let edges = project_edges(resources);
1615    let foreign_secrets: BTreeSet<String> = resources
1616        .values()
1617        .flat_map(|r| r.foreign_callers.iter().map(|f| f.binding.clone()))
1618        .collect();
1619    let orphans = find_orphans(lock, environment, resources);
1620    Plan {
1621        environment,
1622        shown_orphans: orphans.as_named_in(environment),
1623        orphans,
1624        order: order
1625            .iter()
1626            .map(|worker| deployed_worker_name(environment, worker))
1627            .collect(),
1628        contexts: order
1629            .iter()
1630            .map(|worker| {
1631                let declared = &resources[worker];
1632                ContextPlan {
1633                    worker: deployed_worker_name(environment, worker),
1634                    kv: kv_bindings(declared)
1635                        .map(|binding| PlanKv {
1636                            binding,
1637                            action: if recorded_kv_binding(lock, worker, binding, environment)
1638                                .is_some()
1639                            {
1640                                "reuse"
1641                            } else {
1642                                "create"
1643                            },
1644                            namespace: kv_title(worker, binding, environment),
1645                        })
1646                        .collect(),
1647                    r2: declared
1648                        .r2_buckets
1649                        .iter()
1650                        .map(|bucket| PlanR2 {
1651                            binding: &bucket.binding,
1652                            action: r2_action(lock, worker, bucket, environment),
1653                            bucket: r2_bucket_name(environment, &bucket.name),
1654                            jurisdiction: bucket.jurisdiction.as_deref(),
1655                        })
1656                        .collect(),
1657                    ratelimits: declared
1658                        .ratelimits
1659                        .iter()
1660                        .map(|binding| {
1661                            let recorded = recorded_ratelimit(lock, worker, binding, environment);
1662                            PlanRateLimit {
1663                                binding,
1664                                action: if recorded.is_some() {
1665                                    "reuse"
1666                                } else {
1667                                    "allocate"
1668                                },
1669                                namespace_id: recorded.map(str::to_string),
1670                            }
1671                        })
1672                        .collect(),
1673                    queues: declared
1674                        .queues
1675                        .iter()
1676                        .map(|queue| PlanQueue {
1677                            action: if lock.has_queue(environment, queue) {
1678                                "reuse"
1679                            } else {
1680                                "create"
1681                            },
1682                            queue: env_qualify(environment, queue),
1683                        })
1684                        .collect(),
1685                    durable_objects: declared
1686                        .durable_objects
1687                        .iter()
1688                        .map(|object| PlanDurableObject {
1689                            class: &object.class,
1690                            storage: &object.storage,
1691                            reconciled_by: "Cloudflare",
1692                        })
1693                        .collect(),
1694                    renamed_durable_objects: declared
1695                        .renamed_durable_objects
1696                        .iter()
1697                        .map(|(from, to)| PlanDurableObjectRename {
1698                            from,
1699                            to,
1700                            already_applied: lock.has_renamed_class(environment, worker, from, to),
1701                        })
1702                        .collect(),
1703                    deleted_durable_objects: declared
1704                        .deleted_durable_objects
1705                        .iter()
1706                        .map(|class| PlanDeletion {
1707                            class: qualified_class(worker, class),
1708                            already_applied: lock.has_deleted_class(environment, worker, class),
1709                        })
1710                        .collect(),
1711                    secrets: wanted_secrets(
1712                        &declared.declared_secrets,
1713                        &declared.read_secrets,
1714                        secrets,
1715                    )
1716                    .into_iter()
1717                    // I3 (#1971): a foreign caller's secret is never spread.
1718                    .filter(|w| !foreign_secrets.contains(&w.name))
1719                    // I2 (#1971): the key for each edge this Worker is on.
1720                    .chain(
1721                        edge_keys_of(worker, &edges)
1722                            .into_iter()
1723                            .map(|name| WantedSecret {
1724                                name,
1725                                origin: Origin::Managed,
1726                            }),
1727                    )
1728                    // I3 (#1971): and the secret of each foreign caller it
1729                    // admits, which `deploy` mints and hands off.
1730                    .chain(declared.foreign_callers.iter().map(|f| WantedSecret {
1731                        name: f.binding.clone(),
1732                        origin: Origin::Managed,
1733                    }))
1734                    .map(|want| PlanSecret {
1735                        name: want.name,
1736                        origin: want.origin,
1737                        // Presence is not knowable here — the plan runs
1738                        // before auth so `--dry-run` stays offline — so the
1739                        // action is what the run will attempt.
1740                        action: if force { "overwrite" } else { "set" },
1741                    })
1742                    .collect(),
1743                    secrets_complete: declared.reads_complete,
1744                    action: if lock.is_deployed(environment, worker) {
1745                        "redeploy"
1746                    } else {
1747                        "deploy"
1748                    },
1749                    binds_to: declared
1750                        .binds_to
1751                        .iter()
1752                        .map(|target| deployed_worker_name(environment, target))
1753                        .collect(),
1754                    foreign_services: declared
1755                        .foreign_services
1756                        .iter()
1757                        .map(|f| PlanForeignService {
1758                            binding: &f.binding,
1759                            worker: &f.worker,
1760                        })
1761                        .collect(),
1762                }
1763            })
1764            .collect(),
1765    }
1766}
1767
1768/// Two KV namespaces that would share one account-facing title
1769/// ([`kv_title`]), described for the user. `None` when there is no clash.
1770///
1771/// Cloudflare refuses a namespace whose title the account already holds, in
1772/// any environment ("Returns `400` if the account already owns a namespace
1773/// with that title"), so there are two checks:
1774///
1775/// - **Within `environment`**, every namespace the project declares: Worker
1776///   `shop` with `capability CartSessions` and Worker `shop-cart` with
1777///   `capability Sessions` both give `shop-cart-sessions`.
1778/// - **Across environments** (#2048), each namespace this deploy would create
1779///   (declared, with no id recorded for `environment`) against every other
1780///   environment the ledger knows: the titles of the namespaces it records,
1781///   and the titles its next deploy of this source would create. Worker
1782///   `shop`'s bare namespace under `--env staging` and its `capability
1783///   Staging` in the default environment are both `shop-staging`. A namespace
1784///   already recorded here is not created, so it cannot fail; that clash is
1785///   refused by the other environment's deploy instead. A recorded namespace
1786///   whose id has gone from the account is created again, and that is only
1787///   known once the deploy fetches the live ids, so `deploy_one` runs
1788///   [`cross_environment_title_clash`] again at that point (#2059).
1789///
1790/// Checked across the whole project, not just `--context`'s selection, since
1791/// titles are account-wide. The ledger records ids, not titles, so a recorded
1792/// namespace counts under the title Bynk would give it now; one made under
1793/// `--env` before #2037 is really titled with the bare Worker name.
1794pub(crate) fn kv_title_collision(
1795    resources: &BTreeMap<String, Resources>,
1796    lock: &DeployLock,
1797    environment: &str,
1798) -> Option<String> {
1799    let mut seen: BTreeMap<String, (&str, &str)> = BTreeMap::new();
1800    for (worker, declared) in resources {
1801        for binding in kv_bindings(declared) {
1802            let title = kv_title(worker, binding, environment);
1803            if let Some((first_worker, first_binding)) =
1804                seen.insert(title.clone(), (worker.as_str(), binding))
1805            {
1806                return Some(format!(
1807                    "{} and {} would both be titled `{title}` on the account; rename a capability or context so they differ",
1808                    kv_namespace_noun(first_worker, first_binding),
1809                    kv_namespace_noun(worker, binding)
1810                ));
1811            }
1812        }
1813    }
1814
1815    // What this deploy creates: each declared namespace with no recorded id.
1816    let creates: Vec<(&str, &str)> = resources
1817        .iter()
1818        .flat_map(|(worker, declared)| {
1819            kv_bindings(declared).map(move |binding| (worker.as_str(), binding))
1820        })
1821        .filter(|(worker, binding)| {
1822            recorded_kv_binding(lock, worker, binding, environment).is_none()
1823        })
1824        .collect();
1825    cross_environment_title_clash(resources, lock, environment, &creates, None)
1826}
1827
1828/// P5 (#2323): why a plan with a `recreate` bucket may not proceed without
1829/// `--yes`, or `None`. The only thing between a changed `@jurisdiction` and a
1830/// silent move to a new, empty bucket, so `run` consults it before anything is
1831/// provisioned.
1832pub(crate) fn recreate_refusal(plan: &Plan<'_>, yes: bool) -> Option<&'static str> {
1833    (!yes && plan.contexts.iter().any(|c| c.r2.iter().any(|b| b.action == "recreate")))
1834        .then_some("a changed `@jurisdiction` makes a new R2 bucket and leaves the old one, with its objects, where it is (the plan reports it as an orphan); move the data, then re-run with --yes")
1835}
1836
1837/// P5 (#2323): what a deploy will do about `worker`'s R2 bucket `bucket` in
1838/// `environment`. The name is the identity, so a recorded bucket under the
1839/// same jurisdiction is `reuse`; one recorded under another is `recreate`,
1840/// since a bucket's jurisdiction is fixed when it is made.
1841pub(crate) fn r2_action(
1842    lock: &DeployLock,
1843    worker: &str,
1844    bucket: &R2Decl,
1845    environment: &str,
1846) -> &'static str {
1847    match recorded_r2(lock, worker, &bucket.binding, environment) {
1848        None => "create",
1849        Some(recorded)
1850            if recorded.name == r2_bucket_name(environment, &bucket.name)
1851                && recorded.jurisdiction == bucket.jurisdiction =>
1852        {
1853            "reuse"
1854        }
1855        Some(recorded) if recorded.name == r2_bucket_name(environment, &bucket.name) => "recreate",
1856        Some(_) => "create",
1857    }
1858}
1859
1860/// P5 (#2323): why the buckets the build declares cannot all be made in
1861/// `environment`, or `None`. A derived name that breaks R2's rules is an error
1862/// at plan time, naming the instance, never a failed push; and two instances
1863/// (or one here and one another environment's) must not share a name, since
1864/// R2 scopes bucket names to the account.
1865pub(crate) fn r2_bucket_problem(
1866    resources: &BTreeMap<String, Resources>,
1867    lock: &DeployLock,
1868    environment: &str,
1869) -> Option<String> {
1870    let mut seen: BTreeMap<String, (&str, &str)> = BTreeMap::new();
1871    for (worker, declared) in resources {
1872        for bucket in &declared.r2_buckets {
1873            let name = r2_bucket_name(environment, &bucket.name);
1874            if let Some(why) = r2_bucket_name_problem(&name) {
1875                return Some(format!(
1876                    "R2 bucket `{name}` (`{}` of `{worker}`) cannot be created: {why}; rename the capability or the context",
1877                    bucket.binding
1878                ));
1879            }
1880            if let Some((other_worker, other_binding)) =
1881                seen.insert(name.clone(), (worker, &bucket.binding))
1882            {
1883                return Some(format!(
1884                    "`{}` of `{worker}` and `{other_binding}` of `{other_worker}` would both be the R2 bucket `{name}`; bucket names are scoped to the account, so rename one capability",
1885                    bucket.binding
1886                ));
1887            }
1888            for (other_env, env) in &lock.environments {
1889                let clash = env
1890                    .r2_instances
1891                    .iter()
1892                    .flat_map(|(w, instances)| instances.iter().map(move |(b, r)| (w, b, r)))
1893                    .find(|(w, b, r)| {
1894                        r.name == name
1895                            && (other_env != environment
1896                                || w.as_str() != worker
1897                                || **b != bucket.binding)
1898                    });
1899                if let Some((other_worker, other_binding, _)) = clash {
1900                    return Some(format!(
1901                        "`{}` of `{worker}` would be the R2 bucket `{name}`, which `{other_binding}` of `{other_worker}` already holds in environment `{other_env}`; bucket names are scoped to the account, so rename one capability",
1902                        bucket.binding
1903                    ));
1904                }
1905            }
1906        }
1907    }
1908    None
1909}
1910
1911/// The cross-environment half of [`kv_title_collision`]: does any of
1912/// `creates`, the `(worker, binding)` namespaces about to be created under
1913/// `environment`, take a title another environment the ledger knows holds or
1914/// would create? The message names both namespaces and both environments.
1915///
1916/// Also run by `deploy_one` at the moment it decides to create (#2059), since
1917/// that is the only point that knows a recorded id has gone from the account
1918/// and its namespace is about to be created again. There `live_kv_ids` is the
1919/// account's live ids, when fetched: another environment's recorded namespace
1920/// whose id is not among them no longer holds its title (though one this
1921/// source declares there still counts, since that deploy would create it).
1922/// `None` counts every recorded namespace, which is always the case at plan
1923/// time: [`kv_title_collision`] runs before the live ids are fetched, so the
1924/// relaxation applies only to the deploy-time re-check.
1925pub(crate) fn cross_environment_title_clash(
1926    resources: &BTreeMap<String, Resources>,
1927    lock: &DeployLock,
1928    environment: &str,
1929    creates: &[(&str, &str)],
1930    live_kv_ids: Option<&BTreeSet<String>>,
1931) -> Option<String> {
1932    if creates.is_empty() {
1933        return None;
1934    }
1935    let is_live = |id: &str| live_kv_ids.is_none_or(|live| live.contains(id));
1936    let declared = || {
1937        resources.iter().flat_map(|(worker, declared)| {
1938            kv_bindings(declared).map(move |binding| (worker.as_str(), binding))
1939        })
1940    };
1941    for (other, recorded) in &lock.environments {
1942        if other == environment {
1943            continue;
1944        }
1945        // Each title `other` holds or would create, and whose it is: what its
1946        // ledger records first, then what this source declares.
1947        let held = recorded
1948            .kv
1949            .iter()
1950            .filter(|(_, kv)| is_live(&kv.id))
1951            .map(|(worker, _)| {
1952                (
1953                    worker.as_str(),
1954                    bynk_emit::emitter::wrangler::KV_BINDING_NAME,
1955                )
1956            })
1957            .chain(
1958                recorded
1959                    .kv_instances
1960                    .iter()
1961                    .flat_map(|(worker, instances)| {
1962                        instances
1963                            .iter()
1964                            .filter(|(_, kv)| is_live(&kv.id))
1965                            .map(move |(binding, _)| (worker.as_str(), binding.as_str()))
1966                    }),
1967            );
1968        let mut taken: BTreeMap<String, (&str, &str)> = BTreeMap::new();
1969        for (worker, binding) in held.chain(declared()) {
1970            taken
1971                .entry(kv_title(worker, binding, other))
1972                .or_insert((worker, binding));
1973        }
1974        for &(worker, binding) in creates {
1975            let title = kv_title(worker, binding, environment);
1976            if let Some(&(other_worker, other_binding)) = taken.get(&title) {
1977                return Some(format!(
1978                    "{} in {} and {} in {} would both be titled `{title}` on the account, which holds one namespace per title; rename a capability or context so they differ",
1979                    kv_namespace_noun(worker, binding),
1980                    environment_label(environment),
1981                    kv_namespace_noun(other_worker, other_binding),
1982                    environment_label(other),
1983                ));
1984            }
1985        }
1986    }
1987    None
1988}
1989
1990/// How a message names an environment: `the default environment`, or
1991/// `` `--env staging` ``.
1992fn environment_label(environment: &str) -> String {
1993    if environment == "default" {
1994        "the default environment".to_string()
1995    } else {
1996        format!("`--env {environment}`")
1997    }
1998}
1999
2000/// The first KV namespace in `order` with no recorded id, as `(worker,
2001/// binding)`: what CI refuses to mint, since an id created there and never
2002/// committed is an orphan nobody can find again. Every namespace counts, the
2003/// bare `KV` and each derived instance alike (S4b, #1967).
2004fn first_unrecorded_kv<'a>(
2005    order: &'a [String],
2006    resources: &'a BTreeMap<String, Resources>,
2007    lock: &DeployLock,
2008    environment: &str,
2009) -> Option<(&'a str, &'a str)> {
2010    order.iter().find_map(|worker| {
2011        kv_bindings(&resources[worker])
2012            .find(|binding| recorded_kv_binding(lock, worker, binding, environment).is_none())
2013            .map(|binding| (worker.as_str(), binding))
2014    })
2015}
2016
2017/// P3 (#2342): the first rate limiter in `order` with no recorded
2018/// `namespace_id`, as `(worker, binding)`: what CI refuses to allocate.
2019fn first_unrecorded_ratelimit<'a>(
2020    order: &'a [String],
2021    resources: &'a BTreeMap<String, Resources>,
2022    lock: &DeployLock,
2023    environment: &str,
2024) -> Option<(&'a str, &'a str)> {
2025    order.iter().find_map(|worker| {
2026        resources[worker]
2027            .ratelimits
2028            .iter()
2029            .find(|binding| recorded_ratelimit(lock, worker, binding, environment).is_none())
2030            .map(|binding| (worker.as_str(), binding.as_str()))
2031    })
2032}
2033
2034pub(crate) fn requires_interactive_confirmation(yes: bool, stdin_is_terminal: bool) -> bool {
2035    !yes && stdin_is_terminal
2036}
2037
2038fn is_ci() -> bool {
2039    std::env::var_os("CI").is_some_and(|value| value != "false")
2040}
2041
2042fn confirm(yes: bool) -> bool {
2043    if yes {
2044        return true;
2045    }
2046    if !requires_interactive_confirmation(yes, io::stdin().is_terminal()) {
2047        eprintln!("bynk: refusing to mutate in a non-interactive session without --yes");
2048        return false;
2049    }
2050    eprint!("Deploy to Cloudflare? [y/N] ");
2051    let _ = io::stderr().flush();
2052    let mut answer = String::new();
2053    io::stdin().read_line(&mut answer).is_ok()
2054        && matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes")
2055}
2056
2057#[cfg(test)]
2058pub(crate) mod tests {
2059    use super::*;
2060    use crate::deploy::config::tests::project;
2061    use crate::deploy::graph::tests::graph;
2062    use crate::deploy::ledger::tests::{lock_with_deployed, with_kv, with_queue};
2063
2064    fn names(v: &[&str]) -> Vec<String> {
2065        v.iter().map(|s| s.to_string()).collect()
2066    }
2067
2068    /// `derive_plan` with no secret input and no `--force` — the shape every
2069    /// test that predates slice 3 wants, so those tests keep saying what they
2070    /// are about rather than restating two arguments they do not exercise.
2071    pub(crate) fn plan_of<'a>(
2072        order: &'a [String],
2073        resources: &'a BTreeMap<String, Resources>,
2074        lock: &DeployLock,
2075    ) -> Plan<'a> {
2076        derive_plan(
2077            order,
2078            resources,
2079            lock,
2080            &SecretSource::default(),
2081            false,
2082            "default",
2083        )
2084    }
2085
2086    // ---- P5 (#2323): R2 buckets ----
2087
2088    // ---- P3 (#2342): rate limiters ----
2089
2090    #[test]
2091    fn the_plan_allocates_each_rate_limiter_then_reuses_its_id() {
2092        let declared = project(vec![(
2093            "shop",
2094            Resources::default().ratelimits(&["RATELIMIT_LOGIN"]),
2095        )]);
2096        let order = names(&["shop"]);
2097        let fresh = plan_of(&order, &declared, &DeployLock::default());
2098        let report = plan_report(&fresh, DeployFormat::Short);
2099        assert!(
2100            report.contains("ratelimit allocate RATELIMIT_LOGIN\n"),
2101            "{report}"
2102        );
2103        assert_eq!(
2104            first_unrecorded_ratelimit(&order, &declared, &DeployLock::default(), "default"),
2105            Some(("shop", "RATELIMIT_LOGIN")),
2106            "what CI refuses to allocate"
2107        );
2108        let mut lock = DeployLock::default();
2109        lock.allocate_ratelimit("default", "shop", "RATELIMIT_LOGIN");
2110        let again = plan_of(&order, &declared, &lock);
2111        let report = plan_report(&again, DeployFormat::Short);
2112        let id = crate::deploy::ledger::ratelimit_seed("default", "shop", "RATELIMIT_LOGIN");
2113        assert!(
2114            report.contains(&format!(
2115                "ratelimit reuse RATELIMIT_LOGIN (namespace {id})\n"
2116            )),
2117            "{report}"
2118        );
2119        assert_eq!(
2120            first_unrecorded_ratelimit(&order, &declared, &lock, "default"),
2121            None
2122        );
2123    }
2124
2125    fn shop_with_buckets() -> BTreeMap<String, Resources> {
2126        project(vec![(
2127            "shop",
2128            Resources::default().r2_buckets(&[
2129                ("R2_ARCHIVES", "shop-archives", None),
2130                ("R2_INDEXES", "shop-indexes", Some("eu")),
2131            ]),
2132        )])
2133    }
2134
2135    #[test]
2136    fn the_plan_creates_each_bucket_then_reuses_it() {
2137        let declared = shop_with_buckets();
2138        let order = names(&["shop"]);
2139        let fresh = plan_of(&order, &declared, &DeployLock::default());
2140        let r2: Vec<(&str, &str, &str)> = fresh.contexts[0]
2141            .r2
2142            .iter()
2143            .map(|b| (b.binding, b.action, b.bucket.as_str()))
2144            .collect();
2145        assert_eq!(
2146            r2,
2147            [
2148                ("R2_ARCHIVES", "create", "shop-archives"),
2149                ("R2_INDEXES", "create", "shop-indexes")
2150            ]
2151        );
2152        let mut lock = DeployLock::default();
2153        lock.record_r2("default", "shop", "R2_ARCHIVES", "shop-archives", None);
2154        lock.record_r2("default", "shop", "R2_INDEXES", "shop-indexes", Some("eu"));
2155        let again = plan_of(&order, &declared, &lock);
2156        assert!(again.contexts[0].r2.iter().all(|b| b.action == "reuse"));
2157    }
2158
2159    #[test]
2160    fn a_named_environment_plans_its_own_qualified_buckets() {
2161        let declared = shop_with_buckets();
2162        let order = names(&["shop"]);
2163        let plan = derive_plan(
2164            &order,
2165            &declared,
2166            &DeployLock::default(),
2167            &SecretSource::default(),
2168            false,
2169            "staging",
2170        );
2171        assert_eq!(plan.contexts[0].r2[0].bucket, "shop-archives-staging");
2172        let report = plan_report(&plan, DeployFormat::Short);
2173        assert!(
2174            report.contains("r2 create shop-archives-staging (R2_ARCHIVES)\n"),
2175            "{report}"
2176        );
2177        assert!(
2178            report.contains("r2 create shop-indexes-staging (R2_INDEXES) in jurisdiction eu\n"),
2179            "{report}"
2180        );
2181    }
2182
2183    #[test]
2184    fn a_changed_jurisdiction_is_a_recreate_that_says_the_old_bucket_stays() {
2185        let declared = shop_with_buckets();
2186        let mut lock = DeployLock::default();
2187        lock.record_r2("default", "shop", "R2_ARCHIVES", "shop-archives", None);
2188        lock.record_r2(
2189            "default",
2190            "shop",
2191            "R2_INDEXES",
2192            "shop-indexes",
2193            Some("fedramp"),
2194        );
2195        let order = names(&["shop"]);
2196        let plan = plan_of(&order, &declared, &lock);
2197        assert_eq!(plan.contexts[0].r2[0].action, "reuse");
2198        assert_eq!(plan.contexts[0].r2[1].action, "recreate");
2199        let report = plan_report(&plan, DeployFormat::Short);
2200        assert!(
2201            report.contains("r2 recreate shop-indexes (R2_INDEXES) in jurisdiction eu — the old bucket and its objects stay where they are"),
2202            "{report}"
2203        );
2204    }
2205
2206    #[test]
2207    fn a_recreate_without_yes_is_refused_and_with_yes_is_not() {
2208        let declared = shop_with_buckets();
2209        let mut lock = DeployLock::default();
2210        lock.record_r2("default", "shop", "R2_ARCHIVES", "shop-archives", None);
2211        lock.record_r2(
2212            "default",
2213            "shop",
2214            "R2_INDEXES",
2215            "shop-indexes",
2216            Some("fedramp"),
2217        );
2218        let order = names(&["shop"]);
2219        let plan = plan_of(&order, &declared, &lock);
2220        assert!(recreate_refusal(&plan, false).is_some());
2221        assert!(recreate_refusal(&plan, true).is_none());
2222        // Nothing to recreate, nothing to refuse.
2223        let fresh = plan_of(&order, &declared, &DeployLock::default());
2224        assert!(recreate_refusal(&fresh, false).is_none());
2225    }
2226
2227    #[test]
2228    fn a_bucket_a_changed_jurisdiction_left_behind_is_reported_as_an_orphan() {
2229        let declared = shop_with_buckets();
2230        let mut lock = DeployLock::default();
2231        lock.record_r2(
2232            "default",
2233            "shop",
2234            "R2_INDEXES",
2235            "shop-indexes",
2236            Some("fedramp"),
2237        );
2238        lock.record_r2("default", "shop", "R2_INDEXES", "shop-indexes", Some("eu"));
2239        let order = names(&["shop"]);
2240        let report = plan_report(&plan_of(&order, &declared, &lock), DeployFormat::Short);
2241        assert!(
2242            report.contains(
2243                "orphan r2 shop R2_INDEXES (shop-indexes, jurisdiction fedramp; not deleted)\n"
2244            ),
2245            "{report}"
2246        );
2247    }
2248
2249    #[test]
2250    fn an_orphaned_bucket_is_shown_with_its_name_and_kept() {
2251        let declared = project(vec![("shop", Resources::default())]);
2252        let mut lock = DeployLock::default();
2253        lock.record_r2("default", "shop", "R2_OLD", "shop-old", None);
2254        let order = names(&["shop"]);
2255        let plan = plan_of(&order, &declared, &lock);
2256        let report = plan_report(&plan, DeployFormat::Short);
2257        assert!(
2258            report.contains("orphan r2 shop R2_OLD (shop-old; not deleted)\n"),
2259            "{report}"
2260        );
2261        assert!(!plan.orphans.has_prunable());
2262    }
2263
2264    #[test]
2265    fn a_derived_name_r2_would_refuse_is_an_error_naming_the_instance() {
2266        let lock = DeployLock::default();
2267        let long = project(vec![(
2268            "a-very-long-context-name-that-goes-on-and-on",
2269            Resources::default().r2_buckets(&[(
2270                "R2_SOME_RATHER_LONG_CAPABILITY_NAME",
2271                "a-very-long-context-name-that-goes-on-and-on-some-rather-long-capability-name",
2272                None,
2273            )]),
2274        )]);
2275        let message = r2_bucket_problem(&long, &lock, "default").expect("too long");
2276        assert!(
2277            message.contains("`R2_SOME_RATHER_LONG_CAPABILITY_NAME`"),
2278            "{message}"
2279        );
2280        assert!(message.contains("3 to 63 characters"), "{message}");
2281        // The environment suffix counts toward the limit.
2282        let edge = project(vec![(
2283            "shop",
2284            Resources::default().r2_buckets(&[("R2_X", &"a".repeat(60), None)]),
2285        )]);
2286        assert!(r2_bucket_problem(&edge, &lock, "default").is_none());
2287        assert!(r2_bucket_problem(&edge, &lock, "production").is_some());
2288        let underscore = project(vec![(
2289            "shop_front",
2290            Resources::default().r2_buckets(&[("R2_X", "shop_front-x", None)]),
2291        )]);
2292        assert!(
2293            r2_bucket_problem(&underscore, &lock, "default")
2294                .unwrap()
2295                .contains("lowercase letters, digits and hyphens")
2296        );
2297    }
2298
2299    #[test]
2300    fn two_instances_that_would_share_a_bucket_name_are_refused() {
2301        let colliding = project(vec![
2302            (
2303                "shop-cart",
2304                Resources::default().r2_buckets(&[("R2_A", "shop-cart-a", None)]),
2305            ),
2306            (
2307                "shop",
2308                Resources::default().r2_buckets(&[("R2_CART_A", "shop-cart-a", None)]),
2309            ),
2310        ]);
2311        let message =
2312            r2_bucket_problem(&colliding, &DeployLock::default(), "default").expect("a collision");
2313        assert!(message.contains("`shop-cart-a`"), "{message}");
2314        // Another environment's bucket holding the name is the same clash.
2315        let mut lock = DeployLock::default();
2316        lock.record_r2(
2317            "production",
2318            "shop",
2319            "R2_OTHER",
2320            "shop-archives-staging",
2321            None,
2322        );
2323        let staging = project(vec![(
2324            "shop",
2325            Resources::default().r2_buckets(&[("R2_ARCHIVES", "shop-archives", None)]),
2326        )]);
2327        assert!(r2_bucket_problem(&staging, &lock, "staging").is_some());
2328        assert!(r2_bucket_problem(&staging, &lock, "default").is_none());
2329    }
2330
2331    // ---- interop I5 (#1972): Service Bindings to foreign Workers ----
2332
2333    #[test]
2334    fn a_foreign_service_is_a_plan_line_with_no_action() {
2335        let resources = project(vec![(
2336            "shop-billing",
2337            Resources::default().foreign(&[("SERVICE_BILLING", "legacy-billing")]),
2338        )]);
2339        let order = names(&["shop-billing"]);
2340        let plan = plan_of(&order, &resources, &DeployLock::default());
2341        assert_eq!(
2342            plan_report(&plan, DeployFormat::Short),
2343            // I2 (#1971): a binding to a foreign Worker is no edge, so it
2344            // carries no edge key: the foreign Worker is not Bynk's and
2345            // never receives one.
2346            "service SERVICE_BILLING legacy-billing (foreign Worker; not provisioned)\ndeploy shop-billing\n"
2347        );
2348        let json: serde_json::Value =
2349            serde_json::from_str(&plan_report(&plan, DeployFormat::Json)).unwrap();
2350        assert_eq!(
2351            json["contexts"][0]["foreign_services"],
2352            serde_json::json!([{ "binding": "SERVICE_BILLING", "worker": "legacy-billing" }])
2353        );
2354        assert_eq!(json["contexts"][0]["binds_to"], serde_json::json!([]));
2355    }
2356
2357    /// Track §11 Q8: warn, never refuse. Each Worker is asked once however
2358    /// many contexts bind it; a present one says nothing, a missing one says
2359    /// Cloudflare will reject the upload, and an unreadable answer is a
2360    /// warning of its own rather than a guess either way.
2361    #[test]
2362    fn foreign_worker_warnings_ask_once_and_never_refuse() {
2363        let resources = project(vec![
2364            (
2365                "a",
2366                Resources::default().foreign(&[("SERVICE_BILLING", "legacy-billing")]),
2367            ),
2368            (
2369                "b",
2370                Resources::default().foreign(&[
2371                    ("SERVICE_BILLING", "legacy-billing"),
2372                    ("SERVICE_LEDGER", "ledger"),
2373                    ("SERVICE_TAX", "tax"),
2374                ]),
2375            ),
2376        ]);
2377        let order = names(&["a", "b"]);
2378        let mut asked = Vec::new();
2379        let warnings = foreign_worker_warnings(&order, &resources, |name| {
2380            asked.push(name.to_string());
2381            match name {
2382                "legacy-billing" => WorkerPresence::Missing,
2383                "ledger" => WorkerPresence::Present,
2384                _ => WorkerPresence::Unknown("Authentication error [code: 10000]".into()),
2385            }
2386        });
2387        assert_eq!(asked, ["ledger", "legacy-billing", "tax"]);
2388        assert_eq!(warnings.len(), 2);
2389        assert!(
2390            warnings[0].contains("`a`, `b` binds to Worker `legacy-billing`")
2391                && warnings[0].contains("rejects a Service Binding to a missing Worker at upload"),
2392            "{}",
2393            warnings[0]
2394        );
2395        assert!(
2396            warnings[1].contains("could not check that Worker `tax`")
2397                && warnings[1].contains("[code: 10000]"),
2398            "{}",
2399            warnings[1]
2400        );
2401    }
2402
2403    /// Only Cloudflare's "Worker not found" codes read as missing.
2404    #[test]
2405    fn classify_worker_listing_reads_only_not_found_as_missing() {
2406        assert_eq!(classify_worker_listing(true, ""), WorkerPresence::Present);
2407        assert_eq!(
2408            classify_worker_listing(
2409                false,
2410                "A request to the Cloudflare API (/accounts/x/workers/scripts/legacy-billing/deployments) failed.\n  This Worker does not exist on your account. [code: 10007]"
2411            ),
2412            WorkerPresence::Missing
2413        );
2414        assert_eq!(
2415            classify_worker_listing(false, "… [code: 10090]"),
2416            WorkerPresence::Missing
2417        );
2418        assert!(matches!(
2419            classify_worker_listing(false, "fetch failed"),
2420            WorkerPresence::Unknown(_)
2421        ));
2422    }
2423
2424    // ---- v0.177 (#643): the deploy-time contract-skew gate ----
2425
2426    fn lock_with(worker: &str, contracts: &[(&str, &str)]) -> DeployLock {
2427        let mut lock = DeployLock::default();
2428        lock.record_deployed(
2429            "default",
2430            worker,
2431            Some(
2432                contracts
2433                    .iter()
2434                    .map(|(s, h)| (s.to_string(), h.to_string()))
2435                    .collect(),
2436            ),
2437        );
2438        lock
2439    }
2440
2441    fn expects(dep: &str, svc: &str, hash: &str) -> BTreeMap<String, BTreeMap<String, String>> {
2442        BTreeMap::from([(
2443            dep.to_string(),
2444            BTreeMap::from([(svc.to_string(), hash.to_string())]),
2445        )])
2446    }
2447
2448    #[test]
2449    fn conflicting_env_passthrough_finds_bare_and_equals_forms() {
2450        assert_eq!(
2451            conflicting_env_passthrough(&names(&["--env", "production"])),
2452            Some("--env")
2453        );
2454        assert_eq!(
2455            conflicting_env_passthrough(&names(&["--env=production"])),
2456            Some("--env=production")
2457        );
2458        assert_eq!(
2459            conflicting_env_passthrough(&names(&["--environment", "production"])),
2460            Some("--environment")
2461        );
2462        assert_eq!(
2463            conflicting_env_passthrough(&names(&["--minify"])),
2464            None,
2465            "an unrelated flag is not a conflict"
2466        );
2467        assert_eq!(conflicting_env_passthrough(&[]), None);
2468    }
2469
2470    #[test]
2471    fn plan_creates_or_reuses_kv_from_the_ledger() {
2472        let order = names(&["api"]);
2473        let declared = project(vec![("api", Resources::default().needs_kv())]);
2474        let fresh = DeployLock::default();
2475        assert_eq!(
2476            plan_of(&order, &declared, &fresh).contexts[0].kv[0].action,
2477            "create"
2478        );
2479        assert_eq!(
2480            plan_of(&order, &declared, &with_kv(DeployLock::default(), "api")).contexts[0].kv[0]
2481                .action,
2482            "reuse"
2483        );
2484        assert!(
2485            plan_of(
2486                &order,
2487                &project(vec![("api", Resources::default())]),
2488                &fresh
2489            )
2490            .contexts[0]
2491                .kv
2492                .is_empty(),
2493            "a context declaring no KV gets no KV line"
2494        );
2495    }
2496
2497    // ---- #600 slice 1: queues and Durable Objects ----------------------
2498
2499    #[test]
2500    fn plan_creates_or_reuses_a_queue_by_its_name() {
2501        // Queues reconcile on the name `from queue("n")` gave them — there is
2502        // no id — so the ledger's whole answer is "have we made this before?"
2503        let order = names(&["jobs"]);
2504        let declared = project(vec![("jobs", Resources::default().consumes(&["intake"]))]);
2505        let line =
2506            |lock: &DeployLock| plan_of(&order, &declared, lock).contexts[0].queues[0].action;
2507        assert_eq!(line(&DeployLock::default()), "create");
2508        assert_eq!(line(&with_queue(DeployLock::default(), "intake")), "reuse");
2509        // The name is keyed environment-wide, not per worker: a different
2510        // context consuming `intake` means the same queue.
2511        assert!(with_queue(DeployLock::default(), "intake").has_queue("default", "intake"));
2512        assert!(!with_queue(DeployLock::default(), "intake").has_queue("default", "other"));
2513    }
2514
2515    #[test]
2516    fn a_context_with_no_queues_gets_no_queue_lines() {
2517        assert!(
2518            plan_of(
2519                &names(&["api"]),
2520                &project(vec![("api", Resources::default())]),
2521                &DeployLock::default(),
2522            )
2523            .contexts[0]
2524                .queues
2525                .is_empty()
2526        );
2527    }
2528
2529    #[test]
2530    fn a_passthrough_secrets_file_is_refused_only_when_the_run_has_a_first_deploy() {
2531        // #2055: a first deploy passes its own `--secrets-file`.
2532        let order = names(&["api", "web"]);
2533        let mut lock = DeployLock::default();
2534        lock.record_deployed("staging", "api", None);
2535        let args = |a: &[&str]| names(a);
2536        assert_eq!(
2537            secrets_file_passthrough_refusal(
2538                &args(&["--secrets-file", "s.env"]),
2539                &order,
2540                &lock,
2541                "staging"
2542            )
2543            .as_deref(),
2544            Some(
2545                "bynk: `--secrets-file` after `--` conflicts with the secrets file `bynk deploy` uploads with a new Worker's first version (`web-staging`).\n  \
2546                 Pass it to bynk instead, before `--`: `bynk deploy --secrets-file <file>`.\n"
2547            )
2548        );
2549        assert!(
2550            secrets_file_passthrough_refusal(
2551                &args(&["--secrets-file=s.env"]),
2552                &order,
2553                &lock,
2554                "staging"
2555            )
2556            .is_some_and(|r| r.contains("`--secrets-file=s.env`")),
2557            "the `=`-joined spelling is the same flag"
2558        );
2559        lock.record_deployed("staging", "web", None);
2560        assert_eq!(
2561            secrets_file_passthrough_refusal(
2562                &args(&["--secrets-file", "s.env"]),
2563                &order,
2564                &lock,
2565                "staging"
2566            ),
2567            None,
2568            "a redeploy passes no secrets file of its own"
2569        );
2570        assert_eq!(
2571            secrets_file_passthrough_refusal(&args(&["--minify"]), &order, &lock, "default"),
2572            None
2573        );
2574    }
2575
2576    #[test]
2577    fn a_wrangler_below_the_floor_refuses_only_a_push_that_declares_a_durable_object() {
2578        // #1796: `exports` is read from WRANGLER_MIN on, so below it a context
2579        // with an agent can't deploy; one without still can.
2580        let min = probe::Version::parse(bynk_emit::WRANGLER_MIN).unwrap();
2581        let wrangler = |version: probe::Version| probe::Probe {
2582            tool: "wrangler".to_string(),
2583            version: Some(version),
2584            provenance: probe::Provenance::Path("/usr/bin/wrangler".into()),
2585        };
2586        // The newest version strictly below the floor, whatever the floor is.
2587        let old = if min.patch > 0 {
2588            probe::Version {
2589                patch: min.patch - 1,
2590                ..min
2591            }
2592        } else if min.minor > 0 {
2593            probe::Version {
2594                minor: min.minor - 1,
2595                patch: 999,
2596                ..min
2597            }
2598        } else {
2599            probe::Version {
2600                major: min.major - 1,
2601                minor: 999,
2602                patch: 999,
2603            }
2604        };
2605        let resources = project(vec![
2606            ("api", Resources::default()),
2607            ("jobs", Resources::default().exports(&["JobLedger"])),
2608        ]);
2609        let both = names(&["api", "jobs"]);
2610
2611        let refusal = wrangler_floor_refusal(&wrangler(old), &both, &resources)
2612            .expect("an agent-bearing push below the floor is refused");
2613        for part in [
2614            format!("wrangler {old}"),
2615            bynk_emit::WRANGLER_MIN.to_string(),
2616            "`jobs`".to_string(),
2617            "npm install -g wrangler@4".to_string(),
2618        ] {
2619            assert!(refusal.contains(&part), "missing {part:?} in: {refusal}");
2620        }
2621
2622        // No class in what's pushed (`--context api`), the floor itself, or an
2623        // unversioned npx wrangler: nothing to refuse.
2624        assert_eq!(
2625            wrangler_floor_refusal(&wrangler(old), &names(&["api"]), &resources),
2626            None
2627        );
2628        assert_eq!(
2629            wrangler_floor_refusal(&wrangler(min), &both, &resources),
2630            None
2631        );
2632        let npx = probe::Probe {
2633            tool: "wrangler".to_string(),
2634            version: None,
2635            provenance: probe::Provenance::Npx,
2636        };
2637        assert_eq!(wrangler_floor_refusal(&npx, &both, &resources), None);
2638    }
2639
2640    /// State-migrations S3 (#2248): a rename and a deletion each get a plan
2641    /// line saying what happens to stored data; a deletion is named as
2642    /// `<context>.<Class>`, the form `--confirm-delete` takes.
2643    #[test]
2644    fn renames_and_deletions_are_planned_by_what_they_do_to_data() {
2645        let order = names(&["shop-orders"]);
2646        let declared = Resources {
2647            renamed_durable_objects: vec![("Cart".to_string(), "Basket".to_string())],
2648            deleted_durable_objects: vec!["Wishlist".to_string()],
2649            ..Resources::default().exports(&["Basket"])
2650        };
2651        let resources = project(vec![("shop-orders", declared)]);
2652        let plan = plan_of(&order, &resources, &DeployLock::default());
2653        let context = &plan.contexts[0];
2654        assert_eq!(context.deleted_durable_objects.len(), 1);
2655        assert_eq!(
2656            context.deleted_durable_objects[0].class,
2657            "shop.orders.Wishlist"
2658        );
2659        assert!(!context.deleted_durable_objects[0].already_applied);
2660        let text = plan_report(&plan, DeployFormat::Short);
2661        assert!(
2662            text.contains("rename durable object Cart → Basket (moves its stored data)"),
2663            "{text}"
2664        );
2665        assert!(
2666            text.contains(
2667                "delete durable object shop.orders.Wishlist (all stored data; irreversible)"
2668            ),
2669            "{text}"
2670        );
2671        let json = plan_report(&plan, DeployFormat::Json);
2672        assert!(json.contains("\"durable_objects_renamed\""), "{json}");
2673        assert!(json.contains("\"shop.orders.Wishlist\""), "{json}");
2674    }
2675
2676    /// #2279 review: what a push records is what the next plan reads, for
2677    /// renames and deletions alike, and a later push without them clears both.
2678    #[test]
2679    fn a_push_records_its_tombstones_for_the_next_plan() {
2680        let order = names(&["shop"]);
2681        let declared = Resources {
2682            renamed_durable_objects: vec![("Box".to_string(), "Crate".to_string())],
2683            deleted_durable_objects: vec!["Wishlist".to_string()],
2684            ..Resources::default().exports(&["Crate"])
2685        };
2686        let mut lock = DeployLock::default();
2687        record_pushed_tombstones(&mut lock, "default", "shop", &declared);
2688        let resources = project(vec![("shop", declared)]);
2689        let plan = plan_of(&order, &resources, &lock);
2690        assert!(plan.contexts[0].renamed_durable_objects[0].already_applied);
2691        assert!(plan.contexts[0].deleted_durable_objects[0].already_applied);
2692        record_pushed_tombstones(&mut lock, "default", "shop", &Resources::default());
2693        assert!(!lock.has_renamed_class("default", "shop", "Box", "Crate"));
2694        assert!(!lock.has_deleted_class("default", "shop", "Wishlist"));
2695    }
2696
2697    /// #2276 Decision D: a rename an earlier push carried reads as applied;
2698    /// a different target, or another environment, still reads as moving data.
2699    #[test]
2700    fn a_rename_already_pushed_reads_as_applied() {
2701        let order = names(&["shop"]);
2702        let declared = Resources {
2703            renamed_durable_objects: vec![("Box".to_string(), "Crate".to_string())],
2704            ..Resources::default().exports(&["Crate"])
2705        };
2706        let resources = project(vec![("shop", declared)]);
2707        let mut lock = DeployLock::default();
2708        lock.record_renamed_classes(
2709            "default",
2710            "shop",
2711            [("Box".to_string(), "Crate".to_string())].into(),
2712        );
2713        let plan = plan_of(&order, &resources, &lock);
2714        assert!(plan.contexts[0].renamed_durable_objects[0].already_applied);
2715        let text = plan_report(&plan, DeployFormat::Short);
2716        assert!(
2717            text.contains("rename durable object Box → Crate (already applied by an earlier deploy; the tombstone stays until Cloudflare reports it stale)"),
2718            "{text}"
2719        );
2720        let json = plan_report(&plan, DeployFormat::Json);
2721        assert!(json.contains("\"already_applied\": true"), "{json}");
2722        lock.record_renamed_classes(
2723            "default",
2724            "shop",
2725            [("Box".to_string(), "Bin".to_string())].into(),
2726        );
2727        let plan = plan_of(&order, &resources, &lock);
2728        assert!(!plan.contexts[0].renamed_durable_objects[0].already_applied);
2729        lock.record_renamed_classes("default", "shop", BTreeMap::new());
2730        assert!(!lock.has_renamed_class("default", "shop", "Box", "Bin"));
2731        assert!(
2732            !toml::to_string(&lock).unwrap().contains("renamed_classes"),
2733            "an empty record leaves nothing in the ledger"
2734        );
2735    }
2736
2737    /// State-migrations S3 (#2248, Decision E): a deletion needs a named
2738    /// confirmation; `--yes` never stands in, and a confirmation for a class
2739    /// the plan does not delete is refused. (Tests run without a terminal, so
2740    /// the interactive prompt is not reached.)
2741    #[test]
2742    fn a_deletion_needs_its_own_named_confirmation() {
2743        let order = names(&["shop"]);
2744        let declared = Resources {
2745            deleted_durable_objects: vec!["Wishlist".to_string()],
2746            ..Resources::default()
2747        };
2748        let resources = project(vec![("shop", declared)]);
2749        let lock = DeployLock::default();
2750        let plan = plan_of(&order, &resources, &lock);
2751        assert!(
2752            !confirm_deletions(&plan, &[], &lock),
2753            "unconfirmed: refused"
2754        );
2755        assert!(confirm_deletions(&plan, &names(&["shop.Wishlist"]), &lock));
2756        assert!(
2757            !confirm_deletions(&plan, &names(&["shop.Wishlist", "shop.Other"]), &lock),
2758            "a confirmation for a class not deleted is refused"
2759        );
2760        let none = project(vec![("shop", Resources::default())]);
2761        let quiet = plan_of(&order, &none, &lock);
2762        assert!(
2763            confirm_deletions(&quiet, &[], &lock),
2764            "nothing to delete, nothing to confirm"
2765        );
2766        assert!(!confirm_deletions(
2767            &quiet,
2768            &names(&["shop.Wishlist"]),
2769            &lock
2770        ));
2771    }
2772
2773    /// #2253 review: the `removed agent` declaration stays until Cloudflare
2774    /// reports its tombstone stale, so the deploy after the deletion carries
2775    /// the tombstone again. The ledger records the first push's deletion, so
2776    /// the second plan shows it as applied and asks nothing; a CI flag still
2777    /// naming it — even after the declaration is gone — is not refused.
2778    #[test]
2779    fn a_deletion_already_pushed_is_not_confirmed_again() {
2780        let order = names(&["shop"]);
2781        let declared = Resources {
2782            deleted_durable_objects: vec!["Wishlist".to_string()],
2783            ..Resources::default()
2784        };
2785        let resources = project(vec![("shop", declared)]);
2786        let mut lock = DeployLock::default();
2787        lock.record_deleted_classes("default", "shop", ["Wishlist".to_string()].into());
2788        let plan = plan_of(&order, &resources, &lock);
2789        assert!(plan.contexts[0].deleted_durable_objects[0].already_applied);
2790        let text = plan_report(&plan, DeployFormat::Short);
2791        assert!(
2792            text.contains("already deleted by an earlier deploy"),
2793            "{text}"
2794        );
2795        assert!(
2796            confirm_deletions(&plan, &[], &lock),
2797            "an applied deletion asks nothing"
2798        );
2799        assert!(confirm_deletions(&plan, &names(&["shop.Wishlist"]), &lock));
2800        // The declaration removed: the plan deletes nothing, and the leftover
2801        // flag names a deletion the ledger records, so it is not refused.
2802        let gone = project(vec![("shop", Resources::default())]);
2803        let after = plan_of(&order, &gone, &lock);
2804        assert!(confirm_deletions(&after, &names(&["shop.Wishlist"]), &lock));
2805        assert!(!confirm_deletions(&after, &names(&["shop.Other"]), &lock));
2806    }
2807
2808    #[test]
2809    fn the_durable_object_lines_are_advisory_in_every_ledger_state() {
2810        // #1796, ADR 0194 D1's principle: Cloudflare reconciles the declared
2811        // `exports` against the Worker's namespaces, so the plan says what the
2812        // push will *declare* and never what already exists. A ledger that has
2813        // deployed this context before must not change the lines — there is no
2814        // state here for the ledger to have an opinion about.
2815        let order = names(&["jobs"]);
2816        let declared = project(vec![(
2817            "jobs",
2818            Resources::default().exports(&["JobLedger", "__EventsFanout"]),
2819        )]);
2820        for lock in [DeployLock::default(), lock_with_deployed(&["jobs"])] {
2821            let plan = plan_of(&order, &declared, &lock);
2822            let objects = &plan.contexts[0].durable_objects;
2823            assert_eq!(
2824                objects.iter().map(|o| o.class).collect::<Vec<_>>(),
2825                ["JobLedger", "__EventsFanout"],
2826                "every declared class gets a line, the fan-out class included"
2827            );
2828            for object in objects {
2829                assert_eq!(object.storage, "sqlite");
2830                assert_eq!(
2831                    object.reconciled_by, "Cloudflare",
2832                    "the plan names an owner other than bynk — that is the advisory"
2833                );
2834            }
2835        }
2836        // No agent, no Durable Object line.
2837        assert!(
2838            plan_of(
2839                &names(&["api"]),
2840                &project(vec![("api", Resources::default())]),
2841                &DeployLock::default(),
2842            )
2843            .contexts[0]
2844                .durable_objects
2845                .is_empty()
2846        );
2847    }
2848
2849    // ---- #601 slice 2: `--context` dependency liveness (D4) ------------
2850
2851    #[test]
2852    fn context_flag_names_a_dependency_that_was_never_deployed() {
2853        let g = graph(&[("orders", &["payment"]), ("payment", &[])]);
2854        assert_eq!(
2855            absent_dependencies("orders", &g, &DeployLock::default(), "default"),
2856            names(&["payment"]),
2857            "deploying orders alone would fail at upload — say which target is missing"
2858        );
2859        // Once payment is in the ledger, orders alone is fine.
2860        assert!(
2861            absent_dependencies("orders", &g, &lock_with_deployed(&["payment"]), "default")
2862                .is_empty()
2863        );
2864        // A worker with no bindings never has an absent dependency.
2865        assert!(absent_dependencies("payment", &g, &DeployLock::default(), "default").is_empty());
2866    }
2867
2868    #[test]
2869    fn the_plan_distinguishes_a_first_deploy_from_a_redeploy() {
2870        let order = names(&["api"]);
2871        let declared = project(vec![("api", Resources::default())]);
2872        assert_eq!(
2873            plan_of(&order, &declared, &DeployLock::default()).contexts[0].action,
2874            "deploy"
2875        );
2876        assert_eq!(
2877            plan_of(&order, &declared, &lock_with_deployed(&["api"])).contexts[0].action,
2878            "redeploy",
2879            "a re-run re-pushes rather than skipping, so the plan must not say `deploy`"
2880        );
2881    }
2882
2883    #[test]
2884    fn the_plan_carries_the_order_and_each_context_s_bindings() {
2885        let order = names(&["payment", "orders"]);
2886        let declared = project(vec![
2887            ("orders", Resources::default().binds(&["payment"])),
2888            ("payment", Resources::default()),
2889        ]);
2890        let plan = plan_of(&order, &declared, &DeployLock::default());
2891        assert_eq!(plan.order, vec!["payment", "orders"]);
2892        assert_eq!(plan.contexts[1].worker, "orders");
2893        assert_eq!(plan.contexts[1].binds_to, vec!["payment"]);
2894        assert!(plan.contexts[0].binds_to.is_empty());
2895    }
2896
2897    /// #2028: under `--env staging` the plan names what the push creates —
2898    /// each Worker, binding target and queue `-staging` — while the ledger,
2899    /// keyed by logical name within the environment, still decides each
2900    /// action.
2901    #[test]
2902    fn under_a_named_environment_the_plan_names_what_the_push_creates() {
2903        let order = names(&["payment", "orders"]);
2904        let declared = project(vec![
2905            (
2906                "orders",
2907                Resources::default().binds(&["payment"]).consumes(&["jobs"]),
2908            ),
2909            ("payment", Resources::default()),
2910        ]);
2911        let mut lock = DeployLock::default();
2912        lock.record_deployed("staging", "payment", Some(Default::default()));
2913        lock.record_deployed("staging", "gone", Some(Default::default()));
2914        lock.record_queue("staging", "jobs");
2915        lock.record_queue("staging", "old-jobs");
2916        let plan = derive_plan(
2917            &order,
2918            &declared,
2919            &lock,
2920            &SecretSource::default(),
2921            false,
2922            "staging",
2923        );
2924        assert_eq!(plan.order, vec!["payment-staging", "orders-staging"]);
2925        assert_eq!(plan.contexts[0].worker, "payment-staging");
2926        assert_eq!(plan.contexts[0].action, "redeploy", "found by logical name");
2927        assert_eq!(plan.contexts[1].worker, "orders-staging");
2928        assert_eq!(plan.contexts[1].action, "deploy");
2929        assert_eq!(plan.contexts[1].binds_to, vec!["payment-staging"]);
2930        assert_eq!(plan.contexts[1].queues[0].queue, "jobs-staging");
2931        assert_eq!(plan.contexts[1].queues[0].action, "reuse");
2932        // Shown as the account names them; pruned by ledger key.
2933        assert_eq!(plan.shown_orphans.workers, vec!["gone-staging"]);
2934        assert_eq!(plan.shown_orphans.queues, vec!["old-jobs-staging"]);
2935        assert_eq!(plan.orphans.workers, vec!["gone"]);
2936        assert_eq!(plan.orphans.queues, vec!["old-jobs"]);
2937
2938        let text = plan_report(&plan, DeployFormat::Short);
2939        assert!(text.contains("orphan worker gone-staging\n"), "{text}");
2940        assert!(text.contains("orphan queue old-jobs-staging\n"), "{text}");
2941        assert!(text.contains("queue reuse jobs-staging\n"), "{text}");
2942        assert!(text.contains("redeploy payment-staging\n"), "{text}");
2943        assert!(text.contains("deploy orders-staging\n"), "{text}");
2944        assert!(
2945            text.contains("order payment-staging → orders-staging\n"),
2946            "{text}"
2947        );
2948
2949        let json: serde_json::Value =
2950            serde_json::from_str(&plan_report(&plan, DeployFormat::Json)).unwrap();
2951        assert_eq!(
2952            json["order"],
2953            serde_json::json!(["payment-staging", "orders-staging"])
2954        );
2955        assert_eq!(json["contexts"][1]["worker"], "orders-staging");
2956        assert_eq!(
2957            json["contexts"][1]["binds_to"],
2958            serde_json::json!(["payment-staging"])
2959        );
2960        assert_eq!(json["contexts"][1]["queues"][0]["queue"], "jobs-staging");
2961        assert_eq!(
2962            json["orphans"]["workers"],
2963            serde_json::json!(["gone-staging"])
2964        );
2965        assert_eq!(
2966            json["orphans"]["queues"],
2967            serde_json::json!(["old-jobs-staging"])
2968        );
2969    }
2970
2971    /// #2028: the plan and the push get every name from the same functions.
2972    /// The `[env.staging]` block the push hands Wrangler is synthesised from a
2973    /// generated config, and its `name`, Service Binding targets and queue
2974    /// names are exactly the plan's `worker`, `binds_to` and `queues`.
2975    #[test]
2976    fn the_plan_and_the_push_share_one_name_function() {
2977        const ORDERS: &str = r#"name = "shop-orders"
2978main = "index.ts"
2979
2980[[services]]
2981binding = "PAYMENT"
2982service = "shop-payment"
2983
2984[[queues.consumers]]
2985queue = "jobs"
2986"#;
2987        let order = names(&["shop-payment", "shop-orders"]);
2988        let declared = project(vec![
2989            (
2990                "shop-orders",
2991                Resources::default()
2992                    .binds(&["shop-payment"])
2993                    .consumes(&["jobs"]),
2994            ),
2995            ("shop-payment", Resources::default()),
2996        ]);
2997        for environment in ["staging", "production", "default"] {
2998            let plan = derive_plan(
2999                &order,
3000                &declared,
3001                &DeployLock::default(),
3002                &SecretSource::default(),
3003                false,
3004                environment,
3005            );
3006            let orders = &plan.contexts[1];
3007            let pushed: toml::Table = synthesise_environment_block(
3008                ORDERS,
3009                environment,
3010                &BTreeMap::new(),
3011                &BTreeSet::new(),
3012            )
3013            .unwrap()
3014            .parse()
3015            .unwrap();
3016            let block = &pushed["env"][environment];
3017            assert_eq!(block["name"].as_str(), Some(orders.worker.as_str()));
3018            assert_eq!(
3019                block["services"][0]["service"].as_str(),
3020                Some(orders.binds_to[0].as_str())
3021            );
3022            assert_eq!(
3023                block["queues"]["consumers"][0]["queue"].as_str(),
3024                Some(orders.queues[0].queue.as_str())
3025            );
3026            assert_eq!(plan.order[1], orders.worker);
3027            assert_eq!(
3028                orders.worker,
3029                deployed_worker_name(environment, "shop-orders")
3030            );
3031        }
3032    }
3033
3034    #[test]
3035    fn dry_run_and_ci_gates_do_not_reach_mutation() {
3036        assert!(
3037            DeployOptions {
3038                dry_run: true,
3039                ..Default::default()
3040            }
3041            .dry_run
3042        );
3043    }
3044
3045    /// Review of #1974: an instance title another Worker's namespace already
3046    /// takes is refused, naming both; distinct titles pass.
3047    #[test]
3048    fn a_kv_title_two_namespaces_would_share_is_refused() {
3049        let colliding = project(vec![
3050            (
3051                "shop",
3052                Resources::default().kv_instances(&["KV_CART_SESSIONS"]),
3053            ),
3054            (
3055                "shop-cart",
3056                Resources::default().kv_instances(&["KV_SESSIONS"]),
3057            ),
3058        ]);
3059        let message =
3060            kv_title_collision(&colliding, &DeployLock::default(), "default").expect("a collision");
3061        assert!(
3062            message.contains("`shop-cart-sessions`")
3063                && message.contains("`KV_CART_SESSIONS` for `shop`")
3064                && message.contains("`KV_SESSIONS` for `shop-cart`"),
3065            "{message}"
3066        );
3067        // An instance title equal to another Worker's bare namespace title.
3068        let bare = project(vec![
3069            ("shop", Resources::default().kv_instances(&["KV_CART"])),
3070            ("shop-cart", Resources::default().needs_kv()),
3071        ]);
3072        assert!(kv_title_collision(&bare, &DeployLock::default(), "default").is_some());
3073        let distinct = project(vec![(
3074            "shop-cart",
3075            Resources::default()
3076                .needs_kv()
3077                .kv_instances(&["KV_PRICE_CACHE", "KV_SESSIONS"]),
3078        )]);
3079        assert_eq!(
3080            kv_title_collision(&distinct, &DeployLock::default(), "staging"),
3081            None
3082        );
3083    }
3084
3085    /// #2048: Worker `shop` with the bare `Kv` and `capability Staging`. Its
3086    /// bare namespace under `--env staging` and its instance in the default
3087    /// environment are both titled `shop-staging`.
3088    fn shop_with_staging_instance() -> BTreeMap<String, Resources> {
3089        project(vec![(
3090            "shop",
3091            Resources::default()
3092                .needs_kv()
3093                .kv_instances(&["KV_STAGING"]),
3094        )])
3095    }
3096
3097    /// #2048: a first staging deploy whose bare title the default
3098    /// environment's instance already holds is refused, naming both sides
3099    /// and both environments.
3100    #[test]
3101    fn a_title_another_environment_holds_is_refused() {
3102        let resources = shop_with_staging_instance();
3103        let mut lock = DeployLock::default();
3104        lock.record_kv("default", "shop", "KV", "bare-id");
3105        lock.record_kv("default", "shop", "KV_STAGING", "instance-id");
3106        let message = kv_title_collision(&resources, &lock, "staging").expect("a collision");
3107        assert_eq!(
3108            message,
3109            "KV namespace for `shop` in `--env staging` and KV namespace `KV_STAGING` for `shop` \
3110             in the default environment would both be titled `shop-staging` on the account, \
3111             which holds one namespace per title; rename a capability or context so they differ"
3112        );
3113    }
3114
3115    /// #2048: the reverse order. A default deploy that would create a title
3116    /// a known staging environment would create is refused too, before
3117    /// either fails at `kv namespace create`.
3118    #[test]
3119    fn a_title_another_environment_would_create_is_refused() {
3120        let resources = shop_with_staging_instance();
3121        let mut lock = DeployLock::default();
3122        // Staging is known to the ledger, but has not provisioned KV yet.
3123        lock.record_deployed("staging", "other", Some(Default::default()));
3124        let message = kv_title_collision(&resources, &lock, "default").expect("a collision");
3125        assert!(
3126            message.contains("KV namespace `KV_STAGING` for `shop` in the default environment")
3127                && message.contains("KV namespace for `shop` in `--env staging`")
3128                && message.contains("`shop-staging`"),
3129            "{message}"
3130        );
3131    }
3132
3133    /// #2048: a recorded namespace in another environment holds its title
3134    /// even when the source no longer declares it (an orphan not yet
3135    /// pruned).
3136    #[test]
3137    fn an_orphan_in_another_environment_still_holds_its_title() {
3138        let resources = project(vec![(
3139            "shop",
3140            Resources::default().kv_instances(&["KV_STAGING"]),
3141        )]);
3142        let mut lock = DeployLock::default();
3143        lock.record_kv("staging", "shop", "KV", "orphaned-id");
3144        let message = kv_title_collision(&resources, &lock, "default").expect("a collision");
3145        assert!(
3146            message.contains("KV namespace for `shop` in `--env staging`"),
3147            "{message}"
3148        );
3149    }
3150
3151    /// #2048: only namespaces this deploy creates are checked. One already
3152    /// recorded here exists, so it cannot fail; the other environment's deploy
3153    /// is where the clash is refused.
3154    #[test]
3155    fn a_namespace_already_recorded_here_is_not_refused() {
3156        let resources = shop_with_staging_instance();
3157        let mut lock = DeployLock::default();
3158        lock.record_kv("default", "shop", "KV", "bare-id");
3159        lock.record_kv("default", "shop", "KV_STAGING", "instance-id");
3160        lock.record_deployed("staging", "other", Some(Default::default()));
3161        assert_eq!(kv_title_collision(&resources, &lock, "default"), None);
3162        // ...and the staging deploy is the one refused.
3163        assert!(kv_title_collision(&resources, &lock, "staging").is_some());
3164    }
3165
3166    /// #2048: an environment the ledger does not know is not compared, and
3167    /// distinct titles across known environments pass.
3168    #[test]
3169    fn unknown_environments_and_distinct_titles_pass() {
3170        let resources = shop_with_staging_instance();
3171        assert_eq!(
3172            kv_title_collision(&resources, &DeployLock::default(), "staging"),
3173            None
3174        );
3175        let plain = project(vec![(
3176            "shop-cart",
3177            Resources::default()
3178                .needs_kv()
3179                .kv_instances(&["KV_SESSIONS"]),
3180        )]);
3181        let mut lock = DeployLock::default();
3182        lock.record_kv("default", "shop-cart", "KV", "bare-id");
3183        lock.record_kv("default", "shop-cart", "KV_SESSIONS", "sessions-id");
3184        lock.record_kv("production", "shop-cart", "KV", "prod-id");
3185        assert_eq!(kv_title_collision(&plain, &lock, "staging"), None);
3186    }
3187
3188    /// S4b (#1967): CI refuses any unrecorded namespace, including an
3189    /// instance whose Worker's bare namespace is recorded.
3190    #[test]
3191    fn ci_refuses_the_first_unrecorded_namespace_bare_or_instance() {
3192        use crate::deploy::ledger::tests::with_kv_instance;
3193        let order = names(&["shop-cart"]);
3194        let declared = project(vec![(
3195            "shop-cart",
3196            Resources::default()
3197                .needs_kv()
3198                .kv_instances(&["KV_PRICE_CACHE", "KV_SESSIONS"]),
3199        )]);
3200        assert_eq!(
3201            first_unrecorded_kv(&order, &declared, &DeployLock::default(), "default"),
3202            Some(("shop-cart", "KV"))
3203        );
3204        let bare = with_kv(DeployLock::default(), "shop-cart");
3205        assert_eq!(
3206            first_unrecorded_kv(&order, &declared, &bare, "default"),
3207            Some(("shop-cart", "KV_PRICE_CACHE"))
3208        );
3209        let all = with_kv_instance(
3210            with_kv_instance(bare, "shop-cart", "KV_PRICE_CACHE"),
3211            "shop-cart",
3212            "KV_SESSIONS",
3213        );
3214        assert_eq!(
3215            first_unrecorded_kv(&order, &declared, &all, "default"),
3216            None
3217        );
3218        assert_eq!(
3219            first_unrecorded_kv(&order, &declared, &all, "staging"),
3220            Some(("shop-cart", "KV")),
3221            "recorded under another environment is unrecorded here"
3222        );
3223    }
3224
3225    #[test]
3226    fn an_interrupted_rotation_says_how_to_repair_it() {
3227        let edge = |caller: &str, callee: &str| Edge {
3228            caller: caller.to_string(),
3229            callee: callee.to_string(),
3230        };
3231        let set = |rotating: bool| EdgeKey::Set {
3232            value: "minted-value".into(),
3233            rotating,
3234        };
3235        let keys: BTreeMap<Edge, EdgeKey> = [
3236            (edge("commerce-orders", "commerce-payment"), set(true)),
3237            (edge("commerce-orders", "commerce-ledger"), set(false)),
3238            (edge("commerce-ledger", "commerce-payment"), EdgeKey::Keep),
3239        ]
3240        .into_iter()
3241        .collect();
3242        // `commerce-payment` finished; `commerce-orders` did not.
3243        let hint = interrupted_rotation_hint("default", &keys, &names(&["commerce-payment"]))
3244            .expect("a rotation was interrupted");
3245        assert!(
3246            hint.contains("`commerce-orders` → `commerce-payment`"),
3247            "{hint}"
3248        );
3249        assert!(
3250            !hint.contains("commerce-ledger"),
3251            "an edge that had no key, or kept its key, cannot be split: {hint}"
3252        );
3253        assert!(hint.contains("bynk deploy --force"), "{hint}");
3254        assert!(!hint.contains("minted-value"), "never the value: {hint}");
3255        // Both ends finished, so nothing was split.
3256        assert_eq!(
3257            interrupted_rotation_hint(
3258                "default",
3259                &keys,
3260                &names(&["commerce-payment", "commerce-orders"])
3261            ),
3262            None
3263        );
3264    }
3265
3266    #[test]
3267    fn non_interactive_deploy_requires_yes() {
3268        assert!(!requires_interactive_confirmation(false, false));
3269        assert!(requires_interactive_confirmation(false, true));
3270        assert!(!requires_interactive_confirmation(true, false));
3271    }
3272
3273    #[test]
3274    fn matching_contracts_are_not_a_skew() {
3275        let lock = lock_with("app-b", &[("whoami", "317bdd3de84d2176")]);
3276        let found = contract_skews(
3277            &expects("app.b", "whoami", "317bdd3de84d2176"),
3278            &lock,
3279            |c| c.replace('.', "-"),
3280            "default",
3281        );
3282        assert!(found.is_empty(), "{found:?}");
3283    }
3284
3285    #[test]
3286    fn a_changed_contract_is_a_skew() {
3287        // The scenario the increment exists for: B was redeployed with a new
3288        // contract, and A still stamps the old one.
3289        let lock = lock_with("app-b", &[("whoami", "ffffffffffffffff")]);
3290        let found = contract_skews(
3291            &expects("app.b", "whoami", "317bdd3de84d2176"),
3292            &lock,
3293            |c| c.replace('.', "-"),
3294            "default",
3295        );
3296        assert_eq!(found.len(), 1);
3297        assert_eq!(found[0].dependency, "app.b");
3298        assert_eq!(found[0].service, "whoami");
3299        assert_eq!(found[0].expected, "317bdd3de84d2176");
3300        assert_eq!(found[0].live, "ffffffffffffffff");
3301    }
3302
3303    #[test]
3304    fn a_service_the_live_callee_no_longer_provides_is_a_skew() {
3305        let lock = lock_with("app-b", &[("somethingElse", "317bdd3de84d2176")]);
3306        let found = contract_skews(
3307            &expects("app.b", "whoami", "317bdd3de84d2176"),
3308            &lock,
3309            |c| c.replace('.', "-"),
3310            "default",
3311        );
3312        assert_eq!(found.len(), 1);
3313        assert_eq!(found[0].live, "<absent>");
3314    }
3315
3316    #[test]
3317    fn a_ledger_with_no_contract_record_yields_no_finding() {
3318        // Silence is not a match. A dependency deployed by a pre-v0.177 driver
3319        // has no contract record, and the gate must report only what it *knows*
3320        // is skewed — never what it merely cannot rule out. The runtime check is
3321        // the backstop for exactly this case, so a false accusation here would
3322        // block a legitimate deploy for no gain.
3323        let mut lock = DeployLock::default();
3324        lock.record_deployed("default", "app-b", None);
3325        let found = contract_skews(
3326            &expects("app.b", "whoami", "317bdd3de84d2176"),
3327            &lock,
3328            |c| c.replace('.', "-"),
3329            "default",
3330        );
3331        assert!(found.is_empty(), "{found:?}");
3332    }
3333
3334    #[test]
3335    fn a_callee_that_now_provides_nothing_is_a_total_skew() {
3336        // The counterpart to the rule above, and why the sentinel is `Option`
3337        // rather than an empty map: a callee that removed *all* its `on call`
3338        // services emits no manifest, so a bare-map field would record `{}` —
3339        // indistinguishable from "old ledger" — and the gate would wave through
3340        // the most complete skew there is. `Some({})` says "known to provide
3341        // nothing", which is a finding.
3342        let lock = lock_with("app-b", &[]);
3343        let found = contract_skews(
3344            &expects("app.b", "whoami", "317bdd3de84d2176"),
3345            &lock,
3346            |c| c.replace('.', "-"),
3347            "default",
3348        );
3349        assert_eq!(found.len(), 1);
3350        assert_eq!(found[0].live, "<absent>");
3351    }
3352
3353    #[test]
3354    fn a_never_deployed_dependency_yields_no_finding_here() {
3355        // That is D4's existing job (`absent_dependencies`), and it runs first.
3356        // Reporting it twice, in two vocabularies, would only confuse.
3357        let found = contract_skews(
3358            &expects("app.b", "whoami", "317bdd3de84d2176"),
3359            &DeployLock::default(),
3360            |c| c.replace('.', "-"),
3361            "default",
3362        );
3363        assert!(found.is_empty(), "{found:?}");
3364    }
3365
3366    // ---- #2077: the callee half of the contract-skew gate ----
3367
3368    /// Chapter 14 of the shop, live: payment provides `authorise` at
3369    /// `febd15c2757b72e1`, and orders was compiled against it.
3370    fn live_shop(orders_expects: Option<&str>) -> DeployLock {
3371        let mut lock = lock_with("shop-payment", &[("authorise", "febd15c2757b72e1")]);
3372        lock.record_expects("default", "shop-payment", BTreeMap::new());
3373        lock.record_deployed("default", "shop-orders", Some(BTreeMap::new()));
3374        if let Some(hash) = orders_expects {
3375            lock.record_expects(
3376                "default",
3377                "shop-orders",
3378                expects("shop.payment", "authorise", hash),
3379            );
3380        }
3381        lock
3382    }
3383
3384    fn provides(svc: &str, hash: &str) -> BTreeMap<String, String> {
3385        BTreeMap::from([(svc.to_string(), hash.to_string())])
3386    }
3387
3388    fn worker_of(c: &str) -> String {
3389        c.replace('.', "-")
3390    }
3391
3392    #[test]
3393    fn a_callee_whose_contract_changed_under_a_live_caller_is_refused() {
3394        // The issue's reproduction: `authorise`'s parameter renamed, so the
3395        // new build provides `da0d725f3a004040` while live orders stamps the
3396        // old hash.
3397        let lock = live_shop(Some("febd15c2757b72e1"));
3398        let found = callee_contract_skews(
3399            "shop-payment",
3400            &provides("authorise", "da0d725f3a004040"),
3401            &lock,
3402            worker_of,
3403            "default",
3404        );
3405        assert_eq!(
3406            found,
3407            [CalleeSkew {
3408                caller: "shop-orders".into(),
3409                dependency: "shop.payment".into(),
3410                service: "authorise".into(),
3411                expected: "febd15c2757b72e1".into(),
3412                provided: "da0d725f3a004040".into(),
3413            }]
3414        );
3415        let refusal = callee_contract_skew_refusal("default", "shop-payment", &found);
3416        assert!(refusal.contains("`shop-orders` calls shop.payment.authorise"));
3417        assert!(refusal.contains(CONTRACT_SKEW));
3418    }
3419
3420    /// #2077 review: a caller this build still has is advised to deploy the
3421    /// whole project, which moves it with the callee.
3422    #[test]
3423    fn a_built_caller_is_refused_with_the_whole_project_remedy() {
3424        let lock = live_shop(Some("febd15c2757b72e1"));
3425        let found = callee_contract_skews(
3426            "shop-payment",
3427            &provides("authorise", "da0d725f3a004040"),
3428            &lock,
3429            worker_of,
3430            "default",
3431        );
3432        let bindings = graph(&[("shop-orders", &["shop-payment"]), ("shop-payment", &[])]);
3433        let (built, orphaned) = split_orphaned_callers(found, &bindings);
3434        assert_eq!(built.len(), 1);
3435        assert!(orphaned.is_empty(), "{orphaned:?}");
3436        let refusal = callee_contract_skew_refusal("default", "shop-payment", &built);
3437        assert!(refusal.contains("Deploy the whole project (`bynk deploy`)"));
3438    }
3439
3440    /// #2077 review: a caller of a context removed from the project is still
3441    /// live in the ledger, and still refused — but a whole-project deploy
3442    /// would neither move nor check it, so its refusal says to retire it (or
3443    /// restore its context), never just to deploy the whole project.
3444    #[test]
3445    fn an_orphaned_caller_is_refused_with_the_orphan_remedy() {
3446        let mut lock = live_shop(Some("febd15c2757b72e1"));
3447        lock.record_deployed("staging", "shop-payment", Some(BTreeMap::new()));
3448        lock.record_deployed("staging", "shop-legacy", Some(BTreeMap::new()));
3449        lock.record_expects(
3450            "staging",
3451            "shop-legacy",
3452            expects("shop.payment", "authorise", "febd15c2757b72e1"),
3453        );
3454        let found = callee_contract_skews(
3455            "shop-payment",
3456            &provides("authorise", "da0d725f3a004040"),
3457            &lock,
3458            worker_of,
3459            "staging",
3460        );
3461        // This build has orders and payment; `shop-legacy` is gone.
3462        let bindings = graph(&[("shop-orders", &["shop-payment"]), ("shop-payment", &[])]);
3463        let (built, orphaned) = split_orphaned_callers(found, &bindings);
3464        assert!(built.is_empty(), "{built:?}");
3465        assert_eq!(orphaned.len(), 1);
3466        assert_eq!(orphaned[0].caller, "shop-legacy");
3467        let refusal = orphaned_caller_skew_refusal("staging", "shop-payment", &orphaned);
3468        assert!(refusal.contains(CONTRACT_SKEW), "{refusal}");
3469        assert!(
3470            refusal.contains("`shop-legacy-staging` (orphaned) calls shop.payment.authorise"),
3471            "{refusal}"
3472        );
3473        assert!(
3474            refusal.contains("`npx wrangler delete --name shop-legacy-staging`")
3475                && refusal.contains("`[environments.staging.workers.shop-legacy]`")
3476                && refusal.contains("`bynk deploy --env staging`"),
3477            "{refusal}"
3478        );
3479    }
3480
3481    #[test]
3482    fn a_callee_that_still_provides_the_live_hash_passes() {
3483        let lock = live_shop(Some("febd15c2757b72e1"));
3484        let found = callee_contract_skews(
3485            "shop-payment",
3486            &provides("authorise", "febd15c2757b72e1"),
3487            &lock,
3488            worker_of,
3489            "default",
3490        );
3491        assert!(found.is_empty(), "{found:?}");
3492    }
3493
3494    #[test]
3495    fn a_service_a_live_caller_still_calls_but_the_callee_removed_is_refused() {
3496        let lock = live_shop(Some("febd15c2757b72e1"));
3497        let found = callee_contract_skews(
3498            "shop-payment",
3499            &BTreeMap::new(),
3500            &lock,
3501            worker_of,
3502            "default",
3503        );
3504        assert_eq!(found.len(), 1);
3505        assert_eq!(found[0].provided, "<absent>");
3506    }
3507
3508    #[test]
3509    fn a_pushed_worker_is_never_held_to_its_own_stale_record() {
3510        // The gate is a `--context` gate, as D4 is: `run` reaches it only for
3511        // a single selected context, so a whole-project run (which redeploys
3512        // the callers beside the callee) is never refused by it — the
3513        // binary-driven `deploy_env` test pins that. Here: a pushed caller's
3514        // own recorded `expects` is being replaced, so it is no finding even
3515        // when the pushed Worker's contract changed.
3516        let mut lock = live_shop(Some("febd15c2757b72e1"));
3517        lock.record_expects(
3518            "default",
3519            "shop-orders",
3520            expects("shop.orders", "place", "0000000000000000"),
3521        );
3522        let found =
3523            callee_contract_skews("shop-orders", &BTreeMap::new(), &lock, worker_of, "default");
3524        assert!(found.is_empty(), "{found:?}");
3525    }
3526
3527    #[test]
3528    fn a_caller_recorded_before_expects_is_not_refused_but_is_named_for_a_warning() {
3529        // An old lock: orders is deployed, but the ledger never recorded what
3530        // it expects. Silence is not a match, so no refusal; orders binds to
3531        // payment in this build, so it is named for the warning.
3532        let lock = live_shop(None);
3533        let found = callee_contract_skews(
3534            "shop-payment",
3535            &provides("authorise", "da0d725f3a004040"),
3536            &lock,
3537            worker_of,
3538            "default",
3539        );
3540        assert!(found.is_empty(), "{found:?}");
3541        let bindings = graph(&[("shop-orders", &["shop-payment"]), ("shop-payment", &[])]);
3542        assert_eq!(
3543            unrecorded_callers("shop-payment", &bindings, &lock, "default"),
3544            names(&["shop-orders"])
3545        );
3546        // A Worker that does not bind to payment is no caller of it.
3547        let unrelated = graph(&[("shop-orders", &[]), ("shop-payment", &[])]);
3548        assert!(unrecorded_callers("shop-payment", &unrelated, &lock, "default").is_empty());
3549        // And once orders' expectations are recorded, there is nothing to warn of.
3550        let recorded = live_shop(Some("febd15c2757b72e1"));
3551        assert!(unrecorded_callers("shop-payment", &bindings, &recorded, "default").is_empty());
3552    }
3553
3554    #[test]
3555    fn a_caller_that_expects_nothing_of_the_callee_is_not_a_finding() {
3556        let mut lock = live_shop(None);
3557        lock.record_expects("default", "shop-orders", BTreeMap::new());
3558        let found = callee_contract_skews(
3559            "shop-payment",
3560            &BTreeMap::new(),
3561            &lock,
3562            worker_of,
3563            "default",
3564        );
3565        assert!(found.is_empty(), "{found:?}");
3566    }
3567
3568    #[test]
3569    fn a_caller_live_in_another_environment_is_not_this_environments_caller() {
3570        let lock = live_shop(Some("febd15c2757b72e1"));
3571        let found = callee_contract_skews(
3572            "shop-payment",
3573            &provides("authorise", "da0d725f3a004040"),
3574            &lock,
3575            worker_of,
3576            "staging",
3577        );
3578        assert!(found.is_empty(), "{found:?}");
3579    }
3580}