@bongos/core 1.19.682 → 1.19.684

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.682",
6
- "core_contract": "1.19.682",
7
- "source_commit": "b499e76b5b34ee8e7e753b7b89a3f6e1e8874224",
5
+ "core_version": "1.19.684",
6
+ "core_contract": "1.19.684",
7
+ "source_commit": "3cf0cdf9f887f9b33631e0bcfb3b7290cd935b06",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-12T16:22:24.694Z",
9
+ "built_at": "2026-09-12T20:55:49.945Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 476,
12
+ "docs_redacted": 477,
13
13
  "agent_docs_stubbed": 24,
14
- "functional_verbatim": 2124,
14
+ "functional_verbatim": 2125,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2624,
20
- "tree_sha256": "a8aca2d995556a722b4debefe87ae388610d584d4954da4285b7dbf0a642eef4",
19
+ "file_count": 2626,
20
+ "tree_sha256": "dc96d8a5eb830704ee772fe1cee19ce1c4ec39575c4fafbbfe46012f5a2cd4b9",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -1909,10 +1909,15 @@
1909
1909
  "mode": "0000644",
1910
1910
  "sha256": "46e65d06711ad356563b01b824c416d03cecf135f9f69a60e469710e91cb3f62"
1911
1911
  },
1912
+ {
1913
+ "path": "docs/adr/0280-the-unattended-lane-may-drive-a-co-tenant-because-the-roster-is-the-decision.md",
1914
+ "mode": "0000644",
1915
+ "sha256": "630f6460b3484f4cc629cce9c431f824bb8363d30a249db9e21e89863995c7ac"
1916
+ },
1912
1917
  {
1913
1918
  "path": "docs/adr/README.md",
1914
1919
  "mode": "0000644",
1915
- "sha256": "6766d2b792da65e6cebc79adf4261b710d6f5ce113a8a84953cf1c643e20a0b7"
1920
+ "sha256": "49273e63c50c201cd70200a4f0db7f233e496827823adb9449638eb5a0c1d030"
1916
1921
  },
