Skip to main content

bynk/deploy/
ledger.rs

1use super::*;
2
3#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
4pub(crate) struct DeployLock {
5    // No serde `default`: a ledger with no `version` is not a fresh project, it
6    // is corruption (a truncated write), and must fail the read rather than
7    // parse as an empty v1 ledger that re-mints every namespace (#736).
8    version: u32,
9    #[serde(default)]
10    pub(crate) environments: BTreeMap<String, Environment>,
11}
12
13/// #2077: what a Worker was compiled against — dependency (qualified context
14/// name) → service → contract hash ([`WorkerRecord::expects`]).
15pub(crate) type Expects = BTreeMap<String, BTreeMap<String, String>>;
16
17fn lock_version() -> u32 {
18    1
19}
20
21#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
22pub(crate) struct Environment {
23    /// Each Worker's bare `KV` namespace, the cloudflare adapter's `Kv`.
24    #[serde(default)]
25    pub(crate) kv: BTreeMap<String, KvNamespace>,
26    /// S4b (#1967): each Worker's derived KV instances (`capability Sessions =
27    /// Kv`), keyed by the instance's binding (`KV_SESSIONS`): the capability's
28    /// name as the generated config carries it, which `deploy` reads rather
29    /// than the source (ADR 0193 D3). A renamed capability is a new binding,
30    /// so a new key: the plan shows a create and an orphan.
31    ///
32    /// Additive, `default`ed and omitted when empty, so an older ledger still
33    /// reads and a project with only the bare `Kv` writes the same file.
34    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
35    pub(crate) kv_instances: BTreeMap<String, BTreeMap<String, KvNamespace>>,
36    /// P5 (#2323): each Worker's `R2` instances (`capability Archives = R2`),
37    /// keyed by the instance's binding (`R2_ARCHIVES`), as `kv_instances` is.
38    /// A bucket has no minted id (its name is its identity), so the record is
39    /// the name and jurisdiction it was created under: what a changed
40    /// `@jurisdiction` is compared against, and what an orphan names.
41    ///
42    /// Additive, `default`ed and omitted when empty, so an older ledger still
43    /// reads and a project without R2 writes the same file.
44    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
45    pub(crate) r2_instances: BTreeMap<String, BTreeMap<String, R2Bucket>>,
46    /// P3 (#2342): each Worker's `RateLimit` instances, keyed by binding
47    /// (`RATELIMIT_LOGIN_LIMIT`), as `kv_instances` is. The `namespace_id` is
48    /// minted here, not by Cloudflare: a positive integer unique across every
49    /// environment of the ledger, since environments may share an account
50    /// and the id is unique per account.
51    ///
52    /// Additive, `default`ed and omitted when empty, so an older ledger still
53    /// reads and a project without rate limiting writes the same file.
54    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
55    pub(crate) ratelimit_instances: BTreeMap<String, BTreeMap<String, RateLimitNamespace>>,
56    /// Slice 2: which Workers this project has ever pushed. Additive and
57    /// `default`ed, so a slice-0 ledger still reads.
58    ///
59    /// KV state alone could not answer "does this Worker exist on the account?"
60    /// — a context with no KV has no `kv` entry at all — and `--context` must
61    /// know, because a Service Binding to an absent Worker fails at upload.
62    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
63    workers: BTreeMap<String, WorkerRecord>,
64    /// Slice 1: the queue names this project has created at least once.
65    ///
66    /// Environment-wide rather than per-worker, because a queue is an account
67    /// resource addressed by name, not something a Worker owns — two contexts
68    /// consuming `"jobs"` mean the same queue.
69    ///
70    /// **Authoritative for nothing** (ADR 0194 D2). It exists so the plan can
71    /// say `create` or `reuse` without a `wrangler queues list` call; the
72    /// provision step attempts the create regardless, so a queue deleted
73    /// out-of-band comes back rather than being skipped on this set's word.
74    /// Additive and `default`ed, so a slice-0 or slice-2 ledger still reads.
75    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
76    queues: BTreeSet<String>,
77    /// State-migrations S3 (#2248, #2253 review): per Worker, the agent classes
78    /// whose `deleted` tombstone a successful push carried in this
79    /// environment. A `removed agent` declaration stays in source (and its
80    /// tombstone in the config) until Cloudflare reports it stale, so later
81    /// plans show the deletion as already applied and do not ask to confirm
82    /// it again. Replaced on every push of the Worker, so a declaration that is
83    /// later removed leaves nothing behind. A record of what was confirmed and
84    /// pushed — never a claim about the namespace, which Cloudflare owns (ADR
85    /// 0194 D1). Additive and `default`ed, so an older ledger still reads.
86    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
87    deleted_classes: BTreeMap<String, BTreeSet<String>>,
88    /// #2276 Decision D: per Worker, the agent class renames (old name → new
89    /// name) whose `renamed` tombstone a successful push carried in this
90    /// environment, so later plans show them as applied. Replaced on every
91    /// push of the Worker, like [`Environment::deleted_classes`], and for the
92    /// same reason a record, never a claim about the namespace. Additive and
93    /// `default`ed, so an older ledger still reads.
94    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
95    renamed_classes: BTreeMap<String, BTreeMap<String, String>>,
96}
97
98#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
99pub(crate) struct KvNamespace {
100    pub(crate) id: String,
101}
102
103/// P3 (#2342): the `namespace_id` this project allocated for one rate limiter.
104#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
105pub(crate) struct RateLimitNamespace {
106    pub(crate) namespace_id: String,
107}
108
109/// P3 (#2342): the lowest `namespace_id` the ledger allocates. Low ids are the
110/// ones a hand-written Worker on the same account is likeliest to have chosen.
111pub(crate) const FIRST_RATELIMIT_NAMESPACE: u64 = 1000;
112
113/// P3 (#2342): how many ids above [`FIRST_RATELIMIT_NAMESPACE`] a seed spreads
114/// over.
115pub(crate) const RATELIMIT_NAMESPACE_SPAN: u64 = 1_000_000_000;
116
117/// P3 (#2342, review): where the search for `worker`'s rate limiter
118/// `binding`'s id in `environment` starts: a hash of the three, into
119/// [`FIRST_RATELIMIT_NAMESPACE`]`..+`[`RATELIMIT_NAMESPACE_SPAN`]. Another
120/// Bynk project on the same account has a ledger of its own, which this one
121/// can't see; but its Workers have other names (a Worker name is unique on an
122/// account), so its limiters start elsewhere. A fixed starting point would
123/// give every project's first limiter the same id, and the same counters.
124pub(crate) fn ratelimit_seed(environment: &str, worker: &str, binding: &str) -> u64 {
125    use sha2::{Digest, Sha256};
126    let digest = Sha256::digest(format!("{environment}\0{worker}\0{binding}").as_bytes());
127    let n = u64::from_be_bytes(digest[..8].try_into().expect("8 bytes"));
128    FIRST_RATELIMIT_NAMESPACE + n % RATELIMIT_NAMESPACE_SPAN
129}
130
131/// P5 (#2323): one R2 bucket this project created, as the account names it.
132#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
133pub(crate) struct R2Bucket {
134    /// The bucket's account-facing name, environment-qualified.
135    pub(crate) name: String,
136    /// The jurisdiction it was created in, if any.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub(crate) jurisdiction: Option<String>,
139    /// Buckets this binding used to have, left in place when a changed
140    /// `@jurisdiction` made a new one. They hold data, so they stay recorded
141    /// (and are reported as orphans) until their entry is removed by hand.
142    #[serde(default, skip_serializing_if = "Vec::is_empty")]
143    pub(crate) superseded: Vec<R2Past>,
144}
145
146/// A bucket a binding no longer uses (see [`R2Bucket::superseded`]).
147#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
148pub(crate) struct R2Past {
149    pub(crate) name: String,
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub(crate) jurisdiction: Option<String>,
152}
153
154/// What the ledger remembers about one pushed Worker. A struct rather than a
155/// bare bool so slice 3's secrets have somewhere to land.
156#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
157struct WorkerRecord {
158    deployed: bool,
159    /// v0.177 (#643): the contract hash this Worker *provides* per `on call`
160    /// service, as of the push that recorded it — what is **live**.
161    ///
162    /// This is what makes a skew visible before a request finds it: a later
163    /// `deploy --context A` compares A's compiled `expects` against these, and
164    /// refuses rather than shipping a caller that will 409 in production.
165    ///
166    /// `None` means **no record** — a Worker pushed by a pre-v0.177 driver, which
167    /// has nothing to say about contracts either way. `Some({})` means the
168    /// Worker is *known* to provide no `on call` service at all.
169    ///
170    /// The distinction is load-bearing, and an empty map cannot carry it: a
171    /// callee that removes **all** its services emits no manifest, so a
172    /// bare-`BTreeMap` field would record `{}` — indistinguishable from "old
173    /// ledger" — and the gate's `continue` would let a total, real skew through.
174    /// `Option` keeps "silence is not a match" while still catching removal.
175    ///
176    /// Additive and `default`ed, so a pre-v0.177 ledger still reads.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    contracts: Option<BTreeMap<String, String>>,
179    /// #2077: the contract hash this Worker was compiled against for each
180    /// dependency service it calls, as of the push that recorded it —
181    /// dependency (qualified context name) → service → hash, the build's
182    /// `bynk-contracts.json` `expects` record. What its live call sites stamp.
183    ///
184    /// The other half of [`WorkerRecord::contracts`]: a later `deploy
185    /// --context B` compares B's newly built `provides` against every other
186    /// live Worker's `expects`, and refuses rather than shipping a callee its
187    /// live callers would 409 against.
188    ///
189    /// `None` means **no record** — a Worker pushed by a driver that predates
190    /// this field, which cannot be checked (the gate warns, never refuses, on
191    /// it: silence is not a match). `Some({})` means the Worker is *known* to
192    /// call no `on call` service at all. Additive and `default`ed, so an older
193    /// ledger still reads.
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    expects: Option<Expects>,
196    /// #1883, slice 1b: the static files this Worker serves, as of the push
197    /// that recorded it — each one's served path (`/_assets/board.<hash>.css`).
198    /// The content hash is in the path, so a changed, added or removed file
199    /// is a changed line in the committed lock's diff. Empty (and omitted)
200    /// for a Worker with none; additive and `default`ed, so an older lock
201    /// still reads.
202    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
203    assets: BTreeSet<String>,
204    /// I4 (#1970): the contract each declared foreign caller may call this
205    /// Worker at, as of the push that recorded it — actor → service → hash, the
206    /// build's `bynk-contracts.json` `foreign` record. The foreign callers are
207    /// not Bynk and record nothing themselves, so this is where the ledger
208    /// learns of them, beside what the Worker `provides`. Empty (and omitted)
209    /// for a Worker with none; additive and `default`ed.
210    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
211    foreign: BTreeMap<String, BTreeMap<String, String>>,
212}
213
214impl DeployLock {
215    pub(crate) fn is_deployed(&self, environment: &str, worker: &str) -> bool {
216        self.environments
217            .get(environment)
218            .and_then(|env| env.workers.get(worker))
219            .is_some_and(|record| record.deployed)
220    }
221
222    /// State-migrations S4 (#2259): every environment this ledger records
223    /// `worker` as pushed in, sorted. A floor-raising `bynk migrate sweep`
224    /// must cover all of them.
225    pub(crate) fn deployed_environments(&self, worker: &str) -> Vec<String> {
226        self.environments
227            .iter()
228            .filter(|(env, _)| self.is_deployed(env, worker))
229            .map(|(env, _)| env.clone())
230            .collect()
231    }
232
233    /// State-migrations S3 (#2248): whether a push in `environment` already
234    /// carried `class`'s `deleted` tombstone for `worker`.
235    pub(crate) fn has_deleted_class(&self, environment: &str, worker: &str, class: &str) -> bool {
236        self.environments
237            .get(environment)
238            .and_then(|env| env.deleted_classes.get(worker))
239            .is_some_and(|classes| classes.contains(class))
240    }
241
242    /// State-migrations S3 (#2248): record the `deleted` tombstones a
243    /// successful push of `worker` carried, replacing what was recorded for it.
244    pub(crate) fn record_deleted_classes(
245        &mut self,
246        environment: &str,
247        worker: &str,
248        classes: BTreeSet<String>,
249    ) {
250        let env = self
251            .environments
252            .entry(environment.to_string())
253            .or_default();
254        if classes.is_empty() {
255            env.deleted_classes.remove(worker);
256        } else {
257            env.deleted_classes.insert(worker.to_string(), classes);
258        }
259    }
260
261    /// #2276 Decision D: whether a push in `environment` already carried the
262    /// rename `from → to` for `worker`.
263    pub(crate) fn has_renamed_class(
264        &self,
265        environment: &str,
266        worker: &str,
267        from: &str,
268        to: &str,
269    ) -> bool {
270        self.environments
271            .get(environment)
272            .and_then(|env| env.renamed_classes.get(worker))
273            .and_then(|renames| renames.get(from))
274            .is_some_and(|recorded| recorded == to)
275    }
276
277    /// #2276 Decision D: record the `renamed` tombstones a successful push of
278    /// `worker` carried, replacing what was recorded for it.
279    pub(crate) fn record_renamed_classes(
280        &mut self,
281        environment: &str,
282        worker: &str,
283        renames: BTreeMap<String, String>,
284    ) {
285        let env = self
286            .environments
287            .entry(environment.to_string())
288            .or_default();
289        if renames.is_empty() {
290            env.renamed_classes.remove(worker);
291        } else {
292            env.renamed_classes.insert(worker.to_string(), renames);
293        }
294    }
295
296    pub(crate) fn record_deployed(
297        &mut self,
298        environment: &str,
299        worker: &str,
300        contracts: Option<BTreeMap<String, String>>,
301    ) {
302        self.environments
303            .entry(environment.to_string())
304            .or_default()
305            .workers
306            .insert(
307                worker.to_string(),
308                WorkerRecord {
309                    deployed: true,
310                    contracts,
311                    expects: None,
312                    assets: BTreeSet::new(),
313                    foreign: BTreeMap::new(),
314                },
315            );
316    }
317
318    /// #1883, slice 1b: record the served paths of the static files `worker`
319    /// shipped in this push ([`WorkerRecord::assets`]). Called right after
320    /// [`DeployLock::record_deployed`]; a Worker not yet recorded is ignored.
321    pub(crate) fn record_assets(
322        &mut self,
323        environment: &str,
324        worker: &str,
325        assets: BTreeSet<String>,
326    ) {
327        if let Some(record) = self
328            .environments
329            .get_mut(environment)
330            .and_then(|env| env.workers.get_mut(worker))
331        {
332            record.assets = assets;
333        }
334    }
335
336    /// I4 (#1970): record the contract `worker`'s declared foreign callers may
337    /// call it at, as of this push ([`WorkerRecord::foreign`]). Called right
338    /// after [`DeployLock::record_deployed`]; a Worker not yet recorded is
339    /// ignored.
340    pub(crate) fn record_foreign(
341        &mut self,
342        environment: &str,
343        worker: &str,
344        foreign: BTreeMap<String, BTreeMap<String, String>>,
345    ) {
346        if let Some(record) = self
347            .environments
348            .get_mut(environment)
349            .and_then(|env| env.workers.get_mut(worker))
350        {
351            record.foreign = foreign;
352        }
353    }
354
355    /// #2077: record the contracts `worker` was compiled against as of this
356    /// push ([`WorkerRecord::expects`]). Called right after
357    /// [`DeployLock::record_deployed`]; a Worker not yet recorded is ignored.
358    /// `Some` even when empty: this build *knows* what the Worker calls.
359    pub(crate) fn record_expects(&mut self, environment: &str, worker: &str, expects: Expects) {
360        if let Some(record) = self
361            .environments
362            .get_mut(environment)
363            .and_then(|env| env.workers.get_mut(worker))
364        {
365            record.expects = Some(expects);
366        }
367    }
368
369    /// #2077: every Worker the ledger records as deployed in `environment`,
370    /// with what it was compiled against — `None` when its record predates
371    /// [`WorkerRecord::expects`].
372    pub(crate) fn live_expects(
373        &self,
374        environment: &str,
375    ) -> impl Iterator<Item = (&str, Option<&Expects>)> {
376        self.environments
377            .get(environment)
378            .into_iter()
379            .flat_map(|env| env.workers.iter())
380            .filter(|(_, record)| record.deployed)
381            .map(|(worker, record)| (worker.as_str(), record.expects.as_ref()))
382    }
383
384    /// I4 (#1970): what the ledger recorded `worker`'s foreign callers may call
385    /// it at, empty when it recorded none.
386    #[cfg(test)]
387    pub(crate) fn live_foreign(
388        &self,
389        environment: &str,
390        worker: &str,
391    ) -> Option<&BTreeMap<String, BTreeMap<String, String>>> {
392        self.environments
393            .get(environment)
394            .and_then(|env| env.workers.get(worker))
395            .map(|record| &record.foreign)
396    }
397
398    /// v0.177 (#643): what the ledger believes `worker` currently provides.
399    ///
400    /// `None` for both "never deployed" and "deployed before contracts were
401    /// recorded" — in each case the ledger cannot speak, and the gate must not
402    /// invent an answer.
403    pub(crate) fn live_contracts(
404        &self,
405        environment: &str,
406        worker: &str,
407    ) -> Option<&BTreeMap<String, String>> {
408        self.environments
409            .get(environment)
410            .and_then(|env| env.workers.get(worker))
411            .and_then(|record| record.contracts.as_ref())
412    }
413
414    /// Record the namespace `id` this project created for `worker`'s KV
415    /// `binding`: the bare `KV` in [`Environment::kv`], an instance in
416    /// [`Environment::kv_instances`] (S4b, #1967).
417    pub(crate) fn record_kv(&mut self, environment: &str, worker: &str, binding: &str, id: &str) {
418        let env = self
419            .environments
420            .entry(environment.to_string())
421            .or_default();
422        let namespace = KvNamespace { id: id.to_string() };
423        if is_bare_kv(binding) {
424            env.kv.insert(worker.to_string(), namespace);
425        } else {
426            env.kv_instances
427                .entry(worker.to_string())
428                .or_default()
429                .insert(binding.to_string(), namespace);
430        }
431    }
432
433    /// P5 (#2323): record the bucket this project created for `worker`'s `R2`
434    /// `binding`.
435    pub(crate) fn record_r2(
436        &mut self,
437        environment: &str,
438        worker: &str,
439        binding: &str,
440        name: &str,
441        jurisdiction: Option<&str>,
442    ) {
443        let instances = self
444            .environments
445            .entry(environment.to_string())
446            .or_default()
447            .r2_instances
448            .entry(worker.to_string())
449            .or_default();
450        // A bucket this replaces is not forgotten: its objects are still there.
451        let mut superseded = Vec::new();
452        if let Some(old) = instances.remove(binding) {
453            superseded = old.superseded;
454            if old.name != name || old.jurisdiction.as_deref() != jurisdiction {
455                superseded.push(R2Past {
456                    name: old.name,
457                    jurisdiction: old.jurisdiction,
458                });
459            }
460        }
461        instances.insert(
462            binding.to_string(),
463            R2Bucket {
464                name: name.to_string(),
465                jurisdiction: jurisdiction.map(str::to_string),
466                superseded,
467            },
468        );
469    }
470
471    /// P3 (#2342): the `namespace_id` for `worker`'s rate limiter `binding`
472    /// in `environment`: the recorded one, else a new one — the first id from
473    /// its [`ratelimit_seed`] upward (wrapping within the span) that no rate
474    /// limiter in any environment of this ledger holds — recorded now. `true`
475    /// when it was allocated.
476    pub(crate) fn allocate_ratelimit(
477        &mut self,
478        environment: &str,
479        worker: &str,
480        binding: &str,
481    ) -> (String, bool) {
482        if let Some(id) = recorded_ratelimit(self, worker, binding, environment) {
483            return (id.to_string(), false);
484        }
485        let taken: BTreeSet<&str> = self
486            .environments
487            .values()
488            .flat_map(|env| env.ratelimit_instances.values())
489            .flat_map(|instances| instances.values())
490            .map(|r| r.namespace_id.as_str())
491            .collect();
492        let seed = ratelimit_seed(environment, worker, binding) - FIRST_RATELIMIT_NAMESPACE;
493        let id = (0..RATELIMIT_NAMESPACE_SPAN)
494            .map(|k| {
495                (FIRST_RATELIMIT_NAMESPACE + (seed + k) % RATELIMIT_NAMESPACE_SPAN).to_string()
496            })
497            .find(|n| !taken.contains(n.as_str()))
498            .expect("an unused id");
499        self.environments
500            .entry(environment.to_string())
501            .or_default()
502            .ratelimit_instances
503            .entry(worker.to_string())
504            .or_default()
505            .insert(
506                binding.to_string(),
507                RateLimitNamespace {
508                    namespace_id: id.clone(),
509                },
510            );
511        (id, true)
512    }
513
514    /// P3 (#2342): forget `worker`'s rate limiters that `declared` no longer
515    /// names. A limiter holds no data and has no account resource, so a
516    /// removed instance needs nothing deleted, only its row dropped (its id
517    /// is then free again). `true` when anything was forgotten.
518    pub(crate) fn forget_undeclared_ratelimits(
519        &mut self,
520        environment: &str,
521        worker: &str,
522        declared: &[String],
523    ) -> bool {
524        let Some(env) = self.environments.get_mut(environment) else {
525            return false;
526        };
527        let Some(instances) = env.ratelimit_instances.get_mut(worker) else {
528            return false;
529        };
530        let before = instances.len();
531        instances.retain(|binding, _| declared.contains(binding));
532        let changed = instances.len() != before;
533        if instances.is_empty() {
534            env.ratelimit_instances.remove(worker);
535        }
536        changed
537    }
538
539    /// Forget `worker`'s KV `binding`, dropping a Worker's instance map once
540    /// it is empty, so a pruned ledger is the file it would have been had the
541    /// instance never existed.
542    fn forget_kv(&mut self, environment: &str, worker: &str, binding: &str) {
543        let Some(env) = self.environments.get_mut(environment) else {
544            return;
545        };
546        if is_bare_kv(binding) {
547            env.kv.remove(worker);
548        } else if let Some(instances) = env.kv_instances.get_mut(worker) {
549            instances.remove(binding);
550            if instances.is_empty() {
551                env.kv_instances.remove(worker);
552            }
553        }
554    }
555
556    pub(crate) fn has_queue(&self, environment: &str, queue: &str) -> bool {
557        self.environments
558            .get(environment)
559            .is_some_and(|env| env.queues.contains(queue))
560    }
561
562    /// Note that this project has created `queue`. Returns whether the ledger
563    /// changed, so a re-run that provisions nothing also writes nothing.
564    pub(crate) fn record_queue(&mut self, environment: &str, queue: &str) -> bool {
565        self.environments
566            .entry(environment.to_string())
567            .or_default()
568            .queues
569            .insert(queue.to_string())
570    }
571}
572
573/// Slice 5: every ledger entry for this environment that the current build no
574/// longer declares. Owned, not borrowed — ledger-derived names don't share
575/// `Plan<'a>`'s lifetime over `order`/`resources` (the `PlanSecret.name:
576/// String` precedent below).
577///
578/// `kv` and `workers` are independent checks, deliberately not merged: both
579/// are keyed by worker name in the ledger (`Environment`, above), so a
580/// context removed from source that had KV is reported as **two** orphans,
581/// one per map, not one combined line. `--prune` (when it lands) treats them
582/// independently too — the `kv` line is prunable, the `workers` line is
583/// report-only (a whole-Worker delete is a materially larger blast radius
584/// than a namespace or a queue, and out of this slice's scope).
585#[derive(Debug, Default, PartialEq, Eq, Serialize)]
586pub(crate) struct Orphans {
587    pub(crate) kv: Vec<OrphanKv>,
588    /// P5 (#2323): recorded R2 buckets the build no longer declares. Reported
589    /// only: a bucket holds data, so `--prune` never deletes one (and R2
590    /// refuses to delete a non-empty one anyway).
591    #[serde(skip_serializing_if = "Vec::is_empty")]
592    pub(crate) r2: Vec<OrphanR2>,
593    pub(crate) workers: Vec<String>,
594    pub(crate) queues: Vec<String>,
595}
596
597/// One recorded KV namespace the build no longer declares (S4b, #1967): the
598/// bare one of a removed Worker (`binding` absent), or a derived instance,
599/// whose Worker may still be live — a capability removed or renamed.
600#[derive(Debug, PartialEq, Eq, Serialize)]
601pub(crate) struct OrphanKv {
602    pub(crate) worker: String,
603    #[serde(skip_serializing_if = "Option::is_none")]
604    pub(crate) binding: Option<String>,
605}
606
607/// One recorded R2 bucket the build no longer declares (P5, #2323): a
608/// capability removed or renamed, or a whole Worker gone.
609#[derive(Debug, PartialEq, Eq, Serialize)]
610pub(crate) struct OrphanR2 {
611    pub(crate) worker: String,
612    pub(crate) binding: String,
613    /// The bucket's name on the account, which stays there.
614    pub(crate) bucket: String,
615    #[serde(skip_serializing_if = "Option::is_none")]
616    pub(crate) jurisdiction: Option<String>,
617}
618
619impl OrphanKv {
620    /// The binding whose ledger entry this is: `KV` for the bare namespace.
621    pub(crate) fn binding(&self) -> &str {
622        self.binding
623            .as_deref()
624            .unwrap_or(bynk_emit::emitter::wrangler::KV_BINDING_NAME)
625    }
626}
627
628impl Orphans {
629    /// Whether `--prune` would actually delete anything. **Not** the same as
630    /// checking whether every field is empty (review, #840): `workers`
631    /// orphans are report-only (DECISION C — never `wrangler delete`), so a
632    /// project whose only orphan is an unprunable Worker must not trigger
633    /// `confirm_prune`'s prompt at all — `Delete 0 resource(s)?` is a bug,
634    /// not a valid state.
635    pub(crate) fn has_prunable(&self) -> bool {
636        !self.kv.is_empty() || !self.queues.is_empty()
637    }
638
639    /// These orphans as the account names them under `environment`, for the
640    /// plan to show (#2028): a queue by the [`env_qualify`]d name `--prune`
641    /// deletes, a Worker by its [`deployed_worker_name`]. A KV orphan keeps
642    /// its Worker's ledger key: its line names the ledger entry, not the
643    /// namespace's title ([`kv_title`]), which a namespace made before #2037
644    /// may not match.
645    pub(crate) fn as_named_in(&self, environment: &str) -> Orphans {
646        Orphans {
647            kv: self
648                .kv
649                .iter()
650                .map(|kv| OrphanKv {
651                    worker: kv.worker.clone(),
652                    binding: kv.binding.clone(),
653                })
654                .collect(),
655            r2: self
656                .r2
657                .iter()
658                .map(|r2| OrphanR2 {
659                    worker: r2.worker.clone(),
660                    binding: r2.binding.clone(),
661                    bucket: r2.bucket.clone(),
662                    jurisdiction: r2.jurisdiction.clone(),
663                })
664                .collect(),
665            workers: self
666                .workers
667                .iter()
668                .map(|worker| deployed_worker_name(environment, worker))
669                .collect(),
670            queues: self
671                .queues
672                .iter()
673                .map(|queue| env_qualify(environment, queue))
674                .collect(),
675        }
676    }
677}
678
679/// The orphan diff: ledger vs. the current build's full declared resource
680/// set, regardless of `--context` — `resources` already spans the *whole*
681/// project (`project_resources`, called from `run()` over `available` — every
682/// worker `workers::discover_workers` found — not the `--context`-narrowed
683/// `order`/`selected`), so `resources.keys()` alone is the full live-worker
684/// set and this reuses data `run()` already has rather than reading anything
685/// new. No live Cloudflare call: the report half of reconciliation costs
686/// nothing and needs no auth, keeping `--dry-run`'s "never authenticates"
687/// promise intact.
688///
689/// Pure, so the diff — including the shared-queue case (a queue two contexts
690/// still consume must never appear orphaned because a *third* context that
691/// used to consume it was removed) — is tested without a build tree.
692pub(crate) fn find_orphans(
693    lock: &DeployLock,
694    environment: &str,
695    resources: &BTreeMap<String, Resources>,
696) -> Orphans {
697    let Some(env) = lock.environments.get(environment) else {
698        return Orphans::default();
699    };
700    let live_queues: BTreeSet<&str> = resources
701        .values()
702        .flat_map(|r| r.queues.iter().map(String::as_str))
703        .collect();
704    // S4b (#1967): an instance is orphaned with its Worker, or on its own
705    // when its Worker no longer declares it.
706    let instance_orphans = env.kv_instances.iter().flat_map(|(worker, instances)| {
707        instances
708            .keys()
709            .filter(move |binding| {
710                resources
711                    .get(worker)
712                    .is_none_or(|r| !r.kv_instances.contains(binding))
713            })
714            .map(move |binding| OrphanKv {
715                worker: worker.clone(),
716                binding: Some(binding.clone()),
717            })
718    });
719    // P5 (#2323): a bucket likewise, with its Worker or on its own.
720    let r2_orphans = env
721        .r2_instances
722        .iter()
723        .flat_map(|(worker, instances)| {
724            instances
725                .iter()
726                .filter(move |(binding, _)| {
727                    resources
728                        .get(worker)
729                        .is_none_or(|r| !r.r2_buckets.iter().any(|b| &b.binding == *binding))
730                })
731                .map(move |(binding, bucket)| OrphanR2 {
732                    worker: worker.clone(),
733                    binding: binding.clone(),
734                    bucket: bucket.name.clone(),
735                    jurisdiction: bucket.jurisdiction.clone(),
736                })
737        })
738        .chain(env.r2_instances.iter().flat_map(|(worker, instances)| {
739            // A bucket a changed jurisdiction left behind is an orphan while
740            // its binding is live, too.
741            instances.iter().flat_map(move |(binding, bucket)| {
742                bucket.superseded.iter().map(move |past| OrphanR2 {
743                    worker: worker.clone(),
744                    binding: binding.clone(),
745                    bucket: past.name.clone(),
746                    jurisdiction: past.jurisdiction.clone(),
747                })
748            })
749        }))
750        .collect();
751    Orphans {
752        r2: r2_orphans,
753        // The bare namespace likewise: with its Worker, or on its own once
754        // the live Worker stops using the bare `Kv` (review of #1974).
755        kv: env
756            .kv
757            .keys()
758            .filter(|worker| resources.get(worker.as_str()).is_none_or(|r| !r.needs_kv))
759            .map(|worker| OrphanKv {
760                worker: worker.clone(),
761                binding: None,
762            })
763            .chain(instance_orphans)
764            .collect(),
765        workers: env
766            .workers
767            .keys()
768            .filter(|worker| !resources.contains_key(worker.as_str()))
769            .cloned()
770            .collect(),
771        queues: env
772            .queues
773            .iter()
774            .filter(|queue| !live_queues.contains(queue.as_str()))
775            .cloned()
776            .collect(),
777    }
778}
779
780/// The bucket recorded for `worker`'s `R2` `binding` (P5, #2323).
781pub(crate) fn recorded_r2<'a>(
782    lock: &'a DeployLock,
783    worker: &str,
784    binding: &str,
785    environment: &str,
786) -> Option<&'a R2Bucket> {
787    lock.environments
788        .get(environment)
789        .and_then(|env| env.r2_instances.get(worker))
790        .and_then(|instances| instances.get(binding))
791}
792
793/// The `namespace_id` recorded for `worker`'s rate limiter `binding` (P3,
794/// #2342).
795pub(crate) fn recorded_ratelimit<'a>(
796    lock: &'a DeployLock,
797    worker: &str,
798    binding: &str,
799    environment: &str,
800) -> Option<&'a str> {
801    lock.environments
802        .get(environment)
803        .and_then(|env| env.ratelimit_instances.get(worker))
804        .and_then(|instances| instances.get(binding))
805        .map(|r| r.namespace_id.as_str())
806}
807
808/// Whether `binding` is the bare `Kv`'s, as opposed to a derived instance's.
809pub(crate) fn is_bare_kv(binding: &str) -> bool {
810    binding == bynk_emit::emitter::wrangler::KV_BINDING_NAME
811}
812
813/// The namespace id recorded for `worker`'s KV `binding` (S4b, #1967): the
814/// bare `KV`, or a derived instance.
815pub(crate) fn recorded_kv_binding<'a>(
816    lock: &'a DeployLock,
817    worker: &str,
818    binding: &str,
819    environment: &str,
820) -> Option<&'a str> {
821    if is_bare_kv(binding) {
822        return recorded_kv(lock, worker, environment);
823    }
824    lock.environments
825        .get(environment)
826        .and_then(|env| env.kv_instances.get(worker))
827        .and_then(|instances| instances.get(binding))
828        .map(|kv| kv.id.as_str())
829}
830
831/// Every KV binding `declared` names, the bare `KV` first, then its instances
832/// in binding order (S4b, #1967): the order a deploy provisions them and the
833/// plan lists them.
834pub(crate) fn kv_bindings(declared: &Resources) -> impl Iterator<Item = &str> {
835    declared
836        .needs_kv
837        .then_some(bynk_emit::emitter::wrangler::KV_BINDING_NAME)
838        .into_iter()
839        .chain(declared.kv_instances.iter().map(String::as_str))
840}
841
842pub(crate) fn recorded_kv<'a>(
843    lock: &'a DeployLock,
844    worker: &str,
845    environment: &str,
846) -> Option<&'a str> {
847    lock.environments
848        .get(environment)
849        .and_then(|env| env.kv.get(worker))
850        .map(|kv| kv.id.as_str())
851}
852
853/// Slice 5, DECISION B, extracted for direct testing (review, #840): should
854/// `deploy_one` trust a recorded KV id, or treat it as though nothing were
855/// recorded at all?
856///
857/// - No record at all → never trust (nothing to trust).
858/// - A record, and the live fetch never ran or failed (`None`) → trust it,
859///   unconditionally — exactly pre-slice-5 behaviour, so a fetch outage
860///   never blocks a deploy that would have succeeded before this slice.
861/// - A record, and a live id set — trust it only if the id is actually in
862///   that set. Absent means Cloudflare no longer recognises it: re-provision,
863///   the same as an unrecorded id would.
864pub(crate) fn should_trust_recorded_kv(
865    recorded: Option<&str>,
866    live_kv_ids: Option<&BTreeSet<String>>,
867) -> bool {
868    match (recorded, live_kv_ids) {
869        (Some(id), Some(live)) => live.contains(id),
870        (Some(_), None) => true,
871        (None, _) => false,
872    }
873}
874
875pub(crate) fn read_lock(path: &Path) -> Result<DeployLock, String> {
876    if !path.exists() {
877        return Ok(DeployLock {
878            version: lock_version(),
879            ..Default::default()
880        });
881    }
882    let text = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
883    // A zero-byte or whitespace-only ledger is a truncated write, not an empty
884    // project. Accepting it as an empty v1 ledger would tell the planner that no
885    // namespaces exist and re-mint every one — the exact orphaning the ledger
886    // exists to prevent (#736, ADR 0180). Fail hard so the operator restores it.
887    if text.trim().is_empty() {
888        return Err(format!(
889            "deploy ledger `{}` is empty or truncated (corrupt); refusing to \
890             treat it as a fresh project — restore it from version control",
891            path.display()
892        ));
893    }
894    // A file that does not parse — including one truncated mid-table or missing
895    // its now-required `version` — is corruption too, and gets the same
896    // restore-it guidance rather than a bare toml diagnostic. A version we simply
897    // do not support is a distinct case (a newer or older format), not corruption.
898    let lock: DeployLock = toml::from_str(&text).map_err(|e| {
899        format!(
900            "deploy ledger `{}` is corrupt ({e}) — restore it from version control",
901            path.display()
902        )
903    })?;
904    if lock.version != lock_version() {
905        return Err(format!("unsupported deploy lock version {}", lock.version));
906    }
907    Ok(lock)
908}
909
910/// Distinguishes concurrent temp ledgers written by the same process.
911static TMP_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
912
913pub(crate) fn write_lock(path: &Path, lock: &DeployLock) -> Result<(), String> {
914    let body = toml::to_string_pretty(lock).map_err(|e| e.to_string())?;
915    // Atomic, durable replace: write a sibling temp file, fsync it, then rename
916    // it over the ledger. A power loss or kill can then only leave the intact old
917    // file or the intact new one — never a truncated ledger that reads as empty
918    // (#736). Atomicity-for-readers (the rename) is not enough on its own: after
919    // a crash the rename can be journaled while the temp's data blocks are still
920    // only in the page cache, so we `sync_all` the data before the rename and
921    // fsync the directory after it to make the new name itself durable.
922    let dir = path
923        .parent()
924        .filter(|p| !p.as_os_str().is_empty())
925        .unwrap_or_else(|| Path::new("."));
926    let file_name = path
927        .file_name()
928        .and_then(|n| n.to_str())
929        .unwrap_or(LOCK_FILE);
930
931    // A per-process counter keeps the temp name unique within a process, and
932    // `create_new` makes the create exclusive — a stale temp from a prior crash
933    // or a pre-planted symlink at this path is refused rather than followed.
934    let (tmp, mut file) = loop {
935        let n = TMP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
936        let candidate = dir.join(format!(".{file_name}.{}.{n}.tmp", std::process::id()));
937        match std::fs::OpenOptions::new()
938            .write(true)
939            .create_new(true)
940            .open(&candidate)
941        {
942            Ok(f) => break (candidate, f),
943            Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
944            Err(e) => return Err(e.to_string()),
945        }
946    };
947
948    // From here on, any failure must remove the temp so a full disk or crash
949    // does not litter the project with `.bynk.deploy.lock.*.tmp` files.
950    let write_then_sync = file
951        .write_all(body.as_bytes())
952        .and_then(|()| file.sync_all());
953    if let Err(e) = write_then_sync {
954        let _ = std::fs::remove_file(&tmp);
955        return Err(e.to_string());
956    }
957    drop(file);
958
959    // Preserve the ledger's existing permissions across the replace.
960    if let Ok(meta) = std::fs::metadata(path) {
961        let _ = std::fs::set_permissions(&tmp, meta.permissions());
962    }
963
964    if let Err(e) = std::fs::rename(&tmp, path) {
965        let _ = std::fs::remove_file(&tmp);
966        return Err(e.to_string());
967    }
968    // Best-effort: make the rename itself durable. Directory fsync is a no-op or
969    // unsupported on some platforms, so a failure here is not fatal.
970    if let Ok(dir_file) = std::fs::File::open(dir) {
971        let _ = dir_file.sync_all();
972    }
973    Ok(())
974}
975
976/// Fill a generated worker configuration from the committed deploy ledger.
977/// This is shared with `bynk dev -- --remote`; local dev leaves placeholders
978/// alone because Miniflare does not read the Cloudflare namespace id.
979///
980/// `environment` (slice 4, #837 review): before `--env` existed every real
981/// deploy recorded into `"default"` regardless, so hardcoding it here always
982/// matched. A project deployed only under a non-default `--env` now has
983/// nothing under `"default"` — reading the wrong section would misreport a
984/// provisioned project as never deployed, so this reads whichever section
985/// `bynk dev --env NAME -- --remote` names (default `"default"`, unchanged).
986pub fn materialise_deploy_state(
987    project_root: &Path,
988    worker: &str,
989    config: &Path,
990    environment: &str,
991) -> Result<bool, String> {
992    // P7.4 (#1305): structural, not a substring search — closes R7.6/R8.20.
993    let text = std::fs::read_to_string(config).map_err(|e| e.to_string())?;
994    // S4b (#1967): every namespace still at its placeholder, the bare `KV`
995    // and each derived instance alike.
996    let needed = bynk_emit::emitter::wrangler::kv_bindings_needing_ids(&text)?;
997    // P5 (#2323): each `R2` bucket must have been created by a deploy, and is
998    // then named as that environment names it.
999    let buckets = bynk_emit::emitter::wrangler::r2_bucket_entries(&text)?;
1000    // P3 (#2342): each rate limiter must have had its id allocated by a deploy.
1001    let limiters = bynk_emit::emitter::wrangler::ratelimits_needing_ids(&text)?;
1002    if needed.is_empty() && buckets.is_empty() && limiters.is_empty() {
1003        return Ok(false);
1004    }
1005    let lock = read_lock(&project_root.join(LOCK_FILE))?;
1006    let mut limiter_ids = BTreeMap::new();
1007    for binding in limiters {
1008        let Some(id) = recorded_ratelimit(&lock, worker, &binding, environment) else {
1009            return Err(format!(
1010                "remote rate limiter `{binding}` for `{worker}` has not been provisioned under environment `{environment}`; run `bynk deploy --env {environment}` first"
1011            ));
1012        };
1013        limiter_ids.insert(binding, id.to_string());
1014    }
1015    let mut names = BTreeMap::new();
1016    for (binding, name, jurisdiction) in &buckets {
1017        let Some(recorded) = recorded_r2(&lock, worker, binding, environment) else {
1018            return Err(format!(
1019                "remote R2 bucket `{binding}` for `{worker}` has not been provisioned under environment `{environment}`; run `bynk deploy --env {environment}` first"
1020            ));
1021        };
1022        if recorded.jurisdiction != *jurisdiction {
1023            return Err(format!(
1024                "remote R2 bucket `{binding}` for `{worker}` was created under another `@jurisdiction` than the source now names; run `bynk deploy --env {environment}` first"
1025            ));
1026        }
1027        names.insert(binding.clone(), r2_bucket_name(environment, name));
1028    }
1029    let mut changed = false;
1030    if !limiter_ids.is_empty() {
1031        if !materialise_ratelimit_ids(config, &limiter_ids) {
1032            return Err("could not write generated configuration".into());
1033        }
1034        changed = true;
1035    }
1036    let text = std::fs::read_to_string(config).map_err(|e| e.to_string())?;
1037    if !names.is_empty() {
1038        let patched = bynk_emit::emitter::wrangler::materialise_r2_bucket_names(&text, &names)?;
1039        if patched != text {
1040            std::fs::write(config, patched).map_err(|e| e.to_string())?;
1041            changed = true;
1042        }
1043    }
1044    if needed.is_empty() {
1045        return Ok(changed);
1046    }
1047    let mut ids = BTreeMap::new();
1048    for binding in needed {
1049        let Some(id) = recorded_kv_binding(&lock, worker, &binding, environment) else {
1050            let what = if is_bare_kv(&binding) {
1051                format!("remote KV for `{worker}`")
1052            } else {
1053                format!("remote KV namespace `{binding}` for `{worker}`")
1054            };
1055            return Err(format!(
1056                "{what} has not been provisioned under environment `{environment}`; run `bynk deploy --env {environment}` first"
1057            ));
1058        };
1059        ids.insert(binding, id.to_string());
1060    }
1061    if materialise_kv_ids(config, &ids) {
1062        Ok(true)
1063    } else {
1064        Err("could not write generated configuration".into())
1065    }
1066}
1067
1068// ---------------------------------------------------------------------------
1069// Slice 5: reconciliation — orphan pruning
1070// ---------------------------------------------------------------------------
1071
1072/// Print exactly what `--prune` is about to delete, then ask once for the
1073/// whole batch — the "strictly stronger gate" the track doc (§6) calls for on
1074/// top of [`confirm`]'s creation gate. `--yes` alone does **not** imply this:
1075/// a CI job that wants unattended pruning must pass `--yes` **and**
1076/// `--prune` together, the same non-interactive-requires-`--yes` shape
1077/// [`confirm`] already uses, just with its own prompt so a script that only
1078/// meant to authorise *creation* cannot accidentally also authorise deletion.
1079pub(crate) fn confirm_prune(yes: bool, orphans: &Orphans, environment: &str) -> bool {
1080    for kv in &orphans.kv {
1081        match &kv.binding {
1082            None => eprintln!("bynk: will delete KV namespace for `{}`", kv.worker),
1083            Some(binding) => eprintln!(
1084                "bynk: will delete KV namespace `{}` for `{}`'s `{binding}`",
1085                kv_title(&kv.worker, binding, environment),
1086                kv.worker
1087            ),
1088        }
1089    }
1090    for queue in &orphans.queues {
1091        // The name `prune_orphans` deletes, not the ledger key (#2028).
1092        eprintln!(
1093            "bynk: will delete queue `{}`",
1094            env_qualify(environment, queue)
1095        );
1096    }
1097    if yes {
1098        return true;
1099    }
1100    if !requires_interactive_confirmation(yes, io::stdin().is_terminal()) {
1101        eprintln!("bynk: refusing to prune in a non-interactive session without --yes");
1102        return false;
1103    }
1104    let count = orphans.kv.len() + orphans.queues.len();
1105    eprint!("Delete {count} resource(s)? [y/N] ");
1106    let _ = io::stderr().flush();
1107    let mut answer = String::new();
1108    io::stdin().read_line(&mut answer).is_ok()
1109        && matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes")
1110}
1111
1112/// Delete every KV and queue orphan `confirm_prune` just named — never a
1113/// worker (DECISION C: `wrangler delete`'s blast radius, routes/domains/crons
1114/// along with the script, is categorically larger than a namespace or a
1115/// queue, and pruning a whole Worker is explicitly out of this slice).
1116///
1117/// The ledger entry is stripped whether the delete found something to remove
1118/// or found it already gone (DECISION E) — both mean "this resource is not
1119/// there", and treating only a clean delete as ledger-worthy would wedge a
1120/// half-completed prune: crash between a successful Cloudflare delete and the
1121/// ledger write, and the next run would re-report the same orphan and
1122/// re-issue a delete Cloudflare now rejects as not-found.
1123pub(crate) fn prune_orphans(
1124    provenance: &Provenance,
1125    project_root: &Path,
1126    lock: &mut DeployLock,
1127    lock_path: &Path,
1128    environment: &str,
1129    orphans: &Orphans,
1130) -> Result<(), DeployFailure> {
1131    for orphan in &orphans.kv {
1132        let (worker, binding) = (orphan.worker.as_str(), orphan.binding());
1133        let Some(id) = recorded_kv_binding(lock, worker, binding, environment).map(str::to_owned)
1134        else {
1135            continue;
1136        };
1137        let what = match &orphan.binding {
1138            None => format!("the orphaned KV namespace for `{worker}`"),
1139            Some(binding) => format!("the orphaned KV namespace `{binding}` for `{worker}`"),
1140        };
1141        delete_kv_namespace(provenance, &id, project_root)
1142            .map_err(|e| DeployFailure::driver(format!("could not delete {what}: {e}")))?;
1143        lock.forget_kv(environment, worker, binding);
1144        write_lock(lock_path, lock).map_err(|e| {
1145            DeployFailure::driver(format!(
1146                "deleted {what} but could not record it in {}: {e}",
1147                lock_path.display()
1148            ))
1149        })?;
1150    }
1151    for queue in &orphans.queues {
1152        let physical = env_qualify(environment, queue);
1153        delete_queue(provenance, &physical, project_root).map_err(|e| {
1154            DeployFailure::driver(format!(
1155                "could not delete the orphaned queue `{physical}`: {e}"
1156            ))
1157        })?;
1158        if let Some(env) = lock.environments.get_mut(environment) {
1159            env.queues.remove(queue);
1160        }
1161        write_lock(lock_path, lock).map_err(|e| {
1162            DeployFailure::driver(format!(
1163                "deleted the orphaned queue `{physical}` but could not record it in {}: {e}",
1164                lock_path.display()
1165            ))
1166        })?;
1167    }
1168    Ok(())
1169}
1170
1171#[cfg(test)]
1172pub(crate) mod tests {
1173    use super::*;
1174    use crate::deploy::config::tests::project;
1175
1176    fn names(v: &[&str]) -> Vec<String> {
1177        v.iter().map(|s| s.to_string()).collect()
1178    }
1179
1180    pub(crate) fn lock_with_deployed(workers: &[&str]) -> DeployLock {
1181        let mut lock = DeployLock::default();
1182        for worker in workers {
1183            lock.record_deployed("default", worker, Some(Default::default()));
1184        }
1185        lock
1186    }
1187
1188    /// Record `worker`'s KV namespace, as a real deploy does before it pushes.
1189    pub(crate) fn with_kv(mut lock: DeployLock, worker: &str) -> DeployLock {
1190        lock.environments
1191            .entry("default".into())
1192            .or_default()
1193            .kv
1194            .insert(worker.to_string(), KvNamespace { id: "ns-id".into() });
1195        lock
1196    }
1197
1198    /// Record `worker`'s derived KV instance `binding` (S4b, #1967).
1199    pub(crate) fn with_kv_instance(
1200        mut lock: DeployLock,
1201        worker: &str,
1202        binding: &str,
1203    ) -> DeployLock {
1204        lock.record_kv("default", worker, binding, &format!("{binding}-id"));
1205        lock
1206    }
1207
1208    fn bare_orphan(worker: &str) -> OrphanKv {
1209        OrphanKv {
1210            worker: worker.to_string(),
1211            binding: None,
1212        }
1213    }
1214
1215    fn instance_orphan(worker: &str, binding: &str) -> OrphanKv {
1216        OrphanKv {
1217            worker: worker.to_string(),
1218            binding: Some(binding.to_string()),
1219        }
1220    }
1221
1222    /// Mark `queue` as one this project has already created.
1223    pub(crate) fn with_queue(mut lock: DeployLock, queue: &str) -> DeployLock {
1224        lock.record_queue("default", queue);
1225        lock
1226    }
1227
1228    fn scratch_lock_path(label: &str) -> std::path::PathBuf {
1229        let unique = std::time::SystemTime::now()
1230            .duration_since(std::time::UNIX_EPOCH)
1231            .unwrap()
1232            .as_nanos();
1233        std::env::temp_dir().join(format!(
1234            "bynk-{label}-{}-{unique}.deploy.lock",
1235            std::process::id()
1236        ))
1237    }
1238
1239    fn scratch_lock_dir(label: &str) -> std::path::PathBuf {
1240        let unique = std::time::SystemTime::now()
1241            .duration_since(std::time::UNIX_EPOCH)
1242            .unwrap()
1243            .as_nanos();
1244        let dir =
1245            std::env::temp_dir().join(format!("bynk-{label}-{}-{unique}", std::process::id()));
1246        std::fs::create_dir_all(&dir).unwrap();
1247        dir
1248    }
1249
1250    fn temp_litter(dir: &Path) -> Vec<std::path::PathBuf> {
1251        std::fs::read_dir(dir)
1252            .unwrap()
1253            .filter_map(|e| e.ok().map(|e| e.path()))
1254            .filter(|p| p.extension().is_some_and(|x| x == "tmp"))
1255            .collect()
1256    }
1257
1258    /// P3 (#2342): an id starts at its limiter's own seed and probes upward
1259    /// past those the ledger holds, in any environment; a recorded id is
1260    /// kept; a forgotten one is free again.
1261    #[test]
1262    fn rate_limiter_ids_are_allocated_across_environments() {
1263        let mut lock = DeployLock::default();
1264        let api = ratelimit_seed("default", "api", "RATELIMIT_A");
1265        assert_eq!(
1266            lock.allocate_ratelimit("default", "api", "RATELIMIT_A"),
1267            (api.to_string(), true)
1268        );
1269        assert_eq!(
1270            lock.allocate_ratelimit("default", "api", "RATELIMIT_A"),
1271            (api.to_string(), false)
1272        );
1273        let staging = lock.allocate_ratelimit("staging", "api", "RATELIMIT_A").0;
1274        assert_ne!(staging, api.to_string(), "each environment its own");
1275        // A seed another limiter in the ledger already holds probes upward.
1276        let mut crowded = DeployLock::default();
1277        let seed = ratelimit_seed("default", "web", "RATELIMIT_B");
1278        crowded
1279            .environments
1280            .entry("staging".into())
1281            .or_default()
1282            .ratelimit_instances
1283            .insert(
1284                "other".into(),
1285                BTreeMap::from([(
1286                    "RATELIMIT_X".into(),
1287                    RateLimitNamespace {
1288                        namespace_id: seed.to_string(),
1289                    },
1290                )]),
1291            );
1292        assert_eq!(
1293            crowded.allocate_ratelimit("default", "web", "RATELIMIT_B"),
1294            ((seed + 1).to_string(), true)
1295        );
1296        assert!(lock.forget_undeclared_ratelimits("default", "api", &[]));
1297        assert!(!lock.forget_undeclared_ratelimits("default", "api", &[]));
1298        assert!(
1299            !lock.environments["default"]
1300                .ratelimit_instances
1301                .contains_key("api")
1302        );
1303    }
1304
1305    /// P3 (#2342, review): two projects on one account have separate ledgers,
1306    /// so neither can see the other's ids. Their Workers' names differ (a
1307    /// Worker name is unique on an account), and so do the seeds their
1308    /// limiters start from: neither starts where the other did.
1309    #[test]
1310    fn two_projects_ledgers_do_not_start_their_limiters_at_one_id() {
1311        let mut shop = DeployLock::default();
1312        let mut registry = DeployLock::default();
1313        let a = shop
1314            .allocate_ratelimit("default", "shop-cart", "RATELIMIT_LOGIN")
1315            .0;
1316        let b = registry
1317            .allocate_ratelimit("default", "registry-publish", "RATELIMIT_LOGIN")
1318            .0;
1319        assert_ne!(a, b);
1320        for id in [&a, &b] {
1321            let n: u64 = id.parse().unwrap();
1322            assert!(
1323                (FIRST_RATELIMIT_NAMESPACE..FIRST_RATELIMIT_NAMESPACE + RATELIMIT_NAMESPACE_SPAN)
1324                    .contains(&n)
1325            );
1326        }
1327    }
1328
1329    #[test]
1330    fn lock_round_trip_is_environment_keyed() {
1331        let lock = DeployLock {
1332            version: 1,
1333            environments: BTreeMap::from([(
1334                "default".into(),
1335                Environment {
1336                    deleted_classes: BTreeMap::new(),
1337                    renamed_classes: BTreeMap::new(),
1338                    kv: BTreeMap::from([("api".into(), KvNamespace { id: "abc".into() })]),
1339                    kv_instances: BTreeMap::from([(
1340                        "api".into(),
1341                        BTreeMap::from([("KV_SESSIONS".into(), KvNamespace { id: "s".into() })]),
1342                    )]),
1343                    ratelimit_instances: BTreeMap::from([(
1344                        "api".into(),
1345                        BTreeMap::from([(
1346                            "RATELIMIT_LOGIN".into(),
1347                            RateLimitNamespace {
1348                                namespace_id: "1000".into(),
1349                            },
1350                        )]),
1351                    )]),
1352                    r2_instances: BTreeMap::from([(
1353                        "api".into(),
1354                        BTreeMap::from([(
1355                            "R2_ARCHIVES".into(),
1356                            R2Bucket {
1357                                name: "api-archives".into(),
1358                                jurisdiction: Some("eu".into()),
1359                                superseded: vec![R2Past {
1360                                    name: "api-archives".into(),
1361                                    jurisdiction: None,
1362                                }],
1363                            },
1364                        )]),
1365                    )]),
1366                    workers: BTreeMap::from([(
1367                        "api".into(),
1368                        WorkerRecord {
1369                            deployed: true,
1370                            contracts: Default::default(),
1371                            expects: Some(BTreeMap::from([(
1372                                "shop.payment".into(),
1373                                BTreeMap::from([("authorise".into(), "febd15c2757b72e1".into())]),
1374                            )])),
1375                            assets: BTreeSet::new(),
1376                            foreign: BTreeMap::from([(
1377                                "Legacy".into(),
1378                                BTreeMap::from([("ping".into(), "4082ce9e2b74e919".into())]),
1379                            )]),
1380                        },
1381                    )]),
1382                    queues: BTreeSet::from(["intake".to_string()]),
1383                },
1384            )]),
1385        };
1386        assert_eq!(
1387            toml::from_str::<DeployLock>(&toml::to_string_pretty(&lock).unwrap()).unwrap(),
1388            lock
1389        );
1390    }
1391
1392    #[test]
1393    fn a_slice_0_ledger_without_workers_or_queues_still_reads() {
1394        // Both tables are additive: a ledger committed before slice 2 (workers)
1395        // or slice 1 (queues) must keep working, reporting nothing recorded
1396        // rather than failing to parse. #600 D4: the version stays 1, so this
1397        // is the whole migration story.
1398        let lock: DeployLock = toml::from_str(
1399            r#"
1400            version = 1
1401            [environments.default.kv.api]
1402            id = "abc"
1403        "#,
1404        )
1405        .expect("a slice-0 ledger must still parse");
1406        assert_eq!(recorded_kv(&lock, "api", "default"), Some("abc"));
1407        assert!(!lock.is_deployed("default", "api"));
1408        assert!(!lock.has_queue("default", "intake"));
1409    }
1410
1411    #[test]
1412    fn the_queue_set_serialises_as_names_under_the_environment() {
1413        // The committed shape is a documented surface — a reviewer reads this
1414        // file in a diff. Queues are environment-wide names, not a per-worker
1415        // table, and carry no id.
1416        let mut lock = DeployLock {
1417            version: 1,
1418            ..Default::default()
1419        };
1420        lock.record_queue("default", "job-intake");
1421        lock.record_queue("default", "job-retry");
1422        let text = toml::to_string_pretty(&lock).unwrap();
1423        assert!(
1424            text.contains("[environments.default]") && text.contains("queues = ["),
1425            "the queue set is environment-wide, not a per-worker table: {text}"
1426        );
1427        for queue in ["job-intake", "job-retry"] {
1428            assert!(
1429                text.contains(&format!("\"{queue}\"")),
1430                "{queue} is recorded"
1431            );
1432        }
1433        assert!(
1434            !text.contains("id"),
1435            "a queue is addressed by name — the ledger has no id to record: {text}"
1436        );
1437        assert_eq!(toml::from_str::<DeployLock>(&text).unwrap(), lock);
1438    }
1439
1440    /// #1883, slice 1b: a Worker's served asset paths round-trip, and a
1441    /// Worker with none writes no `assets` line.
1442    #[test]
1443    fn a_workers_served_assets_round_trip_and_none_is_not_written() {
1444        let mut lock = DeployLock {
1445            version: 1,
1446            ..Default::default()
1447        };
1448        lock.record_deployed("default", "board", Some(Default::default()));
1449        lock.record_assets(
1450            "default",
1451            "board",
1452            ["/_assets/board.1a2b.css".to_string()]
1453                .into_iter()
1454                .collect(),
1455        );
1456        lock.record_deployed("default", "api", Some(Default::default()));
1457        lock.record_assets("default", "api", BTreeSet::new());
1458        let text = toml::to_string_pretty(&lock).unwrap();
1459        assert!(text.contains("\"/_assets/board.1a2b.css\""), "{text}");
1460        assert_eq!(
1461            text.matches("assets = ").count(),
1462            1,
1463            "only `board` has assets: {text}"
1464        );
1465        assert_eq!(toml::from_str::<DeployLock>(&text).unwrap(), lock);
1466    }
1467
1468    /// I4 (#1970): a Worker's foreign callers round-trip, a later push
1469    /// replaces them, and a Worker with none writes no `foreign` table.
1470    #[test]
1471    fn a_workers_foreign_callers_round_trip_and_none_is_not_written() {
1472        let mut lock = DeployLock {
1473            version: 1,
1474            ..Default::default()
1475        };
1476        let legacy = BTreeMap::from([(
1477            "Legacy".to_string(),
1478            BTreeMap::from([("ping".to_string(), "4082ce9e2b74e919".to_string())]),
1479        )]);
1480        lock.record_deployed("default", "shop-orders", Some(Default::default()));
1481        lock.record_foreign("default", "shop-orders", legacy.clone());
1482        lock.record_deployed("default", "shop-front", Some(Default::default()));
1483        lock.record_foreign("default", "shop-front", BTreeMap::new());
1484        let text = toml::to_string_pretty(&lock).unwrap();
1485        assert!(text.contains("4082ce9e2b74e919"), "{text}");
1486        assert_eq!(
1487            text.matches("foreign").count(),
1488            1,
1489            "only `shop-orders`: {text}"
1490        );
1491        let read = toml::from_str::<DeployLock>(&text).unwrap();
1492        assert_eq!(read, lock);
1493        assert_eq!(read.live_foreign("default", "shop-orders"), Some(&legacy));
1494        // A later push records what that build declares, not a union.
1495        lock.record_deployed("default", "shop-orders", Some(Default::default()));
1496        lock.record_foreign("default", "shop-orders", BTreeMap::new());
1497        assert_eq!(
1498            lock.live_foreign("default", "shop-orders"),
1499            Some(&BTreeMap::new())
1500        );
1501        // A Worker never recorded is left alone.
1502        lock.record_foreign("default", "never", legacy);
1503        assert_eq!(lock.live_foreign("default", "never"), None);
1504    }
1505
1506    /// #2077: what a Worker expects round-trips — a dotted dependency key, and
1507    /// "known to expect nothing" (`Some({})`), which must not read back as "no
1508    /// record" or every Worker that calls nothing would look unrecorded.
1509    #[test]
1510    fn a_workers_expects_round_trip_and_empty_is_not_no_record() {
1511        let mut lock = DeployLock {
1512            version: 1,
1513            ..Default::default()
1514        };
1515        let orders = BTreeMap::from([(
1516            "shop.payment".to_string(),
1517            BTreeMap::from([("authorise".to_string(), "febd15c2757b72e1".to_string())]),
1518        )]);
1519        lock.record_deployed("default", "shop-orders", Some(Default::default()));
1520        lock.record_expects("default", "shop-orders", orders.clone());
1521        lock.record_deployed("default", "shop-payment", Some(Default::default()));
1522        lock.record_expects("default", "shop-payment", BTreeMap::new());
1523        let text = toml::to_string_pretty(&lock).unwrap();
1524        let read = toml::from_str::<DeployLock>(&text).unwrap();
1525        assert_eq!(read, lock, "{text}");
1526        let live: BTreeMap<_, _> = read.live_expects("default").collect();
1527        assert_eq!(live["shop-orders"], Some(&orders));
1528        assert_eq!(live["shop-payment"], Some(&BTreeMap::new()), "{text}");
1529        // A Worker never recorded is left alone.
1530        lock.record_expects("default", "never", orders);
1531        assert!(lock.live_expects("default").all(|(w, _)| w != "never"));
1532    }
1533
1534    /// #2077: a lock written before `expects` was recorded still loads; its
1535    /// Workers read as deployed with no record, not as expecting nothing.
1536    #[test]
1537    fn a_lock_without_expects_still_reads_as_no_record() {
1538        let lock: DeployLock = toml::from_str(
1539            r#"
1540            version = 1
1541            [environments.default.workers.shop-orders]
1542            deployed = true
1543            [environments.default.workers.shop-payment]
1544            deployed = true
1545            [environments.default.workers.shop-payment.contracts]
1546            authorise = "febd15c2757b72e1"
1547        "#,
1548        )
1549        .expect("a lock without `expects` must still parse");
1550        assert!(lock.is_deployed("default", "shop-orders"));
1551        let live: BTreeMap<_, _> = lock.live_expects("default").collect();
1552        assert_eq!(live["shop-orders"], None);
1553        assert_eq!(live["shop-payment"], None);
1554        // And a record that has no `expects` writes none back.
1555        assert!(!toml::to_string_pretty(&lock).unwrap().contains("expects"));
1556    }
1557
1558    #[test]
1559    fn an_empty_queue_set_is_not_written_at_all() {
1560        // A project with no queues must not grow an empty `queues = []` line in
1561        // a committed file for a slice it does not use.
1562        let mut lock = DeployLock {
1563            version: 1,
1564            ..Default::default()
1565        };
1566        lock.record_deployed("default", "api", Some(Default::default()));
1567        assert!(!toml::to_string_pretty(&lock).unwrap().contains("queues"));
1568    }
1569
1570    #[test]
1571    fn materialises_only_the_placeholder() {
1572        // P7.4 (#1305): the real shape `emit_wrangler_toml` produces — a
1573        // `[[kv_namespaces]]` stanza, not a bare root `id` key (the old
1574        // fixture's shortcut, which only worked because the old
1575        // implementation was a whole-file substring replace that didn't
1576        // care where the placeholder sat).
1577        let unique = std::time::SystemTime::now()
1578            .duration_since(std::time::UNIX_EPOCH)
1579            .unwrap()
1580            .as_nanos();
1581        let path =
1582            std::env::temp_dir().join(format!("bynk-deploy-{}-{}", std::process::id(), unique));
1583        std::fs::write(
1584            &path,
1585            format!(
1586                "[[kv_namespaces]]\nbinding = \"KV\"\nid = \"{KV_NAMESPACE_ID_PLACEHOLDER}\"\n"
1587            ),
1588        )
1589        .unwrap();
1590        assert!(materialise_kv_ids(
1591            &path,
1592            &BTreeMap::from([("KV".to_string(), "abc".to_string())])
1593        ));
1594        let text = std::fs::read_to_string(&path).unwrap();
1595        let parsed: toml::Table = text.parse().unwrap();
1596        assert_eq!(
1597            parsed["kv_namespaces"].as_array().unwrap()[0]["id"].as_str(),
1598            Some("abc")
1599        );
1600        let _ = std::fs::remove_file(path);
1601    }
1602
1603    #[test]
1604    fn two_environments_do_not_cross_contaminate_the_ledger() {
1605        let mut lock = DeployLock {
1606            version: 1,
1607            ..Default::default()
1608        };
1609        lock.record_deployed("staging", "api", None);
1610        lock.record_queue("staging", "jobs");
1611        lock.environments
1612            .entry("staging".into())
1613            .or_default()
1614            .kv
1615            .insert(
1616                "api".into(),
1617                KvNamespace {
1618                    id: "kv-staging".into(),
1619                },
1620            );
1621
1622        // "default" was never touched — a `--env staging` run must not
1623        // fabricate or leak into the section a plain `bynk deploy` reads.
1624        assert!(!lock.is_deployed("default", "api"));
1625        assert!(!lock.has_queue("default", "jobs"));
1626        assert_eq!(recorded_kv(&lock, "api", "default"), None);
1627
1628        assert!(lock.is_deployed("staging", "api"));
1629        assert!(lock.has_queue("staging", "jobs"));
1630        assert_eq!(recorded_kv(&lock, "api", "staging"), Some("kv-staging"));
1631    }
1632
1633    /// #837 review: `materialise_deploy_state` (shared with `bynk dev --
1634    /// --remote`) hardcoded `"default"` even after `--env` shipped. Before
1635    /// `--env` existed every real deploy recorded into `"default"`
1636    /// regardless, so that always matched — but a project deployed *only*
1637    /// under `bynk deploy --env staging` now has nothing under `"default"`,
1638    /// and reading the wrong section misreports a provisioned project as
1639    /// never deployed.
1640    #[test]
1641    fn materialise_deploy_state_reads_the_named_environment_not_default() {
1642        let unique = std::time::SystemTime::now()
1643            .duration_since(std::time::UNIX_EPOCH)
1644            .unwrap()
1645            .as_nanos();
1646        let dir = std::env::temp_dir().join(format!(
1647            "bynk-materialise-state-{}-{unique}",
1648            std::process::id()
1649        ));
1650        std::fs::create_dir_all(&dir).unwrap();
1651        let project_root = dir.clone();
1652        let config = dir.join("wrangler.toml");
1653        std::fs::write(
1654            &config,
1655            format!(
1656                "[[kv_namespaces]]\nbinding = \"KV\"\nid = \"{KV_NAMESPACE_ID_PLACEHOLDER}\"\n"
1657            ),
1658        )
1659        .unwrap();
1660
1661        // Provisioned under "staging" alone — the scenario the review named:
1662        // a project that has never had a plain `bynk deploy` (no "default").
1663        let mut lock = DeployLock {
1664            version: 1,
1665            ..Default::default()
1666        };
1667        lock.environments
1668            .entry("staging".into())
1669            .or_default()
1670            .kv
1671            .insert(
1672                "api".into(),
1673                KvNamespace {
1674                    id: "kv-staging".into(),
1675                },
1676            );
1677        std::fs::write(
1678            project_root.join(LOCK_FILE),
1679            toml::to_string_pretty(&lock).unwrap(),
1680        )
1681        .unwrap();
1682
1683        // Reading "default" (what this function did unconditionally before
1684        // the fix) must fail helpfully, not silently mis-materialise.
1685        let err = materialise_deploy_state(&project_root, "api", &config, "default")
1686            .expect_err("nothing is recorded under \"default\" — this must not silently pass");
1687        assert!(
1688            err.contains("environment `default`"),
1689            "the error should name which environment it looked under: {err}"
1690        );
1691
1692        // Reading "staging" — the environment it was actually deployed under
1693        // — must succeed and materialise that environment's id.
1694        assert!(materialise_deploy_state(&project_root, "api", &config, "staging").unwrap());
1695        let text = std::fs::read_to_string(&config).unwrap();
1696        let parsed: toml::Table = text.parse().unwrap();
1697        assert_eq!(
1698            parsed["kv_namespaces"].as_array().unwrap()[0]["id"].as_str(),
1699            Some("kv-staging")
1700        );
1701
1702        let _ = std::fs::remove_dir_all(dir);
1703    }
1704
1705    /// S4b (#1967): `bynk dev --remote` binds every namespace, including on a
1706    /// Worker with no bare `KV`, whose first stanza is an instance's. The old
1707    /// first-stanza gate returned early there and materialised nothing.
1708    #[test]
1709    fn materialise_deploy_state_binds_each_rate_limiter() {
1710        // P3 (#2342): `bynk dev --remote` binds the ids a deploy allocated.
1711        let dir = scratch_lock_dir("materialise-ratelimits");
1712        let config = dir.join("wrangler.toml");
1713        std::fs::write(
1714            &config,
1715            "[[ratelimits]]\nname = \"RATELIMIT_LOGIN\"\nnamespace_id = \"<RATELIMIT_LOGIN_NAMESPACE>\" # set at deploy time\nsimple = { limit = 20, period = 60 }\n",
1716        )
1717        .unwrap();
1718        let mut lock = DeployLock {
1719            version: 1,
1720            ..Default::default()
1721        };
1722        std::fs::write(dir.join(LOCK_FILE), toml::to_string_pretty(&lock).unwrap()).unwrap();
1723        let err = materialise_deploy_state(&dir, "shop", &config, "default")
1724            .expect_err("an unallocated limiter must not pass");
1725        assert!(
1726            err.contains("`RATELIMIT_LOGIN`") && err.contains("bynk deploy"),
1727            "{err}"
1728        );
1729        lock.allocate_ratelimit("default", "shop", "RATELIMIT_LOGIN");
1730        std::fs::write(dir.join(LOCK_FILE), toml::to_string_pretty(&lock).unwrap()).unwrap();
1731        assert!(materialise_deploy_state(&dir, "shop", &config, "default").unwrap());
1732        let text = std::fs::read_to_string(&config).unwrap();
1733        let id = ratelimit_seed("default", "shop", "RATELIMIT_LOGIN");
1734        assert!(
1735            text.contains(&format!(
1736                "namespace_id = \"{id}\" # set at deploy time\nsimple = {{ limit = 20, period = 60 }}"
1737            )),
1738            "{text}"
1739        );
1740        let _ = std::fs::remove_dir_all(dir);
1741    }
1742
1743    #[test]
1744    fn materialise_deploy_state_binds_every_instance() {
1745        let dir = scratch_lock_dir("materialise-instances");
1746        let config = dir.join("wrangler.toml");
1747        std::fs::write(
1748            &config,
1749            "[[kv_namespaces]]\nbinding = \"KV_PRICE_CACHE\"\nid = \"<KV_PRICE_CACHE_ID>\"\n\n[[kv_namespaces]]\nbinding = \"KV_SESSIONS\"\nid = \"<KV_SESSIONS_ID>\"\n",
1750        )
1751        .unwrap();
1752        let mut lock = DeployLock {
1753            version: 1,
1754            ..Default::default()
1755        };
1756        lock.record_kv("default", "shop-cart", "KV_SESSIONS", "sess");
1757        std::fs::write(dir.join(LOCK_FILE), toml::to_string_pretty(&lock).unwrap()).unwrap();
1758
1759        // One instance unrecorded: refused, naming it, and nothing written.
1760        let err = materialise_deploy_state(&dir, "shop-cart", &config, "default")
1761            .expect_err("an unprovisioned instance must not pass");
1762        assert!(
1763            err.contains("`KV_PRICE_CACHE`") && err.contains("`shop-cart`"),
1764            "{err}"
1765        );
1766        assert!(
1767            std::fs::read_to_string(&config)
1768                .unwrap()
1769                .contains("<KV_SESSIONS_ID>")
1770        );
1771
1772        lock.record_kv("default", "shop-cart", "KV_PRICE_CACHE", "price");
1773        std::fs::write(dir.join(LOCK_FILE), toml::to_string_pretty(&lock).unwrap()).unwrap();
1774        assert!(materialise_deploy_state(&dir, "shop-cart", &config, "default").unwrap());
1775        let parsed: toml::Table = std::fs::read_to_string(&config).unwrap().parse().unwrap();
1776        let ids: Vec<&str> = parsed["kv_namespaces"]
1777            .as_array()
1778            .unwrap()
1779            .iter()
1780            .map(|ns| ns["id"].as_str().unwrap())
1781            .collect();
1782        assert_eq!(ids, ["price", "sess"]);
1783        let _ = std::fs::remove_dir_all(dir);
1784    }
1785
1786    /// S4b (#1967): a project with only the bare `Kv` writes the ledger it
1787    /// wrote before instances existed, byte for byte: no `kv_instances` table.
1788    #[test]
1789    fn a_bare_kv_ledger_is_written_as_it_was_before_instances() {
1790        let pre_s4b = "version = 1\n\n[environments.default.kv.api]\nid = \"abc\"\n";
1791        let lock: DeployLock = toml::from_str(pre_s4b).expect("a pre-S4b ledger reads");
1792        assert_eq!(
1793            recorded_kv_binding(&lock, "api", "KV", "default"),
1794            Some("abc")
1795        );
1796        assert_eq!(toml::to_string_pretty(&lock).unwrap(), pre_s4b);
1797    }
1798
1799    /// S4b (#1967): instances live beside the bare namespace, keyed by Worker
1800    /// then binding, and forgetting the last one leaves the file as it was.
1801    #[test]
1802    fn instances_are_recorded_by_worker_and_binding_and_forgotten_cleanly() {
1803        let mut lock = with_kv(
1804            DeployLock {
1805                version: 1,
1806                ..Default::default()
1807            },
1808            "shop-cart",
1809        );
1810        let before = toml::to_string_pretty(&lock).unwrap();
1811        lock.record_kv("default", "shop-cart", "KV_SESSIONS", "sess");
1812        let text = toml::to_string_pretty(&lock).unwrap();
1813        assert!(
1814            text.contains("[environments.default.kv_instances.shop-cart.KV_SESSIONS]"),
1815            "{text}"
1816        );
1817        assert_eq!(
1818            recorded_kv_binding(&lock, "shop-cart", "KV_SESSIONS", "default"),
1819            Some("sess")
1820        );
1821        assert_eq!(
1822            recorded_kv_binding(&lock, "shop-cart", "KV", "default"),
1823            Some("ns-id")
1824        );
1825        assert_eq!(
1826            recorded_kv_binding(&lock, "shop-cart", "KV_OTHER", "default"),
1827            None
1828        );
1829        assert_eq!(
1830            toml::from_str::<DeployLock>(&text).unwrap(),
1831            lock,
1832            "the instance table round-trips"
1833        );
1834        lock.forget_kv("default", "shop-cart", "KV_SESSIONS");
1835        assert_eq!(toml::to_string_pretty(&lock).unwrap(), before);
1836    }
1837
1838    /// Review of #1974: a still-live Worker that stops using the bare `Kv`
1839    /// (say, for `capability Sessions = Kv`) no longer declares `KV`, so its
1840    /// recorded bare namespace is an orphan, like an instance it dropped.
1841    #[test]
1842    fn a_bare_namespace_its_live_worker_no_longer_declares_is_an_orphan() {
1843        let lock = with_kv(DeployLock::default(), "api");
1844        let switched = project(vec![(
1845            "api",
1846            Resources::default().kv_instances(&["KV_SESSIONS"]),
1847        )]);
1848        assert_eq!(
1849            find_orphans(&lock, "default", &switched).kv,
1850            [bare_orphan("api")]
1851        );
1852        let still_bare = project(vec![("api", Resources::default().needs_kv())]);
1853        assert!(find_orphans(&lock, "default", &still_bare).kv.is_empty());
1854    }
1855
1856    /// S4b (#1967): an instance its still-live Worker no longer declares (a
1857    /// capability removed or renamed) is an orphan on its own; a removed
1858    /// Worker's instances go with it; a declared one is not an orphan.
1859    #[test]
1860    fn a_removed_or_renamed_instance_is_an_orphan() {
1861        let lock = with_kv_instance(
1862            with_kv_instance(
1863                with_kv_instance(with_kv(DeployLock::default(), "gone"), "gone", "KV_OLD"),
1864                "shop-cart",
1865                "KV_SESSIONS",
1866            ),
1867            "shop-cart",
1868            "KV_CARTS",
1869        );
1870        // `capability Carts = Kv` renamed to `Baskets`: `KV_CARTS` is gone
1871        // from the build, `KV_BASKETS` is new (a create, in the plan).
1872        let resources = project(vec![(
1873            "shop-cart",
1874            Resources::default().kv_instances(&["KV_BASKETS", "KV_SESSIONS"]),
1875        )]);
1876        let orphans = find_orphans(&lock, "default", &resources);
1877        assert_eq!(
1878            orphans.kv,
1879            [
1880                bare_orphan("gone"),
1881                instance_orphan("gone", "KV_OLD"),
1882                instance_orphan("shop-cart", "KV_CARTS"),
1883            ]
1884        );
1885        assert!(orphans.has_prunable());
1886    }
1887
1888    /// P7.4 (#1305): a project with no KV binding at all must skip the lock
1889    /// lookup entirely and return `Ok(false)` — not error just because
1890    /// nothing is recorded for it (there's nothing to materialise). No lock
1891    /// file exists in this project root at all, proving the early exit
1892    /// really does short-circuit before `read_lock` runs.
1893    #[test]
1894    fn materialise_deploy_state_is_a_no_op_without_a_kv_binding() {
1895        let unique = std::time::SystemTime::now()
1896            .duration_since(std::time::UNIX_EPOCH)
1897            .unwrap()
1898            .as_nanos();
1899        let dir = std::env::temp_dir().join(format!(
1900            "bynk-materialise-state-no-kv-{}-{unique}",
1901            std::process::id()
1902        ));
1903        std::fs::create_dir_all(&dir).unwrap();
1904        let config = dir.join("wrangler.toml");
1905        std::fs::write(&config, "name = \"api\"\nmain = \"index.ts\"\n").unwrap();
1906
1907        assert_eq!(
1908            materialise_deploy_state(&dir, "api", &config, "default"),
1909            Ok(false)
1910        );
1911
1912        let _ = std::fs::remove_dir_all(dir);
1913    }
1914
1915    // ---- #839 slice 5: reconciliation maturity + orphan reporting -------
1916
1917    #[test]
1918    fn a_removed_context_with_kv_is_two_orphans_not_one() {
1919        // `kv` and `workers` are both keyed by worker name — a context that
1920        // had KV and was deleted from source shows up in both maps, so the
1921        // diff must report both, independently (DECISION A).
1922        let mut lock = DeployLock {
1923            version: 1,
1924            ..Default::default()
1925        };
1926        lock.environments
1927            .entry("default".into())
1928            .or_default()
1929            .kv
1930            .insert(
1931                "gone".into(),
1932                KvNamespace {
1933                    id: "kv-gone".into(),
1934                },
1935            );
1936        lock.record_deployed("default", "gone", None);
1937        // "still-here" is in the current build, "gone" is not.
1938        let resources = BTreeMap::from([("still-here".into(), Resources::default())]);
1939
1940        let orphans = find_orphans(&lock, "default", &resources);
1941        assert_eq!(orphans.kv, [bare_orphan("gone")]);
1942        assert_eq!(orphans.workers, names(&["gone"]));
1943        assert!(orphans.queues.is_empty());
1944    }
1945
1946    // ---- P5 (#2323): R2 buckets ----
1947
1948    fn r2_lock() -> DeployLock {
1949        let mut lock = DeployLock {
1950            version: 1,
1951            ..Default::default()
1952        };
1953        lock.record_r2("default", "shop", "R2_ARCHIVES", "shop-archives", None);
1954        lock.record_r2("default", "shop", "R2_OLD", "shop-old", Some("eu"));
1955        lock
1956    }
1957
1958    #[test]
1959    fn a_removed_or_renamed_bucket_is_a_reported_orphan_that_prune_leaves_alone() {
1960        let resources = BTreeMap::from([(
1961            "shop".to_string(),
1962            Resources::default().r2_buckets(&[("R2_ARCHIVES", "shop-archives", None)]),
1963        )]);
1964        let orphans = find_orphans(&r2_lock(), "default", &resources);
1965        assert_eq!(
1966            orphans.r2,
1967            [OrphanR2 {
1968                worker: "shop".into(),
1969                binding: "R2_OLD".into(),
1970                bucket: "shop-old".into(),
1971                jurisdiction: Some("eu".into())
1972            }]
1973        );
1974        // A bucket holds data: `--prune` neither offers nor attempts it.
1975        assert!(!orphans.has_prunable());
1976    }
1977
1978    #[test]
1979    fn a_removed_worker_orphans_every_bucket_it_held() {
1980        let orphans = find_orphans(&r2_lock(), "default", &BTreeMap::new());
1981        assert_eq!(orphans.r2.len(), 2);
1982        assert!(
1983            find_orphans(&r2_lock(), "staging", &BTreeMap::new())
1984                .r2
1985                .is_empty()
1986        );
1987    }
1988
1989    #[test]
1990    fn a_bucket_is_recorded_under_the_environment_and_survives_the_file() {
1991        let lock = r2_lock();
1992        assert_eq!(
1993            recorded_r2(&lock, "shop", "R2_OLD", "default"),
1994            Some(&R2Bucket {
1995                name: "shop-old".into(),
1996                jurisdiction: Some("eu".into()),
1997                superseded: Vec::new()
1998            })
1999        );
2000        assert!(recorded_r2(&lock, "shop", "R2_OLD", "staging").is_none());
2001        let text = toml::to_string_pretty(&lock).unwrap();
2002        assert!(text.contains("[environments.default.r2_instances.shop.R2_OLD]"));
2003        let back: DeployLock = toml::from_str(&text).unwrap();
2004        assert_eq!(back, lock);
2005        // No R2, no table: a project without it writes the file it always did.
2006        assert!(
2007            !toml::to_string_pretty(&with_kv(DeployLock::default(), "api"))
2008                .unwrap()
2009                .contains("r2_instances")
2010        );
2011    }
2012
2013    /// A scratch project holding `config` as `workers/shop/wrangler.toml` and
2014    /// the ledger `lock`; returns the root and the config's path.
2015    fn r2_project(
2016        tag: &str,
2017        config: &str,
2018        lock: &DeployLock,
2019    ) -> (std::path::PathBuf, std::path::PathBuf) {
2020        let root = std::env::temp_dir().join(format!("bynk-r2-{tag}-{}", std::process::id()));
2021        let _ = std::fs::remove_dir_all(&root);
2022        std::fs::create_dir_all(root.join("workers/shop")).unwrap();
2023        let path = root.join("workers/shop/wrangler.toml");
2024        std::fs::write(&path, config).unwrap();
2025        write_lock(&root.join(LOCK_FILE), lock).unwrap();
2026        (root, path)
2027    }
2028
2029    const R2_CONFIG: &str = "name = \"shop\"\n\n[[r2_buckets]]\nbinding = \"R2_ARCHIVES\"\nbucket_name = \"shop-archives\"\n";
2030
2031    #[test]
2032    fn dev_remote_binds_a_recorded_bucket_under_the_environments_name() {
2033        let mut lock = DeployLock {
2034            version: 1,
2035            ..Default::default()
2036        };
2037        lock.record_r2(
2038            "staging",
2039            "shop",
2040            "R2_ARCHIVES",
2041            "shop-archives-staging",
2042            None,
2043        );
2044        let (root, config) = r2_project("staging", R2_CONFIG, &lock);
2045        assert_eq!(
2046            materialise_deploy_state(&root, "shop", &config, "staging"),
2047            Ok(true)
2048        );
2049        let text = std::fs::read_to_string(&config).unwrap();
2050        assert!(
2051            text.contains("bucket_name = \"shop-archives-staging\""),
2052            "{text}"
2053        );
2054        let _ = std::fs::remove_dir_all(root);
2055    }
2056
2057    #[test]
2058    fn dev_remote_leaves_the_default_environments_bucket_as_generated() {
2059        let mut lock = DeployLock {
2060            version: 1,
2061            ..Default::default()
2062        };
2063        lock.record_r2("default", "shop", "R2_ARCHIVES", "shop-archives", None);
2064        let (root, config) = r2_project("default", R2_CONFIG, &lock);
2065        assert_eq!(
2066            materialise_deploy_state(&root, "shop", &config, "default"),
2067            Ok(false)
2068        );
2069        assert_eq!(std::fs::read_to_string(&config).unwrap(), R2_CONFIG);
2070        let _ = std::fs::remove_dir_all(root);
2071    }
2072
2073    #[test]
2074    fn a_replaced_bucket_stays_recorded_and_is_reported_while_its_binding_is_live() {
2075        let mut lock = DeployLock {
2076            version: 1,
2077            ..Default::default()
2078        };
2079        lock.record_r2("default", "shop", "R2_ARCHIVES", "shop-archives", None);
2080        // Recording the same bucket again changes nothing.
2081        lock.record_r2("default", "shop", "R2_ARCHIVES", "shop-archives", None);
2082        assert!(
2083            recorded_r2(&lock, "shop", "R2_ARCHIVES", "default")
2084                .unwrap()
2085                .superseded
2086                .is_empty()
2087        );
2088        lock.record_r2(
2089            "default",
2090            "shop",
2091            "R2_ARCHIVES",
2092            "shop-archives",
2093            Some("eu"),
2094        );
2095        let resources = BTreeMap::from([(
2096            "shop".to_string(),
2097            Resources::default().r2_buckets(&[("R2_ARCHIVES", "shop-archives", Some("eu"))]),
2098        )]);
2099        assert_eq!(
2100            find_orphans(&lock, "default", &resources).r2,
2101            [OrphanR2 {
2102                worker: "shop".into(),
2103                binding: "R2_ARCHIVES".into(),
2104                bucket: "shop-archives".into(),
2105                jurisdiction: None
2106            }]
2107        );
2108        let text = toml::to_string_pretty(&lock).unwrap();
2109        assert_eq!(toml::from_str::<DeployLock>(&text).unwrap(), lock);
2110    }
2111
2112    #[test]
2113    fn dev_remote_refuses_a_bucket_recorded_under_another_jurisdiction() {
2114        let mut lock = DeployLock {
2115            version: 1,
2116            ..Default::default()
2117        };
2118        lock.record_r2(
2119            "default",
2120            "shop",
2121            "R2_ARCHIVES",
2122            "shop-archives",
2123            Some("eu"),
2124        );
2125        let (root, config) = r2_project("jurisdiction", R2_CONFIG, &lock);
2126        let error = materialise_deploy_state(&root, "shop", &config, "default").unwrap_err();
2127        assert!(error.contains("another `@jurisdiction`"), "{error}");
2128        let _ = std::fs::remove_dir_all(root);
2129    }
2130
2131    #[test]
2132    fn dev_remote_refuses_a_bucket_no_deploy_has_made() {
2133        let (root, config) = r2_project(
2134            "unprovisioned",
2135            R2_CONFIG,
2136            &DeployLock {
2137                version: 1,
2138                ..Default::default()
2139            },
2140        );
2141        let error = materialise_deploy_state(&root, "shop", &config, "default").unwrap_err();
2142        assert!(error.contains("R2 bucket `R2_ARCHIVES`"), "{error}");
2143        assert!(
2144            error.contains("run `bynk deploy --env default` first"),
2145            "{error}"
2146        );
2147        let _ = std::fs::remove_dir_all(root);
2148    }
2149
2150    #[test]
2151    fn a_queue_two_contexts_share_is_never_orphaned_while_either_declares_it() {
2152        // The false-positive risk DECISION A's diff exists to avoid: a queue
2153        // consumed by two contexts must not be reported orphaned just because
2154        // a *third*, now-removed context used to consume it too.
2155        let mut lock = DeployLock {
2156            version: 1,
2157            ..Default::default()
2158        };
2159        lock.record_queue("default", "jobs");
2160        let resources = BTreeMap::from([
2161            ("orders".into(), Resources::default().consumes(&["jobs"])),
2162            ("billing".into(), Resources::default().consumes(&["jobs"])),
2163        ]);
2164
2165        let orphans = find_orphans(&lock, "default", &resources);
2166        assert!(
2167            orphans.queues.is_empty(),
2168            "jobs is still consumed by two live contexts: {orphans:?}"
2169        );
2170    }
2171
2172    #[test]
2173    fn a_queue_no_context_declares_anymore_is_orphaned() {
2174        let mut lock = DeployLock {
2175            version: 1,
2176            ..Default::default()
2177        };
2178        lock.record_queue("default", "stale-jobs");
2179        let resources = BTreeMap::from([("orders".into(), Resources::default())]);
2180
2181        let orphans = find_orphans(&lock, "default", &resources);
2182        assert_eq!(orphans.queues, names(&["stale-jobs"]));
2183    }
2184
2185    #[test]
2186    fn find_orphans_is_scoped_to_the_named_environment() {
2187        // A "staging" orphan must never leak into "default"'s report, and
2188        // vice versa — environments (slice 4) stay fully independent.
2189        let mut lock = DeployLock {
2190            version: 1,
2191            ..Default::default()
2192        };
2193        lock.environments
2194            .entry("staging".into())
2195            .or_default()
2196            .kv
2197            .insert(
2198                "gone".into(),
2199                KvNamespace {
2200                    id: "kv-staging-gone".into(),
2201                },
2202            );
2203        let resources = BTreeMap::new();
2204
2205        assert!(
2206            find_orphans(&lock, "default", &resources).kv.is_empty(),
2207            "default"
2208        );
2209        assert_eq!(
2210            find_orphans(&lock, "staging", &resources).kv,
2211            [bare_orphan("gone")]
2212        );
2213    }
2214
2215    #[test]
2216    fn an_absent_environment_has_no_orphans() {
2217        let lock = DeployLock::default();
2218        let orphans = find_orphans(&lock, "default", &BTreeMap::new());
2219        assert_eq!(orphans, Orphans::default());
2220    }
2221
2222    #[test]
2223    fn has_prunable_ignores_worker_only_orphans() {
2224        // The bug the review caught: a project whose only orphan is an
2225        // unprunable Worker must not trigger confirm_prune's "Delete 0
2226        // resource(s)?" prompt at all.
2227        let worker_only = Orphans {
2228            workers: names(&["gone"]),
2229            ..Default::default()
2230        };
2231        assert!(!worker_only.has_prunable());
2232
2233        let with_kv = Orphans {
2234            kv: vec![bare_orphan("gone")],
2235            ..Default::default()
2236        };
2237        assert!(with_kv.has_prunable());
2238
2239        let with_queue = Orphans {
2240            queues: names(&["gone"]),
2241            ..Default::default()
2242        };
2243        assert!(with_queue.has_prunable());
2244
2245        assert!(!Orphans::default().has_prunable());
2246    }
2247
2248    #[test]
2249    fn a_written_ledger_reads_back_identically() {
2250        // The floor the atomic write must not disturb: a real ledger survives a
2251        // write/read round-trip unchanged.
2252        let path = scratch_lock_path("roundtrip");
2253        let mut lock = DeployLock {
2254            version: 1,
2255            ..Default::default()
2256        };
2257        lock.environments
2258            .entry("default".into())
2259            .or_default()
2260            .kv
2261            .insert(
2262                "api".into(),
2263                KvNamespace {
2264                    id: "kv-123".into(),
2265                },
2266            );
2267        write_lock(&path, &lock).unwrap();
2268        assert_eq!(read_lock(&path).unwrap(), lock);
2269        let _ = std::fs::remove_file(path);
2270    }
2271
2272    #[test]
2273    fn an_empty_ledger_is_corruption_not_a_fresh_project() {
2274        // #736: a truncated write leaves a zero-byte file. Reading it as an
2275        // empty v1 ledger would re-mint every namespace, so it must fail hard —
2276        // whereas a genuinely absent file is a fresh project and reads clean.
2277        let path = scratch_lock_path("empty");
2278        std::fs::write(&path, "").unwrap();
2279        assert!(
2280            read_lock(&path).is_err(),
2281            "a zero-byte ledger must be rejected, not treated as no environments"
2282        );
2283        std::fs::write(&path, "   \n\t\n").unwrap();
2284        assert!(
2285            read_lock(&path).is_err(),
2286            "a whitespace-only ledger is just as corrupt"
2287        );
2288        let _ = std::fs::remove_file(&path);
2289        assert!(
2290            read_lock(&path).is_ok(),
2291            "an absent ledger is a fresh project, not corruption"
2292        );
2293    }
2294
2295    #[test]
2296    fn a_ledger_without_a_version_is_rejected() {
2297        // With the serde default gone, a file that parses but carries no
2298        // `version` is corruption rather than a silent empty v1 ledger.
2299        let path = scratch_lock_path("noversion");
2300        std::fs::write(&path, "[environments]\n").unwrap();
2301        assert!(read_lock(&path).is_err());
2302        let _ = std::fs::remove_file(path);
2303    }
2304
2305    #[test]
2306    fn a_successful_write_leaves_no_temp_litter() {
2307        // The atomic write renames its temp over the ledger; nothing sibling to
2308        // the ledger may survive the write.
2309        let dir = scratch_lock_dir("nolitter");
2310        let path = dir.join(LOCK_FILE);
2311        let lock = DeployLock {
2312            version: 1,
2313            ..Default::default()
2314        };
2315        write_lock(&path, &lock).unwrap();
2316        write_lock(&path, &lock).unwrap(); // over an existing ledger, too
2317        assert!(
2318            temp_litter(&dir).is_empty(),
2319            "no `.tmp` sibling may outlive the rename"
2320        );
2321        assert_eq!(read_lock(&path).unwrap(), lock);
2322        let _ = std::fs::remove_dir_all(dir);
2323    }
2324
2325    #[cfg(unix)]
2326    #[test]
2327    fn a_rewrite_preserves_the_ledger_permissions() {
2328        use std::os::unix::fs::PermissionsExt;
2329        // A committed ledger's mode must survive a rewrite, or the atomic replace
2330        // would silently loosen or tighten it via the temp file's fresh mode.
2331        let dir = scratch_lock_dir("perms");
2332        let path = dir.join(LOCK_FILE);
2333        let lock = DeployLock {
2334            version: 1,
2335            ..Default::default()
2336        };
2337        write_lock(&path, &lock).unwrap();
2338        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
2339        write_lock(&path, &lock).unwrap();
2340        assert_eq!(
2341            std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
2342            0o600,
2343            "the rewrite must carry the ledger's own mode across the rename"
2344        );
2345        let _ = std::fs::remove_dir_all(dir);
2346    }
2347}