1917
1922
  {
1918
1923
  "path": "docs/api-reference.md",
@@ -2797,7 +2802,7 @@
2797
2802
  {
2798
2803
  "path": "docs/module-api-changelog.md",
2799
2804
  "mode": "0000644",
2800
- "sha256": "1f62636e946a5b5bbf9b546d2e532b704db15ca21c3a9d0e71f5a8b17cde150b"
2805
+ "sha256": "d441eb881efdcff2c6afedfa6d93799fdd64ac323422f12b54bf2c94df2b8d4a"
2801
2806
  },
2802
2807
  {
2803
2808
  "path": "docs/modules-contract.md",
@@ -7777,12 +7782,12 @@
7777
7782
  {
7778
7783
  "path": "package-lock.json",
7779
7784
  "mode": "0000644",
7780
- "sha256": "db81ab75a6e749de67b91e13f3605d79a58a90b2e57c83332f535d48273dabcb"
7785
+ "sha256": "130314108dd59672172778afcb5eedb0b8d605a983329a0d0a878ce9c59fa9c9"
7781
7786
  },
7782
7787
  {
7783
7788
  "path": "package.json",
7784
7789
  "mode": "0000644",
7785
- "sha256": "f4d50516d2a3a9ee031a89735502c1c8ec01789eeb4dfa3fd20da07713679279"
7790
+ "sha256": "d5281482b1e0da7cd0cab8f475fc71345776694e8b457b2f96d241a315902ee4"
7786
7791
  },
7787
7792
  {
7788
7793
  "path": "public-docs/index.html",
@@ -8282,7 +8287,7 @@
8282
8287
  {
8283
8288
  "path": "scripts/gds/gen-api-client.js",
8284
8289
  "mode": "0000644",
8285
- "sha256": "2d8b35709e19b339bc55c57237f2add36fa6bdeb83a9b036faa902b0c4b8e3f9"
8290
+ "sha256": "a023a19001d63b671409efe1afeae400dae96ea50debd6e3a49a7d10dffb55b0"
8286
8291
  },
8287
8292
  {
8288
8293
  "path": "scripts/gds/gen-api-docs.js",
@@ -9542,7 +9547,7 @@
9542
9547
  {
9543
9548
  "path": "src/module-api.js",
9544
9549
  "mode": "0000644",
9545
- "sha256": "9f875091ba63a70601a61a1d486f5b561026167aa524bd371e457c9a93c2635c"
9550
+ "sha256": "927d43875de79a0923fc0997262fd7a0106c6f56626a89ed5ede0c6c74945eec"
9546
9551
  },
9547
9552
  {
9548
9553
  "path": "src/module-loader/catalog.js",
@@ -9662,7 +9667,7 @@
9662
9667
  {
9663
9668
  "path": "tests/api_client.mjs",
9664
9669
  "mode": "0000644",
9665
- "sha256": "fa360a42fa5d11b225afe1b13622f2a27fe43f2b7d0a47c2fef158bc8a11ecb6"
9670
+ "sha256": "c1563c043f34e84aa6c0eec722244b24c51dc1254750fe1a27d3664c327bacd1"
9666
9671
  },
9667
9672
  {
9668
9673
  "path": "tests/api_docs.mjs",
@@ -12984,6 +12989,11 @@
12984
12989
  "mode": "0000644",
12985
12990
  "sha256": "9debf9ecdb02b986c882f3d29bdf4b5fe2154cb4e7639db0fd0a0336cd4bdc3b"
12986
12991
  },
12992
+ {
12993
+ "path": "tests/update_subscription_engine.mjs",
12994
+ "mode": "0000644",
12995
+ "sha256": "a2ba2dca70c6192313c7a64e6dcbb5d7f9dbf2dc457592e643f1d59154b2665f"
12996
+ },
12987
12997
  {
12988
12998
  "path": "tests/upgrade.mjs",
12989
12999
  "mode": "0000644",
@@ -0,0 +1,63 @@
1
+ # ADR 0280 — The unattended lane may drive a co-tenant, because the roster entry *is* the per-instance decision
2
+
3
+ - **Status:** accepted
4
+ - **Date:** 2026-09-12
5
+ - **Task:** [1003843](https://cloudbongos.com/builders#/task/1003843) (goal 1000106) — from [task 1003521](https://cloudbongos.com/builders#/task/1003521), which built co-tenant mode into `go-live.js` and deliberately left this caller alone.
6
+ - **Adjacent:** [ADR 0279](<redacted>.md) (why this lane prefers `go-live.js` at all) · [ADR 0136](<redacted>.md) (the subscription policy core).
7
+
8
+ ## Context
9
+
10
+ `go-live.js` grew **co-tenant mode** in task 1003521: `deployTimer` became optional (a provisioned instance under `/srv/<host>/<slug>` has no pull-deploy timer to stop), and `pinMode: local-commit | leave-dirty` stopped it pushing the pin into the **customer's own** GitHub repo.
11
+
12
+ The nightly auto-subscription lane (`.claude/scheduled-tasks/core-update-subscription/subscribe.js`) could not reach any of it, because it keeps a **second copy of go-live's target contract**:
13
+
14
+ 1. `GO_LIVE_REQUIRED` still listed `deployTimer`, so `goLiveTargetFor()` returned `{ok: false, missing: ['deployTimer']}` for every co-tenant.
15
+ 2. `rawTopologyFor()` reads a fixed field list off `config/update-subscriptions.json`, and `pinMode` was not in it — the same stale-normalizer trap that function's own comment was written to fix, one field later.
16
+
17
+ Task 1003521 deferred the fix on the grounds that widening it "would have meant changing the unattended lane's policy about which instances it may deploy, which is a judgement call." **That premise does not survive reading the fallback.** `mapped.ok === false` does not mean "skip this instance" — it means "deploy it the *weaker* way": a bare `bongos upgrade` with no backup and no deploy-timer guard, and, because the roster's `--commit-pin` support is independent of all this, a pin **committed and pushed** — into the co-tenant's own repo, which is precisely what `pinMode` exists to prevent.
18
+
19
+ So the lane was already auto-deploying co-tenants. The only question ever open was whether it did so well.
20
+
21
+ A third defect sat behind the other two, and would have turned the reported silent downgrade into a hard failure if only the first had been fixed: the normalizer yields `deployTimer: null` for an absent field, an explicit `null` **survives `JSON.stringify`** into `--target-json`, and go-live's `validateTarget` runs `isSafeToken(null)` on it and rejects the whole target. Dropping `deployTimer` from `GO_LIVE_REQUIRED` alone would have handed go-live a target it refuses.
22
+
23
+ **The remaining defects sat outside the go-live call entirely, and they are what made "reachable" different from "safe".** The lane writes the instance's pin in **three** places, and every one of them decided whether to push without asking what mode it was in:
24
+
25
+ 1. **The durability net after the upgrade** — `commitPin()` commits the pin files and **pushes to `origin`**. Under `local-commit` go-live leaves the tree clean, so the net no-ops and is correct by luck. Under **`leave-dirty`** the pin is in the working tree *by design*, so the net committed it and pushed it into the customer's repo.
26
+ 2. **The HEAL-FIRST step before the upgrade**, which is the worst of the three because it fires on the **happy path**. A `leave-dirty` co-tenant's steady state after a *successful* upgrade IS a dirty pin — so the next scheduled sweep read it as "a leftover from an earlier sweep", committed it and pushed it. Reliably, every sweep, not just after a crash. (Found by the grader; it shipped past a green suite on the first round because the decision was an inline `if` inside a 250-line loop that no test could reach.)
27
+ 3. **The direct-upgrade fallback**, which passes `--commit-pin` to `upgrade.js` — and that flag commits *and pushes*. So a co-tenant on an older core had its pin pushed anyway, by the very path taken when go-live's co-tenant support was missing.
28
+
29
+ Reaching co-tenant mode without closing all three would have been worse than not reaching it at all: the lane would honour `pinMode` at the one site that names it and undo it at the two that do not.
30
+
31
+ ## Decision
32
+
33
+ **The nightly lane may drive a co-tenant, and needs no new gate to do it — because enrolling an instance in `config/update-subscriptions.json` already IS the explicit, per-instance operator decision.** The roster ships empty, an instance is touched only after an operator lists it, and the whole routine is behind the autonomy flag (`requiresAutonomy: true`, default OFF). Adding a second consent gate in front of that would be asking the same person the same question twice.
34
+
35
+ The change therefore makes the *existing* behaviour correct rather than widening it. Concretely, in `subscribe.js`:
36
+
37
+ - `GO_LIVE_REQUIRED` now **mirrors** go-live's `REQUIRED_FIELDS` exactly, and a test reads **both source files** and fails on drift. The contract is someone else's; the copy is the liability.
38
+ - `deployTimer` moves into the optional-field loop, which drops falsy values, so a co-tenant **omits the key** rather than carrying a `null` that go-live would reject.
39
+ - `rawTopologyFor` carries `pinMode`, and `goLiveTargetFor` passes it through.
40
+ - The capability probe in `resolveGoLive` gains a **`needsCoTenant`** leg, checked **only when the target actually needs it** — a target with no `deployTimer`, or one naming `pinMode`. Both are rejected outright by a go-live.js predating 1003521 (the missing required field; the unknown-field check), so the fail-closed rule of task 1003212 applies: probe the capability, fall back if absent.
41
+ - `goLiveEngineFor()` makes that decision **once**, for both the dry run and the apply path. They previously re-derived it separately, and the co-tenant probe gave them a third thing to keep in step — a preview that promises an engine the real sweep will not use is its own quiet failure.
42
+ - **All three pin-writing sites obey one policy, read from the roster.** `pinModeOf()` reads the **roster entry** — the operator's declared intent — not the go-live target, because deriving it from the target would silently revert a co-tenant to `push` on exactly the runs that fall back to a direct upgrade. `commitPin()` takes `push` (default `true`, so the platform instance is unchanged); the fallback withholds `--commit-pin` unless the mode is `push`; and the "pin is not durable" advice no longer tells an operator to `git push` a repo we do not own.
43
+ - **The heal decision is a named function, `healDecision(pre, pinMode)`.** Under `leave-dirty` a dirty pin is `by-design`, not a leftover — go-live's dirty-checkout halt reads the **core** checkout, not the instance, so leaving it dirty wedges nothing. Extracting it is the point as much as the fix: all three bugs were a site deciding without asking, and an inline `if` in a long loop is exactly what no test can reach. Non-pin changes still `halt` in **every** mode — a human's uncommitted work stops the lane regardless of pin policy, and the test pins that ordering.
44
+ - **An unknown `pinMode` is refused, not defaulted.** Every unrecognised value would otherwise fall through to the push branch, so a typo in the roster degrades to the *most* dangerous behaviour. The instance is skipped with the expected values named. `PIN_MODES` mirrors go-live's enum, pinned by the same read-both-sources test as `GO_LIVE_REQUIRED`.
45
+
46
+ **Why the probe is conditional and not unconditional.** Probing for `pinMode` on every target would strip the go-live path from any instance sitting on a core between the `--target-json` release and 1003521's — a live regression traded for a hypothetical one. The capability is demanded only where it is used.
47
+
48
+ ## Consequences
49
+
50
+ - A provisioned co-tenant listed in the roster now takes the **stronger** deploy path: pg_dump backup, the served-version read-back of ADR 0279, and a pin that is committed locally instead of pushed into a repo we do not own — end to end, including the lane's own net after go-live returns.
51
+ - A co-tenant on an older core **falls back and says so**, naming co-tenant support specifically, so an operator can tell it apart from a malformed roster entry.
52
+ - Ordinary instances are unchanged — including the mid-generation ones the conditional probe protects.
53
+ - `GO_LIVE_REQUIRED` and `PIN_MODES` drifting from go-live's contract are now **test failures**, not silent downgrades. This is the second time this copy has drifted; the next time it moves, CI says so.
54
+ - A source-level test asserts that **every** `commitPin` call site names `push`, and that the fallback gates `--commit-pin`. The recurring bug was never one site — it was the absence of anything that could see all of them at once.
55
+
56
+ ## Rejected
57
+
58
+ - **A second consent gate** (an opt-in `allowCoTenant` roster flag, or an operator-only command): the roster entry already carries that consent, and the routine is autonomy-gated on top of it.
59
+ - **Deferring to the fleet control plane** ([task 1001948](https://cloudbongos.com/builders#/task/1001948)): that epic is unspecced, and leaving this until then does not leave co-tenants alone — it leaves them on the weaker path, pushing pins into customer repos.
60
+ - **Dropping `deployTimer` from `GO_LIVE_REQUIRED` and nothing else** (the fix as originally proposed): it trades a silent downgrade for a hard target rejection, because of the surviving `null`.
61
+ - **Probing for co-tenant support unconditionally:** regresses every instance on a mid-generation core.
62
+ - **Reaching co-tenant mode without teaching the durability net about it:** `leave-dirty` would have been committed and pushed to the customer's remote by the net running seconds after go-live deliberately did not — nominal support that breaks its own promise.
63
+ - **Teaching `loadSubscriptions()` about `pinMode`** instead of `rawTopologyFor`: that normalizer ships in the *control plane's* vendored core, which nobody bumps — trusting it is the exact trap `rawTopologyFor` was written to route around.
@@ -371,3 +371,4 @@ This keeps the decision history honest and traceable.
371
371
  | 0277 | [**A box is "in use" only while a human is attached, and the claim expires** ([task 1003507](https://cloudbongos.com/builders#/task/1003507) · goal 1000095 — *Working area 6*, criterion `wa6-role-experience`). `sweep-idle` skipped any box with `claude_active = true` at ANY age, and `claude_active` is a LATCH, not a level: only a heartbeat ping writes it, and `infra/box-heartbeat.sh` exits WITHOUT pinging when it sees nothing — so silence, the very signal the sweep exists to act on, could never clear it. Reproduced against the shipped selector: skipped at `idleMinutes` of 10, 120, 1440 and **5,256,000** (ten years). Not reaped late; never. The second leg is that `load > 0.2` kept `last_activity_at` bumping every 5 minutes anyway (a devcontainer plus an idle `claude` clears that floor on its own), so EITHER leg alone kept a box alive — which is why bounding only the veto looks like a fix and is not: the incident box's heartbeat was FRESH. Measured: `example-owner`'s box up since 2026-08-12, tmux `otb` UNATTACHED since 2026-08-15 18:01 UTC, `claude` burning 16 min of CPU across 25h of wall clock, **$20.21** of mostly-unattended compute. A THIRD defect, found while testing this and confirmed on clean `origin/main`, made the sweep inert regardless: `loadDeps()` read `_deps` before anything declared it, so it threw `ReferenceError` on first call and every command resolving a DigitalOcean client went with it — including `sweep-idle --apply`, which builds that client before the park loop. **The idle sweep could not park any box at all**, hidden because the suite's only `apply: true` test relied on the veto emptying the idle set before `makeDo()` was reached: one bug shielded by the other. **Decision: a box may not stay active longer than `BOX_UNATTENDED_MAX_HOURS` (12) without evidence a HUMAN was attached.** The heartbeat already computed that signal and folded it into one boolean; it now reports `attached` separately (login session, inbound SSH, open ttyd, or an ATTACHED tmux client via `#{session_attached}` — the signal that separates this box from a working one) and the server stamps `last_attached_at`. `claude_active` keeps its veto but it EXPIRES, bounded by a `claude_active_since` edge stamp. `pgrep -x claude` and the load floor remain reasons the box PINGS, never evidence anyone is THERE — a running process is not a person, and that distinction is the whole decision. The two `NULL` defaults deliberately DISAGREE: `last_attached_at` NULL means "no data" and keeps a pre-1003507 box on the old behaviour (core_238 pointedly does NOT backfill it — a backfilled `now()` starts a clock nothing can advance and parks every un-upgraded box one cap later, and box source sync is not prompt: idea 1000745 records 257 commits behind for three days), while `claude_active_since` NULL is REFUSED because an unknown latch age is the forever-latch itself, and is backfilled so the state is unreachable after deploy. 12h because parking is reversible since task 1002726 (snapshot kept), so a false positive costs one wake against $20.21 for no cap. Both clocks clear at park/wake/deprovision, or a woken box inherits an expired latch and is parked instantly — the fix reintroducing the bug from the far side. Rejected: tracking attachment INSTEAD of bounding the latch (the silent box keeps `true` forever, so the reported hole survives); bounding the latch alone (built first, and the verification probe caught it — the fresh heartbeat meant lifting the veto changed nothing); deleting the load floor (it is what makes an autonomous run count); measuring from `active_since` (that is uptime — parks a box worked on for days); raising `BOX_IDLE_MINUTES` (no threshold reaches an unbounded veto).](<redacted>.md) | dev box / cost / idle sweep |
372
372
  | 0278 | [**A gated project still takes applications, and the exemption is scoped to the verb** ([task 1003525](https://cloudbongos.com/builders#/task/1003525) · the apply write itself in [task 1003624](https://cloudbongos.com/builders#/task/1003624) · goal 1000106 — *Working area 1, Project creation*; owner decision 2026-09-11). A project's owner sets who may SEE it (`platformVisibility`, [ADR 0192](<redacted>.md)) and who may JOIN it (`joinability`, [ADR 0194](<redacted>.md)) independently — and set to their middle values, members-only AND apply-to-join, the project took no applications at all: the member door refused every cookie-less request with `401` before the public `POST <api>/access-requests` could answer, because that write was not on the exempt list. The two settings composed into **"nobody can apply"**, which nobody chose. It survived because nothing LIED about it — the hub's join box relayed the project's own `401` honestly as `members_only`, and the hall's landing, where the apply form lives, is itself behind the door; the composition was simply unreachable. Found by the R14 proof ([task 1002333](https://cloudbongos.com/builders#/task/1002333)). ADR 0192 §3 had fixed the exempt list at "the door, the manifest, the probes and the downloads — and nothing wider" and left widening it as an owner call, which is what this is. **Decision: yes — and BOTH halves are exempted, each scoped to one path and one verb.** `POST <api>/access-requests` (it grants nothing — an application is a row in a queue the owner still reviews, [ADR 0201](<redacted>.md), already public on every non-gated project) and `GET <api>/access-requests/status` (without it the answer is half an answer: `bongos login` cannot re-poll the device flow after a `not_approved` — the `device_code` is spent — so an applicant would file a request and then wait on an approval they can never observe). `EXEMPT` entries may now be `{ re, methods }` beside the bare `RegExp`s, and `isExempt(path, method)` takes the verb as an OPTIONAL second argument that **fails closed** for a scoped entry when none is given, so the one-argument static callers cannot accidentally widen. **The verb is load-bearing, not tidiness:** the bare `GET` on `<api>/access-requests` is the OWNER'S QUEUE (`requireBuilder` + `access_request.review`), the surface listing would-be builders by name with their vouch state — a path-only exemption would have silently taken the member door off the front of it, leaving one layer where there were two, and the queue's own `requirePermission` still holding is exactly what makes that loss easy to miss. **It opens no oracle the gate was closing:** the status route's boolean twin `GET <api>/auth/web/admission-status` is ALREADY reachable on a gated project inside the `auth/*` subtree §3 exempts whole (§3 records that cost in as many words), and the two share ONE per-IP budget on purpose ([ADR 0209](<redacted>.md)) so neither can be alternated against the other. What it DOES add, stated as the honest cost: applicant detail — `pending`/`dismissed`/`none` over the twin's bare `admitted`. Whether that answer should collapse is ADR 0209's still-open owner question and is deliberately NOT decided here. No hub change: `joinRelayOutcome` maps the RELAYED status, so it carries the project's real answer the moment the `401` stops. Rejected: "gated means gated" — hide *Apply to join* and say so in the manage blurb (coherent, and the call went the other way); exempting the path without the verb; exempting the write alone; collapsing the status response while the route happened to be open (that is how a deferred decision gets made by accident).](<redacted>.md) | project visibility / join door / member door |
373
373
  | 0279 | [**An upgrade is proven by the served version, not by a health check** ([task 1002884](https://cloudbongos.com/builders#/task/1002884) · goal 1000090 — *Working area 4, Bongos Core distribution*; from idea 1000682). On 2026-08-11 the auto-upgrade sweep printed `✓ upgrade complete — core 1.19.13 → 1.19.56`, wrote a success row to `core_upgrades` and exited 0 while live kept serving **1.19.13**. Three shipped checks formed a closed loop that could not see the failure they existed to catch: the `systemctl restart` failed with `Interactive authentication required` (a `User=` unit, no TTY) and `upgrade.js` treated it as a WARNING and fell through; `readInstalledCoreVersion()` then confirmed the version on **disk**, where `npm install` had correctly put it; and `pollHealth()` got a 200 from the **still-running old process**, because a health check confirms a port is served, never *what* serves it. A lying tool is worse than a broken one — nothing goes looking. The damage outlived the incident: `subscribe.js` had already routed the unattended lane around `bongos upgrade` in favour of `go-live.js`, citing this false-pass in a comment. **Decision: a bump is confirmed by asking the running process what version it is.** (1) A failed restart enters the same auto-rollback path as a failed install/migrate/health and exits non-zero — rolling back rather than merely erroring keeps disk and process consistent, since disk-ahead-of-process is the state that made the incident invisible. (2) The served version is read back from `/version` (`coreVersion`, since 1.17.2, prelaunch-gate exempt), defaulting to the `--health-url` origin and overridable via `--version-url` — derived rather than opt-in because every existing call site passes only `--health-url`, and a check you must opt into is off exactly where it is needed. (3) A **mismatch** fails (proof of failure → roll back); an **unreadable** endpoint only warns (absence of proof — refusing every such bump would regress harder than the false-pass), except under an explicit `--version-url`, which asks for proof and therefore gets a failure. That strict mode is what the unattended subscription lane now passes, its roster already carrying the URL. **Escalation is `sudo -n`, not a hand-placed polkit rule:** `restartService()` retries a failed restart through `sudo -n` when not root — the idiom `dev.js`/`dev-lib.js` already use and consistent with `provision.js`, `upgrade.js` having been the one place that restarted without escalating. The live `/etc/polkit-1/rules.d/<redacted>.rules` mitigation is superseded: a rebuilt box inherits code, not hand-placed `/etc` files (polkit route kept in [`docs/recipes/instance-service-restart.md`](../recipes/instance-service-restart.md)). `--no-health-check` stays the single escape hatch and now waives the read-back too. Rejected: comparing `startedAt` (cannot distinguish a restart onto the same old core from one onto the new); making an unreadable endpoint fatal by default; requiring `--version-url` everywhere (absent from every current call site — the same "off where it matters" failure in a new costume); keeping polkit as the answer.](<redacted>.md) | core distribution / upgrade verification |
374
+ | 0280 | [**The unattended lane may drive a co-tenant, because the roster entry IS the per-instance decision** ([task 1003843](https://cloudbongos.com/builders#/task/1003843) · goal 1000106; from [task 1003521](https://cloudbongos.com/builders#/task/1003521), which built co-tenant mode into `go-live.js` and deliberately left this caller alone). `subscribe.js` keeps a SECOND COPY of go-live's target contract, and it drifted: `GO_LIVE_REQUIRED` still listed `deployTimer` after 1003521 made it optional, so `goLiveTargetFor()` returned `missing: ['deployTimer']` for every provisioned co-tenant, and `rawTopologyFor()`'s fixed field list dropped `pinMode` before the target was built — the same stale-normalizer trap that function's own comment was written to fix, one field later. **1003521 deferred this as a policy judgement call; reading the fallback shows the premise was wrong.** `mapped.ok === false` never meant "skip this instance" — it meant "deploy it the WEAKER way": a bare `bongos upgrade`, no backup, no deploy-timer guard, and a pin COMMITTED AND PUSHED into the co-tenant's own repo, which is exactly what `pinMode` exists to prevent. The lane was already auto-deploying co-tenants; the only open question was whether it did so well. A THIRD defect sat behind the other two and would have turned the silent downgrade into a hard failure had only the first been fixed: the normalizer yields `deployTimer: null`, an explicit null SURVIVES `JSON.stringify` into `--target-json`, and go-live's `validateTarget` runs `isSafeToken(null)` on it and rejects the whole target. A FOURTH sat PAST the go-live call and is what separates "reachable" from "safe": after any successful upgrade the lane runs its own pin net, `commitPin()`, which commits the pin files and PUSHES them to `origin` — a net that predates `pinMode`. Under `local-commit` go-live leaves the tree clean so it no-ops by luck; under **`leave-dirty`** the pin is in the working tree BY DESIGN, so the net committed it and pushed it into the customer's own repo — the lane honouring `pinMode` right up to the moment it undid it. **Decision: the lane may drive a co-tenant and needs no new gate, because listing an instance in `config/update-subscriptions.json` already IS the explicit per-instance operator decision** — the roster ships empty, nothing is touched until an operator lists it, and the routine is autonomy-gated (`requiresAutonomy: true`, default OFF) on top of that; a second consent gate asks the same person the same question twice. So: `GO_LIVE_REQUIRED` now MIRRORS go-live's `REQUIRED_FIELDS`, with a test reading BOTH source files and failing on drift; `deployTimer` moves to the optional-field loop so a co-tenant OMITS the key rather than carrying a null; `rawTopologyFor` carries `pinMode`; `resolveGoLive` gains a `needsCoTenant` probe leg; and `goLiveEngineFor()` makes the decision ONCE for both the dry run and the apply path, which had re-derived it separately. The pin net now obeys the same `pinMode` go-live was given — `commitPin()` takes `push` (default true, so the platform instance is unchanged), `local-commit` commits without pushing, `leave-dirty` skips the net, the rollback tidy follows the same rule, and the "not durable" advice stops telling an operator to `git push` a repo we do not own; its refusal to touch a worktree carrying NON-pin changes is unchanged in every mode. **The probe is CONDITIONAL on purpose** — demanding `pinMode` of every target would strip the go-live path from any instance between the `--target-json` release and 1003521's, trading a live regression for a hypothetical one. Rejected: a second consent gate (an `allowCoTenant` flag); deferring to the unspecced fleet control plane ([task 1001948](https://cloudbongos.com/builders#/task/1001948)) — which does not leave co-tenants alone, it leaves them pushing pins into customer repos; dropping `deployTimer` from `GO_LIVE_REQUIRED` and nothing else (the fix as proposed — trades a silent downgrade for a hard rejection); probing unconditionally; reaching co-tenant mode WITHOUT teaching the durability net about it (nominal support that breaks its own promise seconds later); and teaching `loadSubscriptions()` about `pinMode` instead, since that normalizer ships in the control plane's vendored core that nobody bumps — the exact trap `rawTopologyFor` routes around.](<redacted>.md) | core distribution / unattended deploy |
@@ -1823,5 +1823,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1823
1823
  landed since 1.19.680 with no explicit bump. run 34666648354. (task 1002620)
1824
1824
  1.19.682 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1825
1825
  landed since 1.19.681 with no explicit bump. run 34704993368. (task 1002620)
1826
+ 1.19.683 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1827
+ landed since 1.19.682 with no explicit bump. run 34706984747. (task 1002620)
1828
+ 1.19.684 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1829
+ landed since 1.19.683 with no explicit bump. run 34718471740. (task 1002620)
1826
1830
  ---------------------------------------------------------------------------
1827
1831
  ```
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.682",
3
+ "version": "1.19.684",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.682",
9
+ "version": "1.19.684",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.682",
3
+ "version": "1.19.684",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -21,6 +21,7 @@
21
21
  const fs = require('node:fs');
22
22
  const path = require('node:path');
23
23
  const { resolveDocsRoot } = require('../../src/instance-config');
24
+ const { sameContent } = require('./gen-freshness'); // eol-insensitive freshness (CRLF checkouts)
24
25
 
25
26
  // The client is generated FROM the spec and lives beside it as a per-instance
26
27
  // artifact: both resolve to the DOCS root — the instance repo when a consumer
@@ -516,7 +517,15 @@ function main() {
516
517
  for (const [name, content] of Object.entries(files)) {
517
518
  const dest = path.join(OUT_DIR, name);
518
519
  const existing = fs.existsSync(dest) ? fs.readFileSync(dest, 'utf8') : null;
519
- if (existing !== content) {
520
+ // Freshness is a question about CONTENT, so compare content (task 1003844 — the
521
+ // same defect as 1003619). We render with join('\n') = LF, but a core.autocrlf
522
+ // checkout hands readFileSync back CRLF, so a byte-wise `!==` called the already
523
+ // -correct client STALE on every Windows clone, forever: regenerating writes LF,
524
+ // git restores CRLF, gate red again. Comparing via sameContent cures it for every
525
+ // file here at once, so a seventh generated file can't reintroduce it by missing
526
+ // an eol pin. A missing file reads as null, which never matches a non-empty
527
+ // render — still stale, as before.
528
+ if (!sameContent(existing, content)) {
520
529
  stale = true;
521
530
  if (!check) { fs.mkdirSync(OUT_DIR, { recursive: true }); fs.writeFileSync(dest, content); }
522
531
  }
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.682'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.684'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -11,12 +11,15 @@ import { strict as assert } from 'node:assert';
11
11
  import { test } from 'node:test';
12
12
  import { createRequire } from 'node:module';
13
13
  import fs from 'node:fs';
14
+ import os from 'node:os';
14
15
  import path from 'node:path';
16
+ import { spawnSync } from 'node:child_process';
15
17
  import { fileURLToPath } from 'node:url';
16
18
  import { createClient, ApiError, API_VERSION, DEFAULT_BASE_URL } from '../clients/bongos-client/index.mjs';
17
19
 
18
20
  const require = createRequire(import.meta.url);
19
21
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
22
+ const { sameContent } = require(path.join(ROOT, 'scripts', 'gds', 'gen-freshness.js'));
20
23
 
21
24
  function fakeFetch(recorder, response = { ok: true, status: 200, body: { ok: true } }) {
22
25
  return async (url, init) => {
@@ -149,12 +152,21 @@ test('baseUrl:"" is honored (absolute-path passthrough for the hall fetch wrappe
149
152
  assert.deepEqual(res, { status: 200, ok: true, data: { x: 1 } });
150
153
  });
151
154
 
155
+ // The CI-gate twin of `gen-api-client.js --check`, and it carried the SAME defect
156
+ // (task 1003844): a byte-wise assert.equal against a CRLF working copy failed on
157
+ // every Windows checkout, forever — index.d.ts and README.md have no eol pin, so
158
+ // core.autocrlf hands them back CRLF while the generator renders LF. Compare with
159
+ // sameContent for the same reason gen-freshness.js exists; a real content change
160
+ // still fails, which tests/gen_freshness.mjs pins.
152
161
  test('committed client is fresh (regenerate with gen-api-client.js if this fails)', () => {
153
162
  const gen = require('../scripts/gds/gen-api-client.js');
154
163
  const { files } = gen.build();
155
164
  for (const [name, content] of Object.entries(files)) {
156
165
  const onDisk = fs.readFileSync(path.join(ROOT, 'clients/bongos-client', name), 'utf8');
157
- assert.equal(onDisk, content, `clients/bongos-client/${name} is STALE — run node scripts/gds/gen-api-client.js`);
166
+ assert.ok(
167
+ sameContent(onDisk, content),
168
+ `clients/bongos-client/${name} is STALE — run node scripts/gds/gen-api-client.js`,
169
+ );
158
170
  }
159
171
  });
160
172
 
@@ -245,3 +257,49 @@ test('browser-global build exposes window.BongosClient and shapes requests ident
245
257
  assert.equal(rec.init.headers.Authorization, 'Bearer tok');
246
258
  assert.equal(rec.init.body, JSON.stringify({ a: 1 }));
247
259
  });
260
+
261
+ // --- the --check gate itself (task 1003844) ---------------------------------
262
+ //
263
+ // The assertions above compare the client this checkout HAS, so they can only ever
264
+ // see the eol convention this checkout happens to use. The bug lived in the other
265
+ // direction: a Windows clone whose committed client is already correct was told it
266
+ // was STALE, forever. So drive the real CLI against a throwaway docs root where we
267
+ // control the line endings, and pin BOTH halves — the eol-only case must pass, and
268
+ // a genuine edit must still fail, which is what stops an over-broad "fix" (the same
269
+ // pairing tests/gen_freshness.mjs makes for the helper).
270
+ function runGen(root, args = []) {
271
+ return spawnSync(process.execPath, [path.join(ROOT, 'scripts/gds/gen-api-client.js'), ...args], {
272
+ encoding: 'utf8',
273
+ env: { ...process.env, BONGOS_INSTANCE_ROOT: root },
274
+ });
275
+ }
276
+
277
+ test('--check reads a CRLF working copy as fresh, and a real edit as stale (task 1003844)', () => {
278
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gen-api-client-'));
279
+ try {
280
+ fs.mkdirSync(path.join(root, 'docs', 'api'), { recursive: true });
281
+ fs.copyFileSync(path.join(ROOT, 'docs/api/openapi.json'), path.join(root, 'docs/api/openapi.json'));
282
+
283
+ const wrote = runGen(root);
284
+ assert.equal(wrote.status, 0, `generator failed: ${wrote.stderr}`);
285
+
286
+ // What a core.autocrlf=true checkout hands back for the two files with no eol
287
+ // pin. Nothing about the CONTENT changed, so --check must stay quiet.
288
+ for (const name of ['index.d.ts', 'README.md']) {
289
+ const f = path.join(root, 'clients/bongos-client', name);
290
+ fs.writeFileSync(f, fs.readFileSync(f, 'utf8').replace(/\n/g, '\r\n'));
291
+ }
292
+ const fresh = runGen(root, ['--check']);
293
+ assert.equal(fresh.status, 0, `CRLF must not read as STALE — this is the bug: ${fresh.stderr}`);
294
+ assert.match(fresh.stdout, /is fresh/);
295
+
296
+ // ...and the gate is still a gate.
297
+ const target = path.join(root, 'clients/bongos-client/index.mjs');
298
+ fs.writeFileSync(target, `${fs.readFileSync(target, 'utf8')}\nexport const SMUGGLED = 1;\n`);
299
+ const stale = runGen(root, ['--check']);
300
+ assert.equal(stale.status, 2, 'a real content change must still exit 2');
301
+ assert.match(stale.stderr, /STALE/);
302
+ } finally {
303
+ fs.rmSync(root, { recursive: true, force: true });
304
+ }
305
+ });
@@ -0,0 +1,415 @@
1
+ // tests/update_subscription_engine.mjs
2
+ //
3
+ // task 1003843 — the nightly update lane can drive a CO-TENANT.
4
+ //
5
+ // `.claude/scheduled-tasks/core-update-subscription/subscribe.js` decides, per roster
6
+ // entry, whether the bump goes through go-live.js (backup + timer guard + box-side
7
+ // read-back) or falls back to a bare `bongos upgrade`. That decision reads a second,
8
+ // hand-maintained copy of go-live's own target contract — and a copy drifts:
9
+ //
10
+ // 1. CONTRACT DRIFT. go-live made `deployTimer` optional for co-tenant mode (task
11
+ // 1003521); GO_LIVE_REQUIRED here kept requiring it, so every provisioned
12
+ // co-tenant was told its entry "lacks deployTimer" and silently took the weaker
13
+ // path forever. The first test pins the two lists TOGETHER, at the source, so the
14
+ // next move of go-live's contract fails here instead of degrading a live deploy.
15
+ // 2. A NULL IS NOT AN ABSENT FIELD. The normalizer yields `deployTimer: null`, and
16
+ // that null survives JSON.stringify into --target-json, where go-live's
17
+ // validateTarget runs isSafeToken(null) and rejects the whole target. Dropping
18
+ // the field from GO_LIVE_REQUIRED alone would have traded a silent downgrade for
19
+ // a hard failure, so the target must OMIT the key, not carry a null.
20
+ // 3. THE STALE NORMALIZER, ONE FIELD LATER. rawTopologyFor reads topology straight
21
+ // off disk precisely because the control plane's vendored normalizer drops fields
22
+ // it predates. `pinMode` was the next field it predated.
23
+ // 4. FAIL-CLOSED, BUT NOT OVER-CLOSED. An older go-live.js REJECTS a co-tenant target
24
+ // outright, so the capability probe must refuse it — while an instance on a
25
+ // mid-generation core must KEEP the go-live path it already has for ordinary
26
+ // targets.
27
+ //
28
+ // Every external is injected (readFile, resolveFrom) or written to a temp dir, so this
29
+ // is DB-free, network-free and platform-independent. The source-reading test comes
30
+ // FIRST: it is the half that pins the cross-file contract, and it must run even if a
31
+ // later case trips.
32
+ //
33
+ // Run: node --test tests/update_subscription_engine.mjs
34
+
35
+ import { test } from 'node:test';
36
+ import assert from 'node:assert/strict';
37
+ import { createRequire } from 'node:module';
38
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
39
+ import { tmpdir } from 'node:os';
40
+ import path from 'node:path';
41
+ import { fileURLToPath } from 'node:url';
42
+
43
+ const require = createRequire(import.meta.url);
44
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
45
+ const S = require('../.claude/scheduled-tasks/core-update-subscription/subscribe.js');
46
+
47
+ // A roster entry as loadSubscriptions() normalizes it: absent topology is an explicit
48
+ // null, which is the shape that makes case 2 above possible.
49
+ function entry(over = {}) {
50
+ return {
51
+ slug: 'demo', dir: '/srv/demo', service: 'demo.service',
52
+ healthUrl: 'http://127.0.0.1:3002/healthz', channel: 'patch',
53
+ versionUrl: null, deployTimer: null, deployService: null,
54
+ backupDb: null, backupDir: null, registryPackage: null, env: {},
55
+ ...over,
56
+ };
57
+ }
58
+
59
+ // Stand-ins for the two generations of go-live.js the probe has to tell apart. What
60
+ // matters is only which capability markers the SOURCE carries.
61
+ const GO_LIVE_OLD = 'usage: go-live.js --target-json <json> --to <version>\n';
62
+ const GO_LIVE_NEW = `${GO_LIVE_OLD}const OPTIONAL_FIELDS = ['deployTimer', 'pinMode'];\n`;
63
+ const GO_LIVE_ANCIENT = 'usage: go-live.js --target <name> // ssh only, no local mode\n';
64
+
65
+ function rosterDir(rows) {
66
+ const dir = mkdtempSync(path.join(tmpdir(), 'subscribe-engine-'));
67
+ mkdirSync(path.join(dir, 'config'), { recursive: true });
68
+ writeFileSync(path.join(dir, 'config', 'update-subscriptions.json'), JSON.stringify({ instances: rows }));
69
+ return dir;
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // 1. the cross-file contract — STATIC, reads both sources
74
+ // ---------------------------------------------------------------------------
75
+
76
+ test('GO_LIVE_REQUIRED mirrors go-live.js REQUIRED_FIELDS exactly', () => {
77
+ const src = readFileSync(path.join(ROOT, 'scripts', 'gds', 'go-live.js'), 'utf8');
78
+ const m = src.match(/const REQUIRED_FIELDS = \[([^\]]*)\]/);
79
+ assert.ok(m, 'could not find REQUIRED_FIELDS in scripts/gds/go-live.js');
80
+ const required = m[1].split(',').map((x) => x.trim().replace(/^'|'$/g, '')).filter(Boolean);
81
+
82
+ assert.deepEqual(
83
+ [...S.GO_LIVE_REQUIRED].sort(), [...required].sort(),
84
+ 'subscribe.js keeps its own copy of go-live\'s required fields; they have drifted apart '
85
+ + '— a roster entry go-live would accept is being refused here (or the reverse)',
86
+ );
87
+ // Named outright, so the regression that started this task cannot come back quietly.
88
+ assert.ok(!S.GO_LIVE_REQUIRED.includes('deployTimer'), 'deployTimer is OPTIONAL — requiring it locks out every co-tenant');
89
+ });
90
+
91
+ test('go-live.js still accepts the pinMode the lane now sends', () => {
92
+ const src = readFileSync(path.join(ROOT, 'scripts', 'gds', 'go-live.js'), 'utf8');
93
+ const m = src.match(/const OPTIONAL_FIELDS = \[([^\]]*)\]/);
94
+ assert.ok(m, 'could not find OPTIONAL_FIELDS in scripts/gds/go-live.js');
95
+ // go-live reports UNKNOWN keys as errors rather than ignoring them, so sending a field
96
+ // it does not list is a hard rejection of the whole target, not a no-op.
97
+ assert.ok(m[1].includes("'pinMode'"), 'go-live no longer lists pinMode — the lane would now be sending an unknown field');
98
+ });
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // 2. the target a co-tenant produces
102
+ // ---------------------------------------------------------------------------
103
+
104
+ test('a co-tenant entry maps to a valid target: deployTimer is OMITTED, never null', () => {
105
+ const t = S.goLiveTargetFor(entry(), { versionUrl: 'http://127.0.0.1:3002/version', pinMode: 'local-commit' });
106
+ assert.equal(t.ok, true, `co-tenant entry should map; missing: ${JSON.stringify(t.missing)}`);
107
+ assert.equal('deployTimer' in t.target, false, 'the KEY must be absent — a null reaches go-live as an unsafe token and fails validateTarget');
108
+ // The wire form is what go-live actually sees.
109
+ assert.equal(JSON.parse(JSON.stringify(t.target)).deployTimer, undefined);
110
+ assert.equal(t.target.pinMode, 'local-commit');
111
+ });
112
+
113
+ test('a classic entry still carries its deployTimer', () => {
114
+ const t = S.goLiveTargetFor(entry({ deployTimer: 'demo-deploy.timer' }), { versionUrl: 'http://127.0.0.1:3002/version' });
115
+ assert.equal(t.ok, true);
116
+ assert.equal(t.target.deployTimer, 'demo-deploy.timer');
117
+ assert.equal('pinMode' in t.target, false, 'an entry that names no pinMode must not invent one — go-live defaults it to push');
118
+ });
119
+
120
+ test('a genuinely incomplete entry still reports what is missing', () => {
121
+ const t = S.goLiveTargetFor(entry({ healthUrl: null }), {});
122
+ assert.equal(t.ok, false);
123
+ assert.deepEqual(t.missing, ['healthUrl', 'versionUrl']);
124
+ });
125
+
126
+ test('rawTopologyFor reads pinMode off disk', () => {
127
+ const dir = rosterDir([{ slug: 'demo', dir: '/srv/demo', versionUrl: 'http://x/version', pinMode: 'leave-dirty' }]);
128
+ const raw = S.rawTopologyFor(dir, 'demo');
129
+ assert.equal(raw.pinMode, 'leave-dirty', 'pinMode is dropped before the target is built — the stale-normalizer trap, one field later');
130
+ assert.equal(raw.versionUrl, 'http://x/version');
131
+ });
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // 3. which targets need co-tenant support
135
+ // ---------------------------------------------------------------------------
136
+
137
+ test('needsCoTenantMode: no deployTimer, or any pinMode', () => {
138
+ assert.equal(S.needsCoTenantMode({ deployTimer: 'd.timer' }), false);
139
+ assert.equal(S.needsCoTenantMode({}), true, 'no deployTimer is co-tenant mode');
140
+ assert.equal(S.needsCoTenantMode({ deployTimer: 'd.timer', pinMode: 'local-commit' }), true, 'pinMode is an unknown field to an older go-live');
141
+ assert.equal(S.needsCoTenantMode(null), false);
142
+ });
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // 4. the capability probe — fail-closed, but not over-closed
146
+ // ---------------------------------------------------------------------------
147
+
148
+ const probe = (source, opts) => S.resolveGoLive('/srv/demo', {
149
+ resolveFrom: () => '/srv/demo/node_modules/@bongos/core/scripts/gds/go-live.js',
150
+ readFile: () => source,
151
+ ...opts,
152
+ });
153
+
154
+ test('probe: a co-tenant target refuses a go-live.js without pinMode', () => {
155
+ assert.equal(probe(GO_LIVE_NEW, { needsCoTenant: true }), '/srv/demo/node_modules/@bongos/core/scripts/gds/go-live.js');
156
+ assert.equal(probe(GO_LIVE_OLD, { needsCoTenant: true }), null, 'that go-live would reject the target on a validation error instead of falling back');
157
+ });
158
+
159
+ test('probe: an ORDINARY target still runs on a mid-generation core', () => {
160
+ // The regression to avoid: probing for pinMode unconditionally would strip the
161
+ // go-live path from every instance sitting between the two releases.
162
+ assert.equal(probe(GO_LIVE_OLD, { needsCoTenant: false }), '/srv/demo/node_modules/@bongos/core/scripts/gds/go-live.js');
163
+ assert.equal(probe(GO_LIVE_ANCIENT, { needsCoTenant: false }), null, 'no --target-json: the pre-existing local-mode gate still holds');
164
+ assert.equal(probe(GO_LIVE_ANCIENT, { needsCoTenant: true }), null);
165
+ });
166
+
167
+ test('probe: an unreadable or unresolvable go-live falls back rather than throwing', () => {
168
+ assert.equal(S.resolveGoLive('/srv/demo', { resolveFrom: () => null, readFile: () => GO_LIVE_NEW }), null);
169
+ assert.equal(S.resolveGoLive('/srv/demo', {
170
+ resolveFrom: () => '/srv/demo/go-live.js',
171
+ readFile: () => { throw new Error('EACCES'); },
172
+ }), null);
173
+ });
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // 5. end to end: roster entry → engine
177
+ // ---------------------------------------------------------------------------
178
+
179
+ const engine = (rows, inst, source) => S.goLiveEngineFor(inst, rosterDir(rows), {
180
+ resolveFrom: () => '/srv/demo/node_modules/@bongos/core/scripts/gds/go-live.js',
181
+ readFile: () => source,
182
+ });
183
+
184
+ test('a co-tenant on a current core is driven by go-live, pinMode and all', () => {
185
+ const row = { slug: 'demo', dir: '/srv/demo', versionUrl: 'http://127.0.0.1:3002/version', pinMode: 'local-commit' };
186
+ const e = engine([row], entry(), GO_LIVE_NEW);
187
+ assert.ok(e.script, `should have chosen go-live; why: ${e.why}`);
188
+ assert.equal(e.coTenant, true);
189
+ assert.equal(e.mapped.target.pinMode, 'local-commit', 'the pin must not be pushed into the customer\'s own repo');
190
+ assert.equal('deployTimer' in e.mapped.target, false);
191
+ assert.equal(e.raw.versionUrl, 'http://127.0.0.1:3002/version', 'the fallback branch reads versionUrl off this');
192
+ });
193
+
194
+ test('a co-tenant on an older core falls back, and says why', () => {
195
+ const row = { slug: 'demo', dir: '/srv/demo', versionUrl: 'http://127.0.0.1:3002/version', pinMode: 'local-commit' };
196
+ const e = engine([row], entry(), GO_LIVE_OLD);
197
+ assert.equal(e.script, null);
198
+ assert.equal(e.coTenant, true);
199
+ assert.match(e.why, /co-tenant support/, `the operator has to be able to tell this apart from a malformed roster entry (got: ${e.why})`);
200
+ });
201
+
202
+ test('an entry missing topology falls back naming the fields, not the core', () => {
203
+ const e = engine([{ slug: 'demo', dir: '/srv/demo' }], entry(), GO_LIVE_NEW);
204
+ assert.equal(e.script, null);
205
+ assert.equal(e.coTenant, false);
206
+ assert.match(e.why, /roster entry lacks versionUrl/);
207
+ });
208
+
209
+ test('a classic entry on a current core is unchanged by all of this', () => {
210
+ const row = { slug: 'demo', dir: '/srv/demo', versionUrl: 'http://127.0.0.1:3002/version', deployTimer: 'demo-deploy.timer' };
211
+ const e = engine([row], entry(), GO_LIVE_NEW);
212
+ assert.ok(e.script);
213
+ assert.equal(e.coTenant, false);
214
+ assert.equal(e.mapped.target.deployTimer, 'demo-deploy.timer');
215
+ });
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // 6. the pin-durability net must not fight the target's own pin policy
219
+ //
220
+ // This is the half that makes co-tenant support real rather than nominal. go-live
221
+ // honours pinMode correctly; the LANE then runs its own commit-and-push net over the
222
+ // same repo afterwards, and that net predates pinMode. Under `leave-dirty` it found the
223
+ // deliberately-dirty pin, committed it and PUSHED it — into the customer's own remote,
224
+ // which is the single thing pinMode exists to prevent.
225
+ // ---------------------------------------------------------------------------
226
+
227
+ // A fake git that records every invocation and reports one dirty pin file.
228
+ function fakeGit({ dirty = ['package.json'] } = {}) {
229
+ const calls = [];
230
+ const run = (_bin, args) => {
231
+ calls.push(args.slice(2)); // drop the leading -C <dir>
232
+ const sub = args[2];
233
+ if (sub === 'status') return { status: 0, stdout: dirty.map((f) => ` M ${f}`).join('\n') };
234
+ if (sub === 'rev-parse') return { status: 0, stdout: 'main\n' };
235
+ return { status: 0, stdout: '' };
236
+ };
237
+ return { run, calls, ran: (verb) => calls.some((c) => c[0] === verb) };
238
+ }
239
+
240
+ test('pinModeOf defaults to push, exactly as go-live does', () => {
241
+ assert.deepEqual(S.pinModeOf({}), { mode: 'push', valid: true });
242
+ assert.deepEqual(S.pinModeOf(null), { mode: 'push', valid: true });
243
+ assert.deepEqual(S.pinModeOf({ pinMode: 'local-commit' }), { mode: 'local-commit', valid: true });
244
+ assert.deepEqual(S.pinModeOf({ pinMode: 'leave-dirty' }), { mode: 'leave-dirty', valid: true });
245
+ });
246
+
247
+ test('pinModeOf REFUSES an unknown mode instead of defaulting', () => {
248
+ // Defaulting would send a typo to the push branch, and on a co-tenant that push lands in the
249
+ // customer's own repo. The safe-looking default is the dangerous one here.
250
+ assert.deepEqual(S.pinModeOf({ pinMode: 'local_commit' }), { mode: 'local_commit', valid: false });
251
+ assert.deepEqual(S.pinModeOf({ pinMode: 'leavedirty' }), { mode: 'leavedirty', valid: false });
252
+ assert.deepEqual(S.PIN_MODES, ['push', 'local-commit', 'leave-dirty']);
253
+ });
254
+
255
+ test('PIN_MODES mirrors go-live.js PIN_MODES exactly', () => {
256
+ const src = readFileSync(path.join(ROOT, 'scripts', 'gds', 'go-live.js'), 'utf8');
257
+ const m = src.match(/const PIN_MODES = \[([^\]]*)\]/);
258
+ assert.ok(m, 'could not find PIN_MODES in scripts/gds/go-live.js');
259
+ const theirs = m[1].split(',').map((x) => x.trim().replace(/^'|'$/g, '')).filter(Boolean);
260
+ assert.deepEqual([...S.PIN_MODES].sort(), [...theirs].sort(),
261
+ 'the lane validates pinMode against its own copy of go-live\'s enum; they have drifted');
262
+ });
263
+
264
+ test('commitPin pushes by default — the platform instance still needs that', () => {
265
+ const g = fakeGit();
266
+ const r = S.commitPin({ dir: '/srv/demo', message: 'pin' }, g.run, () => {});
267
+ assert.equal(r.ok, true);
268
+ assert.equal(r.action, 'committed');
269
+ assert.equal(g.ran('push'), true, 'an unpushed pin on the platform instance is reverted by the next pull-deploy reset');
270
+ });
271
+
272
+ test('commitPin with push:false commits and STOPS', () => {
273
+ const g = fakeGit();
274
+ const r = S.commitPin({ dir: '/srv/demo', message: 'pin', push: false }, g.run, () => {});
275
+ assert.equal(r.ok, true);
276
+ assert.equal(r.action, 'committed-local');
277
+ assert.equal(g.ran('commit'), true, 'the pin must still be made durable locally');
278
+ assert.equal(g.ran('push'), false, 'the remote is the CUSTOMER\'s repo — pushing there is what pinMode forbids');
279
+ });
280
+
281
+ test('commitPin still refuses a worktree carrying non-pin changes', () => {
282
+ const g = fakeGit({ dirty: ['package.json', 'src/thing.js'] });
283
+ const r = S.commitPin({ dir: '/srv/demo', message: 'pin', push: false }, g.run, () => {});
284
+ assert.equal(r.ok, false);
285
+ assert.equal(r.action, 'refused');
286
+ assert.equal(g.ran('commit'), false, 'a human\'s uncommitted work must stop the lane regardless of pin mode');
287
+ });
288
+
289
+ // ---------------------------------------------------------------------------
290
+ // 7. the HEAL step — the third place the pin gets written
291
+ //
292
+ // Found by the grader on round 1, and it is the worst of the three because it
293
+ // fires on the HAPPY path. `leave-dirty`'s steady state after a successful
294
+ // upgrade IS a dirty pin, so the next scheduled sweep's heal-first step saw it,
295
+ // called it "a leftover from an earlier sweep", and committed + pushed it into
296
+ // the customer's own repo — reliably, every sweep, not just after a crash.
297
+ //
298
+ // These cases drive commitPin the way main()'s heal branch does, since the heal
299
+ // itself is inline in main() and not separately callable.
300
+ // ---------------------------------------------------------------------------
301
+
302
+ test('heal under local-commit commits WITHOUT pushing', () => {
303
+ const g = fakeGit();
304
+ const r = S.commitPin({ dir: '/srv/demo', message: 'leftover pin', push: false }, g.run, () => {});
305
+ assert.equal(r.ok, true);
306
+ assert.equal(r.action, 'committed-local');
307
+ assert.equal(g.ran('push'), false);
308
+ });
309
+
310
+ test('heal counts a local commit as healed, not as a no-op', () => {
311
+ // main() increments `healed` on 'committed' OR 'committed-local'; matching only the former
312
+ // would under-report every co-tenant heal as if nothing happened.
313
+ const g = fakeGit();
314
+ const r = S.commitPin({ dir: '/srv/demo', message: 'leftover pin', push: false }, g.run, () => {});
315
+ assert.ok(['committed', 'committed-local'].includes(r.action));
316
+ });
317
+
318
+ test('the heal branch is reachable only when the dirty files ARE the pin', () => {
319
+ // Unchanged by this work, re-pinned because the heal now has a third path through it.
320
+ const g = fakeGit({ dirty: ['package.json', 'docs/thing.md'] });
321
+ const r = S.commitPin({ dir: '/srv/demo', message: 'leftover pin', push: true }, g.run, () => {});
322
+ assert.equal(r.ok, false);
323
+ assert.equal(r.action, 'refused');
324
+ assert.equal(g.ran('commit'), false);
325
+ });
326
+
327
+ // ---------------------------------------------------------------------------
328
+ // 8. the whole file, read as policy
329
+ //
330
+ // The three pin-writing sites are spread across ~250 lines of main(), and the
331
+ // bug each time was a site that did not ask what mode it was in. A source-level
332
+ // assertion is the only thing that sees all three at once.
333
+ // ---------------------------------------------------------------------------
334
+
335
+ test('every commitPin call site in the lane decides about push', () => {
336
+ const src = readFileSync(path.join(ROOT, '.claude', 'scheduled-tasks', 'core-update-subscription', 'subscribe.js'), 'utf8');
337
+ // Every CALL, excluding the definition (which is `function commitPin({ ... push = true })` —
338
+ // its default is what these sites must not silently inherit).
339
+ const calls = [];
340
+ const re = /commitPin\(\{/g;
341
+ let m;
342
+ while ((m = re.exec(src)) !== null) {
343
+ if (src.slice(Math.max(0, m.index - 9), m.index) === 'function ') continue;
344
+ // Brace-depth, not indexOf('}') — every message here carries a `${...}` whose own brace
345
+ // would otherwise end the slice before `push:` is ever reached.
346
+ let depth = 0, end = m.index;
347
+ for (let i = src.indexOf('{', m.index); i < src.length; i++) {
348
+ if (src[i] === '{') depth++;
349
+ else if (src[i] === '}' && --depth === 0) { end = i + 1; break; }
350
+ }
351
+ calls.push(src.slice(m.index, end));
352
+ }
353
+ assert.ok(calls.length >= 3, `expected the three known pin-writing call sites, found ${calls.length}`);
354
+ for (const c of calls) {
355
+ assert.match(c, /push:/, `a commitPin call site does not decide about push, so it inherits the default and pushes:\n${c}`);
356
+ }
357
+ });
358
+
359
+ test('the direct-upgrade fallback withholds --commit-pin unless the mode is push', () => {
360
+ const src = readFileSync(path.join(ROOT, '.claude', 'scheduled-tasks', 'core-update-subscription', 'subscribe.js'), 'utf8');
361
+ // --commit-pin makes upgrade.js commit AND push, so the fallback has to gate it too — the
362
+ // policy must not depend on which engine the instance's core happened to support.
363
+ assert.match(src, /if \(canCommitPin && pinMode === 'push'\) upArgs\.push\('--commit-pin'\)/,
364
+ 'the fallback path would push a co-tenant pin through upgrade.js instead of the lane');
365
+ });
366
+
367
+ // ---------------------------------------------------------------------------
368
+ // 9. healDecision — the grader's blocker, now reachable by a test
369
+ //
370
+ // This is the scenario that shipped past a green suite on round 1: under
371
+ // `leave-dirty` the steady state after a successful upgrade IS a dirty pin, so
372
+ // the NEXT sweep's heal step called it a leftover and pushed it to the
373
+ // customer's own remote. On the happy path. Every sweep.
374
+ // ---------------------------------------------------------------------------
375
+
376
+ const dirtyPin = { repo: true, clean: false, pin: [{ file: 'package.json' }], other: [] };
377
+ const dirtyHuman = { repo: true, clean: false, pin: [], other: [{ file: 'src/thing.js' }] };
378
+ const cleanTree = { repo: true, clean: true, pin: [], other: [] };
379
+
380
+ test('healDecision: leave-dirty is BY DESIGN, never a leftover', () => {
381
+ assert.deepEqual(S.healDecision(dirtyPin, 'leave-dirty'), { action: 'by-design' });
382
+ });
383
+
384
+ test('healDecision: local-commit heals WITHOUT pushing', () => {
385
+ assert.deepEqual(S.healDecision(dirtyPin, 'local-commit'), { action: 'commit', push: false });
386
+ });
387
+
388
+ test('healDecision: the platform default still commits and pushes', () => {
389
+ assert.deepEqual(S.healDecision(dirtyPin, 'push'), { action: 'commit', push: true });
390
+ });
391
+
392
+ test('healDecision: non-pin changes halt in EVERY mode', () => {
393
+ // A human's uncommitted work stops the lane whatever the pin policy is — checked in all three
394
+ // because 'by-design' is an early return and could easily be placed ahead of this.
395
+ for (const mode of ['push', 'local-commit', 'leave-dirty']) {
396
+ assert.deepEqual(S.healDecision(dirtyHuman, mode), { action: 'halt' }, `mode ${mode} must still halt`);
397
+ }
398
+ });
399
+
400
+ test('healDecision: a clean tree or a non-repo does nothing', () => {
401
+ assert.deepEqual(S.healDecision(cleanTree, 'push'), { action: 'nothing' });
402
+ assert.deepEqual(S.healDecision({ repo: false }, 'push'), { action: 'nothing' });
403
+ assert.deepEqual(S.healDecision(null, 'push'), { action: 'nothing' });
404
+ });
405
+
406
+ test('both commitPin successes report committed:true', () => {
407
+ // main() counts a heal off this ONE flag. Matching on action strings instead silently
408
+ // under-reported every co-tenant heal as if nothing had happened.
409
+ const a = S.commitPin({ dir: '/srv/demo', message: 'm' }, fakeGit().run, () => {});
410
+ const b = S.commitPin({ dir: '/srv/demo', message: 'm', push: false }, fakeGit().run, () => {});
411
+ assert.equal(a.committed, true);
412
+ assert.equal(b.committed, true);
413
+ assert.equal(S.commitPin({ dir: '/srv/demo', message: 'm' }, fakeGit({ dirty: [] }).run, () => {}).committed, undefined,
414
+ 'an already-clean tree committed nothing and must not be counted as a heal');
415
+ });