@bongos/core 1.19.70 → 1.19.72

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.70",
6
- "core_contract": "1.19.70",
7
- "source_commit": "b92ae4a4d4458ff81409d2aff12bfc88bd0ef50d",
5
+ "core_version": "1.19.72",
6
+ "core_contract": "1.19.72",
7
+ "source_commit": "36150e839bd63b4544b7828ad26137d2b2501a07",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-12T15:26:39.115Z",
9
+ "built_at": "2026-08-12T16:31:58.158Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 293,
12
+ "docs_redacted": 294,
13
13
  "agent_docs_stubbed": 21,
14
- "functional_verbatim": 1223,
14
+ "functional_verbatim": 1224,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1537,
20
- "tree_sha256": "56c1de962c6965257fb63c72e0201b2a0652833acf9ade499ed57c96e9650adb",
19
+ "file_count": 1539,
20
+ "tree_sha256": "dc8a5fa6b9f2b5d49dec5763477b0793d156abea3894c575d3989591a6b4ad1c",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -1309,10 +1309,15 @@
1309
1309
  "mode": "0000644",
1310
1310
  "sha256": "bb187a29953ba5c316977f09fe86e26ca200693b23cf5c41ece93e9dd24f337e"
1311
1311
  },
1312
+ {
1313
+ "path": "docs/adr/0168-scaffold-packs-installed-core.md",
1314
+ "mode": "0000644",
1315
+ "sha256": "8bdad4b4333cbb7b38f66e4dddab08675dfc0a431d21c0be6a31ca4afd8c498f"
1316
+ },
1312
1317
  {
1313
1318
  "path": "docs/adr/README.md",
1314
1319
  "mode": "0000644",
1315
- "sha256": "b0387cdc43a67c4b9834691155d1fa9454930cb70b0660bb55a0f7e819c7ec90"
1320
+ "sha256": "a96302a9849a2d7225b4a47ce0d7a7ac5f66d6849e80781f9a8e5aa32eba51d4"
1316
1321
  },
1317
1322
  {
1318
1323
  "path": "docs/api-reference.md",
@@ -1562,7 +1567,7 @@
1562
1567
  {
1563
1568
  "path": "docs/recipes/ops-gotchas.md",
1564
1569
  "mode": "0000644",
1565
- "sha256": "0256bd5d59f4a34886efbcabf3f12c520a0c2092e2035b5c969e4b1769c99604"
1570
+ "sha256": "ddbb6d4e0569a6773b648a6fe97abb2e2e04c6e36a5f3100af0cd995688a4810"
1566
1571
  },
1567
1572
  {
1568
1573
  "path": "docs/recipes/overnight-builder.md",
@@ -1612,7 +1617,7 @@
1612
1617
  {
1613
1618
  "path": "docs/recipes/upgrading-the-core.md",
1614
1619
  "mode": "0000644",
1615
- "sha256": "dcf0520928832e4a8e723c830a5d8f5e1ae1a0fcef16ff21345aabf715e142f6"
1620
+ "sha256": "732f3b01b7d2e6cc238dd5f48d5ed774f6c2c27ecc737710047758bf85f2d9e5"
1616
1621
  },
1617
1622
  {
1618
1623
  "path": "docs/recipes/work-on-a-task-from-your-dev-box.md",
@@ -4047,12 +4052,12 @@
4047
4052
  {
4048
4053
  "path": "package-lock.json",
4049
4054
  "mode": "0000644",
4050
- "sha256": "68b3bc7974e4f90d5291ba89dc3451d1cde4db4cff174ba3ed1347e4599c77da"
4055
+ "sha256": "183fa8f7d1c63c5188516529049a52a88cae64c235b8350f9362ea8e6175f886"
4051
4056
  },
4052
4057
  {
4053
4058
  "path": "package.json",
4054
4059
  "mode": "0000644",
4055
- "sha256": "505474194374951dfc13ffca64f10c469da2782e1756214c3efb3a60b8151b68"
4060
+ "sha256": "1293840e72b03f675e2bc9c30a42f4885f4d54a56674fd605260a0f5549bd8b5"
4056
4061
  },
4057
4062
  {
4058
4063
  "path": "public-docs/index.html",
@@ -5027,7 +5032,7 @@
5027
5032
  {
5028
5033
  "path": "scripts/gds/provision.js",
5029
5034
  "mode": "0000644",
5030
- "sha256": "e4108117dbe8dd6493ba41ed053fe8648424ca127b1c26505aa6f6702b2dd94c"
5035
+ "sha256": "7ad384495370312a57f7c1f0dab4770888c83afd08b89a8dc27aebae408abfd1"
5031
5036
  },
5032
5037
  {
5033
5038
  "path": "scripts/gds/publish-manifest.js",
@@ -5312,7 +5317,7 @@
5312
5317
  {
5313
5318
  "path": "scripts/gds/upgrade.js",
5314
5319
  "mode": "0000644",
5315
- "sha256": "4ba3cd406e82907ae554e90abb1c746ff7c9802c9fbed0c1eac241798860d0c6"
5320
+ "sha256": "b539715de4893f8fa68c53c145ac2f54de39ccc5517f9e5ad17540c487dc24b0"
5316
5321
  },
5317
5322
  {
5318
5323
  "path": "scripts/gds/validate-design.js",
@@ -5677,7 +5682,7 @@
5677
5682
  {
5678
5683
  "path": "src/module-api.js",
5679
5684
  "mode": "0000644",
5680
- "sha256": "993ad9f8c4be17c840767dff8c4e092a2ea55c5cbe73a09a53371e31cccfa720"
5685
+ "sha256": "b5c45d792faf401db67d81fa567c437b25e2ba9ef521e2f9e8cb052f474b0098"
5681
5686
  },
5682
5687
  {
5683
5688
  "path": "src/module-loader/loader.js",
@@ -7162,7 +7167,7 @@
7162
7167
  {
7163
7168
  "path": "tests/provision.mjs",
7164
7169
  "mode": "0000644",
7165
- "sha256": "bd8e111c2d5614f79086c5f8298cec6ffdcb7ab57b94565457520d85df0c24a8"
7170
+ "sha256": "edb6c5da9ec32536d50f7cb648b81b026b7926f73f891feb0906fc7343b5b11d"
7166
7171
  },
7167
7172
  {
7168
7173
  "path": "tests/provisioning_domain_attach.mjs",
@@ -7657,7 +7662,12 @@
7657
7662
  {
7658
7663
  "path": "tests/upgrade.mjs",
7659
7664
  "mode": "0000644",
7660
- "sha256": "3d7aaec00a43c303e0bb6e465f48fd8d6523844cd2414c2979ad6144424d2ebb"
7665
+ "sha256": "60e55591174f59238486d318fb92dc8fd8251eb73435d690e38ea83a2a5f235e"
7666
+ },
7667
+ {
7668
+ "path": "tests/upgrade_persist_pin.mjs",
7669
+ "mode": "0000644",
7670
+ "sha256": "07fd48b255bbd2370580b62a8f77d7fc981b0dfdefcc5be6973a9c4d6cb49add"
7661
7671
  },
7662
7672
  {
7663
7673
  "path": "tests/verify_task_guard.mjs",
@@ -0,0 +1,34 @@
1
+ # 0168 — The scaffold vendors the hub's running core (npm pack of the installed package); registry-default deferred to the go-public step
2
+
3
+ - **Status:** Accepted — built in task 1002706.
4
+ - **Date:** 2026-08-12
5
+ - **Deciders:** jaxri (Archon) — autonomous session; the go-public trigger stays the owner's (idea 1000700, ADR 0134).
6
+ - **Task:** task 1002706 · **Goal:** 1000059 · gap 13 of [`docs/audits/project-creation-gap-list.md`](../audits/project-creation-gap-list.md) · ties: task 1002621, task 1002275, idea 1000671.
7
+
8
+ ## Context
9
+
10
+ Two facts collided at the scaffold's core-install step:
11
+
12
+ 1. **New projects were born frozen-stale.** A fresh scaffold pinned `file:vendor/bongos-core-1.19.2.tgz` — ~60 patches behind — because `installedCoreTarball()` (task 1002275) reuses whatever tarball the control plane's own `file:` pin names, and the mothership's pin had sat at 1.19.2. ADR 0136 channels can't move a `file:` pin, so children had no upgrade path.
13
+ 2. **The registry cutover quietly broke the fallback.** Task 1002621 moved cloudbongos.com's own pin to the registry (`"@bongos/core": "1.19.56"` — verified on the box 2026-08-12). With no `file:` pin, `installedCoreTarball()` returns null and the scaffold falls into the `package-core.js` source rebuild — which **aborts on a consumer instance** (REPO_ROOT inside gitignored `node_modules` → empty core set → the child repo is left empty, the exact task-1002275 failure resurrected). The next greenfield scaffold on the box would have failed outright.
14
+
15
+ The task brief proposed "make registry the scaffold default now that `@cloudbongos/core` is public (verify unauthenticated `npm view` works)". **That verification fails**: both `@cloudbongos/core` and `@bongos/core` 404 anonymously on `registry.npmjs.org` (2026-08-12) — they are private scoped packages (ADR 0134), public only at the launch go-public step. A registry-default scaffold today would hand every new owner a caret pin + env-fed `.npmrc` they cannot resolve without a paid-org token — a project **born uninstallable**, strictly worse than born-stale.
16
+
17
+ ## Decision
18
+
19
+ **Three-rung core-resolution ladder** in the standalone scaffold (freshest-correct first), replacing the two-rung one:
20
+
21
+ 1. `file:`-pinned control plane → reuse its pinned tarball (task 1002275, unchanged).
22
+ 2. **NEW — registry-pinned consumer control plane → `npm pack` the core it has installed** (`installedCorePackDir()` finds `node_modules/@bongos/core`, the scaffold packs it to a temp dir and `--vendor-core`s the result). The child pins the **same, current core the hub runs** — 1.19.56 today, not 1.19.2 — and stays `file:`-pinned, so its owner needs no npm token.
23
+ 3. Genuine source checkout → `package-core.js` rebuild (unchanged).
24
+
25
+ The opt-in registry path (`PROVISION_CORE_FROM_REGISTRY=1` + `NPM_TOKEN`, fail-soft) is untouched. **Registry as the scaffold default is deferred to the ADR 0134 go-public step** — the same owner decision idea 1000700 tracks (public repo / public npm / wait for the ADR 0099 mirror). When the package goes public, flipping the default becomes a small follow-up: drop the token gate, keep fail-soft.
26
+
27
+ **Ops leg (gap 13's "flip the box env"):** nothing to flip now — setting `PROVISION_CORE_FROM_REGISTRY=1` on the box today would birth token-locked children (the private-pin trap above). Child freshness rides two things instead: this ladder (children inherit the hub's core at birth) and the hub's own pin staying current (task 1002708 — the upgrade lane must commit + push the pin).
28
+
29
+ ## Consequences
30
+
31
+ - Born-stale collapses from "frozen at 1.19.2 forever" to "as fresh as the hub" — and the hub's freshness is an explicitly tracked lane (task 1002708).
32
+ - The post-cutover empty-repo regression is closed before it fired in production.
33
+ - `npm pack` naming (`<scope>-<name>-<version>.tgz`) is derived from the installed package's own `name`, so both scope aliases work.
34
+ - Rejected: registry-default now (verified-dead premise; uninstallable children), dropping `NPM_TOKEN` (the package is private — tokenless `npm ci` fails closed by design, ADR 0134), vendoring from the npm cacache (undocumented layout; `npm pack` is the supported path).
@@ -239,6 +239,7 @@ This keeps the decision history honest and traceable.
239
239
  | 0164 | [**The implausible-pass guard reads substance + evidence; the Metic+ honor-system residual is explicit** ([task 1002658](https://cloudbongos.com/builders#/task/1002658); grader-hardening goal 1000056). The guard fired only on `issues.length===0` — one fabricated `{}` defeated it, its magnitude inputs are client-supplied, and every in-sample firing (eval 2-for-2, tasks 1002643 ×2, 1002663) was a false positive on an honest unanimous zero-issue panel PASS. Redesign: count only SUBSTANTIVE issues (known severity + non-empty summary — padding trips the guard regardless), and waive the zero-issue pass when `signals.workers` carries the full &ge;3-worker panel shape, stamped `implausible_pass_waived` for /grade-audit. The residual is named, not hidden: every grade-time input is client-authored, so plausibility at grade time is honest-mistake hygiene; the binding authorities on the real diff are land-time (CI unit, gate-review, main audit) — Metic+ grade-time honesty is accepted honor-system per this ADR. The ADR-0042 CI grade-gate (land it vs record permanent deferral) goes to the owner as a blocker, not decided here. No approval step added or removed (ADR 0162).](<redacted>.md) | GDS / grading |
240
240
  | 0165 | [**Instances are hookless by default: no scaffolded `.husky/`; local hooks stay an instance-owned opt-in** ([task 1002641](https://cloudbongos.com/builders#/task/1002641); idea 1000655). Task 1002608 delivered the ADR 0043 Layer 1 pre-push gate for the CORE repo, but nothing materializes `.husky/` on a scaffolded standalone instance (`publish-manifest.js` excludes it by design). Decision: that is the accepted posture, not a gap. Sub-Metic instance builders hold no push credential at all (server-mediated publish, ADR 0053/0055) — a client pre-push hook guards a push that cannot happen; a client hook is not a security boundary (ADR 0016); the core cannot maintain hooks it deliberately does not ship (scaffolded hooks would be born orphans — the audit-authorship rot lesson, task 1002640); and ADR 0162 puts gates in CI, rank-consistent and hands-free. Secrets hooks follow the same rule (the PR-time CI secret scan enforces). The opt-in stays real and instance-owned: an instance commits its own `.husky/` and the core's `install-git-hooks.js` (already run by `/builder-setup`) activates it. Rejected: scaffolding wrappers at init, an `--with-hooks` flag (speculative), treating hooklessness as a regression. Revisit if an instance model has builders pushing directly with their own credentials.](<redacted>.md) | provisioning / trust boundary |
241
241
  | 0166 | [**Module maintenance & deprecation policy: upkeep is a declared `maintenance` block, and `bongos upgrade` surfaces rot in DISABLED modules too** ([task 1001773](https://cloudbongos.com/builders#/task/1001773)). Implements [ADR 0107](<redacted>.md) §7, which named the catalog-rot problem and deferred the mechanics. `module.json` gains an optional-in-schema, required-to-upstream `maintenance` block — `status` one of `core-maintained` / `maintained` / `deprecated` / `orphaned`, plus `since` / `removeAfter` / `successor` / `note`. Each status owes something the validator enforces: `maintained` requires a named top-level `maintainer` (an unnamed owner IS the orphaning failure), a sunset status requires a deprecation path, and `removeAfter` is rejected on a non-sunset status. `orphaned` is deliberately first-class — a policy that only permits happy statuses gets satisfied by lying. The load-bearing fix: accepted modules ship `default:false` (§6), so every rotted catalog entry is DISABLED by construction — and `preflightModules()` returned only `blocking`, discarding `warnings`, so a real `bongos upgrade --to X` reported `"all enabled modules satisfy the target"` while saying nothing about them. It now returns + prints `warnings`/`deprecations`/`expired` with the maintainer NAMED, before the blocking check returns (so a refused bump reports them too). Enabled mismatches still refuse; disabled ones still never block — report, don't block, because blocking an instance's bump on a catalog entry it never opted into makes the shared catalog a liability. `bongos module check`/`submit` refuse an undeclared posture or an already-sunset module; `GET /modules` + the hall Modules tab show an `upkeep:` chip (incl. `undeclared`); all 17 bundled modules stamped `core-maintained` with a unit test holding the line. Rejected: blocking on disabled rot, a required manifest field (breaks every existing manifest), DB-tracked upkeep (must travel with the module, ADR 0107 §2), auto-deprecating on incompatibility (a fact is not a decision), a separate MAINTAINERS file.](<redacted>.md) | modules / upstreaming |
242
+ | 0168 | [**The scaffold vendors the hub's RUNNING core (`npm pack` of the installed package); registry-default deferred to the go-public step** ([task 1002706](https://cloudbongos.com/builders#/task/1002706); gap 13 of the project-creation audit). Two collided facts: children were born pinned to a frozen `file:vendor/bongos-core-1.19.2.tgz` (installedCoreTarball reuses the hub's own file: pin — task 1002275 — and the hub's pin sat at 1.19.2), and the task-1002621 registry cutover (hub pin now `"@bongos/core": "1.19.56"`, verified on the box) made installedCoreTarball return null — dropping the scaffold into the package-core.js source rebuild that ABORTS on a consumer instance (empty core set → empty child repo, the 1002275 failure resurrected). Fix: a three-rung ladder — file:-pin reuse → NEW `installedCorePackDir()` + `npm pack` of the hub's installed core (child pins the SAME, CURRENT core, stays file:-pinned, owner needs no token) → source rebuild. The brief's "make registry the default now that the package is public" was verified FALSE (both npm scopes 404 anonymously; ADR 0134 keeps them private until go-public) — a registry-default child would be born UNINSTALLABLE for a tokenless owner. Registry-default is deferred to the go-public step (idea 1000700); the gap-13 "flip the box env" ops leg is explicitly a no-op until then; child freshness rides this ladder + the hub-pin lane (task 1002708). Rejected: registry-default now, dropping NPM_TOKEN, vendoring from the npm cacache.](<redacted>.md) | provisioning / distribution |
242
243
  | 0167 | [**The /create gate trust link defaults to the AGPL-3.0 license text, not the npm package page** ([task 1002705](https://cloudbongos.com/builders#/task/1002705); gap 1 of the project-creation audit). The gate's "open source" link pointed at a nonexistent repo (404); the task brief's suggested default — the npm package page — is ALSO dead for the gate's actual audience (signed-out visitors): `@cloudbongos/core` and `@bongos/core` both 404 anonymously (private scopes, ADR 0134), and the ADR 0099 public mirror stays dormant until ~2026-11. The license text is the one live, instance-neutral artifact behind the claim today. The browsable-code upgrade is the owner's filed choice (idea 1000700): public repo now (forfeits the ADR 0065 §4 redaction window) / public npm package / wait for the mirror. Rejected: the npm link (verified dead), flipping the repo public (not a builder's call), plain unlinked text (less information, same cost).](<redacted>.md) | public landing / governance |
243
244
 
244
245
  > ⚠️ **Numbering collision (known):** two ADRs share `0008`. The numbers were assigned in parallel sessions before the lock could be enforced. The files are kept under their existing filenames to preserve git history, but the table disambiguates them as `0008-a` / `0008-b`. New ADRs should resume at `0040` to avoid further collisions. (`0024` is also a real on-disk duplicate: the cloneable-repo/local-first-memory ADR and the multi-agent-system-architecture ADR were both numbered `0024` in parallel; both keep their filenames, disambiguated as `0024-a` / `0024-b` like `0008`. Likewise `0025` ended up with two on-disk files — the offsite-backup ADR and the structured-criterion↔task-link ADR — both kept under their existing filenames and disambiguated as `0025-a` / `0025-b`; separately, the BFG ADR had also claimed `0025` on 2026-05-30 and yielded to `0026`. A third happened 2026-06-06: `0036` was claimed in parallel by the builders-subdomain ADR ([#726](https://example.com/builders#/task/726)) and this Discord-channels ADR ([#727](https://example.com/builders#/task/727)); the Discord-channels ADR yielded to `0037`. A fourth landed 2026-06-10: `0044` was claimed in parallel by the palette-replacement ADR and the per-box game-preview ADR ([#897](https://example.com/builders#/task/897)); both keep their filenames, disambiguated as `0044-a` / `0044-b` like `0008`. A fifth was caught pre-land 2026-06-13: `0050` was claimed in parallel by the device-flow-admission ADR ([#1000](https://example.com/builders#/task/1000)) and the sandbox-for-everyone ADR ([#1001](https://example.com/builders#/task/1001)); the latter yielded to `0052` before merging, so there is no `0050` duplicate. A sixth was caught pre-land 2026-06-21: the tool-agnostic-design-layer ADR ([#1375](https://example.com/builders#/task/1375)) first collided at `0079` with the value-gated-autonomous-engagement ADR, then at `0080` with the llm-cache-api-transport ADR (both part of the 0075–0080 LLM-cost-reduction series, landed on `main` in parallel); it yielded to `0081` before merging, so there is no `0079`/`0080` design-layer duplicate. A seventh landed 2026-06-23: `0084` was claimed in parallel by the auto-derived-per-task-rank-floor ADR and the remove-default-spend-cap ADR (task 1499); the rank-floor ADR landed on `main` first, so the spend-cap ADR yielded to `0085`, leaving no `0084` duplicate. An eighth landed 2026-06-27: `0095` was claimed in parallel by the borrowed-memory-and-retrieval-concepts ADR (PR #459, landed first) and the cross-agent-context-management ADR ([#1651](https://example.com/builders#/task/1651), PR #460); both keep their filenames, disambiguated as `0095-a` / `0095-b` like `0008`. These two are companions — `0095-a` adopts the memory/retrieval concepts and explicitly defers the grep-vs-semantic question to `0095-b`.)
@@ -225,6 +225,16 @@ Organized by topic, not chronology. New entries go in the matching section; crea
225
225
  - **Fix:** `otb-box-launch.sh` now asserts `defaultMode=bypassPermissions` itself, synchronously, right before it creates a **new** `otb` tmux session — so a fresh session never depends on cron timing. This prevents the race going forward but does **not** repair a box whose `otb` session is already running in the wrong mode: for that, from an SSH/browser-terminal shell run `tmux kill-session -t otb` then reconnect (the launcher creates a fresh session and picks up the corrected setting immediately). Killing the session only drops the terminal pane — it does not touch `/workspace` or in-flight git state.
226
226
  - **Why it bites:** every layer *looks* fixed — `~/.claude/settings.json` has the right value, `IS_SANDBOX=1` is set — because you're inspecting the box's filesystem, not the already-running process that never re-read it.
227
227
 
228
+ ## New-project scaffolds & the core pin (gap 13 · task 1002706 · ADR 0168)
229
+
230
+ ### Do NOT set `PROVISION_CORE_FROM_REGISTRY=1` on the box until the core package goes public
231
+
232
+ - **Symptom (why you'd be tempted):** the project-creation audit (gap 13) found new projects born on a frozen `file:vendor/bongos-core-1.19.2.tgz`, and the registry-install mode looks like the fix.
233
+ - **Root cause:** `@bongos/core` (and the old `@cloudbongos/core` scope) is a **private** npm package until the ADR 0134 go-public step — both 404 anonymously (verified 2026-08-12). A registry-scaffolded child gets a caret pin + env-fed `.npmrc` that **its owner cannot resolve without a paid-org `NPM_TOKEN`** — a project born uninstallable, strictly worse than born-stale.
234
+ - **Fix (shipped, task 1002706):** nothing to flip. The scaffold now inherits the hub's **running** core automatically: a `file:`-pinned control plane reuses its pinned tarball (task 1002275), a **registry-pinned** control plane (the post-1002621 mothership) `npm pack`s its installed `node_modules/@bongos/core`, and only a true source checkout rebuilds via `package-core.js`. Children stay `file:`-pinned and current-at-birth.
235
+ - **When the package goes public** (idea 1000700 / ADR 0134): flipping registry-default becomes a small follow-up — set `PROVISION_CORE_FROM_REGISTRY=1` in the runner env (token no longer required for public installs; the fail-soft vendor path stays).
236
+ - **Housekeeping:** the mothership's `vendor/` still holds a pile of stale `bongos-core-1.18.x` tarballs no pin references — safe to prune on the next box pass. Child freshness depends on the hub's own pin staying current — that lane is task 1002708 (the upgrade must commit + push the pin, never npm-install in place).
237
+
228
238
  ## When to add a new entry here
229
239
 
230
240
  Anytime you spend more than ~10 minutes diagnosing something that turned out to have a non-obvious cause, add a section. Include symptom, root cause, fix, and why-it-bites. The cost of a 5-minute write-up is much less than the cost of the next session re-discovering the same thing.
@@ -52,6 +52,10 @@ The instance runs on the droplet (co-tenant): systemd `emersonian-circles.servic
52
52
  ```
53
53
  5. **Verify** — `systemctl is-active`, local + public `/healthz` 200, `/version`, `installed core == <version>`, the `core_upgrades` row.
54
54
  6. **Update the repo of record** — run the channel on the laptop clone (`--skip-migrate`, no DB there), commit the pin bump (drop the superseded tarball), `git push origin`.
55
+
56
+ > **The pin MUST reach the repo of record, or the bump silently un-does itself** (task 1002712). A pull-deploy instance resets to `origin/main`; a pin that lives only in the working tree is reverted the next time anything lands, and the lockfile change then trips `npm ci`, which reinstalls the OLD core. Nothing errors. cloudbongos.com lost thirteen releases exactly this way — the `core_upgrades` ledger held two successful `auto-subscription (patch)` rows whose result no longer existed on disk, because deploy.sh short-circuits while `HEAD == origin/main` so the upgrade *looks* fine right up until someone else pushes.
57
+ >
58
+ > `bongos upgrade` now **warns loudly** whenever it leaves the pin uncommitted in a git repo, and `--commit-pin` (optionally `--git-remote <name>`) commits + pushes `package.json` + `package-lock.json` for you. A commit that fails to push is reported as a failure too — deploy.sh resets to the *remote*, so committing locally saves nothing. Verify durability by forcing a deploy tick (`sudo systemctl start <instance>-deploy.service`) and re-reading the pin.
55
59
  7. **Align the droplet checkout** — the droplet has no deploy key (`git pull` fails; idea 509), so `git bundle` the new commit on the laptop, `scp`, `git fetch <bundle> && git reset --hard FETCH_HEAD`. `node_modules` is already the new version, so no re-`npm ci`; a final restart makes `/version` report the new commit.
56
60
 
57
61
  ## Auto-rollback on failure (task 2149)
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.70",
3
+ "version": "1.19.72",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.70",
9
+ "version": "1.19.72",
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.70",
3
+ "version": "1.19.72",
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",
@@ -399,6 +399,39 @@ function installedCoreTarball({ instanceRoot, fsImpl = fs } = {}) {
399
399
  return null;
400
400
  }
401
401
 
402
+ // The REGISTRY-pinned sibling of installedCoreTarball (task 1002706). After a control
403
+ // plane moves its own core pin off the vendored tarball onto the npm registry (the
404
+ // cloudbongos.com cutover, task 1002621), its package.json holds a semver pin — no
405
+ // `file:` — so installedCoreTarball returns null and the scaffold would fall into the
406
+ // package-core.js source rebuild, which ABORTS on a consumer instance (the empty-set
407
+ // abort of task 1002275, resurrected by the cutover). The installed package itself is
408
+ // still the exact core this hub runs: return its node_modules dir + version + the
409
+ // `npm pack` tarball name so the scaffold can pack it into a vendorable .tgz. The child
410
+ // stays file:-pinned — its owner needs no npm token (the registry package is PRIVATE
411
+ // until the ADR 0134 go-public step). Returns { dir, version, tarballName } or null
412
+ // (a file: pin, an uninstalled dep, or a source checkout). fs reads only → dry-run safe.
413
+ function installedCorePackDir({ instanceRoot, fsImpl = fs } = {}) {
414
+ const root = instanceRoot || process.env.BONGOS_INSTANCE_ROOT || process.cwd();
415
+ let pkg;
416
+ try { pkg = JSON.parse(fsImpl.readFileSync(path.join(root, 'package.json'), 'utf8')); }
417
+ catch { return null; } // no/unreadable package.json → a source checkout
418
+ const deps = Object.assign({}, pkg.dependencies, pkg.devDependencies);
419
+ for (const name of ['@bongos/core', '@cloudbongos/core']) {
420
+ const spec = String(deps[name] || '').trim();
421
+ if (!spec || spec.startsWith('file:')) continue; // file: pins belong to installedCoreTarball
422
+ const dir = path.join(root, 'node_modules', ...name.split('/'));
423
+ try {
424
+ const installed = JSON.parse(fsImpl.readFileSync(path.join(dir, 'package.json'), 'utf8'));
425
+ const version = installed && installed.version ? String(installed.version) : null;
426
+ if (!version) continue;
427
+ // npm pack names a scoped tarball <scope>-<name>-<version>.tgz (no @, / → -).
428
+ const base = String(installed.name || name).replace(/^@/, '').replace(/\//g, '-');
429
+ return { dir, version, tarballName: `${base}-${version}.tgz` };
430
+ } catch { /* pinned but not installed → fall through */ }
431
+ }
432
+ return null;
433
+ }
434
+
402
435
  // The onboard mode a provisioning row carries (migration provisioning_004, task 1002562).
403
436
  // 'adopt' means target_ref is a repo the owner ALREADY has, so the scaffold must LAYER
404
437
  // onto its history. Anything else — including a row written before the column existed, or
@@ -606,20 +639,38 @@ async function scaffoldStandaloneRepo(inst, deps) {
606
639
  log(` [scaffold] core install: registry — PRIVATE @bongos/core@${coreV} via env-fed NPM_TOKEN (ADR 0134)`);
607
640
  exec(`node scripts/gds/init.js --from ${specPath} --dir ${root} --no-seed ${modeFlags} --registry-core`);
608
641
  } else {
609
- // Vendored tarball. PREFER a pre-built pinned tarball this control plane already has
610
- // (a consumer instance's own `file:` core pin) — rebuilding via package-core.js aborts
611
- // with an EMPTY core set when this runner IS a consumer instance (REPO_ROOT is inside
612
- // gitignored node_modules), which left new repos empty (task 1002275). Only rebuild
613
- // from source when there is no such pin (a genuine core source checkout).
642
+ // Vendored tarball — a three-rung resolution ladder, freshest-correct first:
643
+ // 1. a `file:`-pinned control plane reuses its own pinned tarball (task 1002275 —
644
+ // rebuilding via package-core.js aborts with an EMPTY core set when this runner
645
+ // IS a consumer instance, leaving new repos empty);
646
+ // 2. a REGISTRY-pinned consumer (the post-1002621 mothership shape) packs the core
647
+ // it has INSTALLED — the child pins the SAME, CURRENT core the hub runs, ending
648
+ // the frozen-old-tarball births (task 1002706, gap 13) without requiring the
649
+ // child's owner to hold a token for the still-private registry package;
650
+ // 3. a genuine core source checkout rebuilds fresh via package-core.js.
614
651
  let tgz = installedCoreTarball({ instanceRoot: deps.instanceRoot });
652
+ let packDest = null; // set on the pack rung — cleaned up once init has vendored the tgz
615
653
  if (tgz) {
616
654
  log(` [scaffold] core install: reusing the pinned core tarball this control plane runs (${path.basename(tgz)}) — no source rebuild (task 1002275)`);
617
655
  } else {
618
- log(` [scaffold] core install: building a fresh vendored tarball from source (package-core.js)`);
619
- exec(`node scripts/gds/package-core.js`); // cwd = REPO_ROOT → dist/bongos-core-<v>.tgz
620
- tgz = path.join(REPO_ROOT, 'dist', `bongos-core-${coreV}.tgz`);
656
+ const packSrc = installedCorePackDir({ instanceRoot: deps.instanceRoot });
657
+ if (packSrc) {
658
+ // Keyed by VERSION, not slug: no request-derived value enters this command, and
659
+ // two concurrent scaffolds of the same core pack identical bytes — collision-safe.
660
+ packDest = path.join(os.tmpdir(), `bongos-core-pack-${packSrc.version}`);
661
+ log(` [scaffold] core install: packing the registry-installed core this control plane runs (v${packSrc.version}) — no source rebuild (task 1002706)`);
662
+ exec(`mkdir -p ${packDest}`);
663
+ exec(`npm pack ${packSrc.dir} --pack-destination ${packDest}`);
664
+ tgz = path.join(packDest, packSrc.tarballName);
665
+ } else {
666
+ log(` [scaffold] core install: building a fresh vendored tarball from source (package-core.js)`);
667
+ exec(`node scripts/gds/package-core.js`); // cwd = REPO_ROOT → dist/bongos-core-<v>.tgz
668
+ tgz = path.join(REPO_ROOT, 'dist', `bongos-core-${coreV}.tgz`);
669
+ }
621
670
  }
622
671
  exec(`node scripts/gds/init.js --from ${specPath} --dir ${root} --no-seed ${modeFlags} --vendor-core ${tgz}`);
672
+ // init --vendor-core COPIED the tarball into the child's vendor/ — the temp pack dir is spent.
673
+ if (packDest) exec(`rm -rf ${packDest}`);
623
674
  }
624
675
 
625
676
  // 4. commit + push to the instance's own main (keep node_modules out).
@@ -1988,7 +2039,7 @@ module.exports = {
1988
2039
  backupUnitName, backupServicePath, backupTimerPath, backupScriptPath, backupService, backupTimer,
1989
2040
  standaloneRoot, standalonePullCmd, standaloneInstallCmd, standaloneMigrateCmd,
1990
2041
  // standalone scaffold (ADR 0108 / 0125, task 2055; consumer-core fix task 1002275)
1991
- parseTargetRef, instanceRepoRemote, instanceInitSpec, installedCoreTarball, scaffoldStandaloneRepo, coreVersionSafe,
2042
+ parseTargetRef, instanceRepoRemote, instanceInitSpec, installedCoreTarball, installedCorePackDir, scaffoldStandaloneRepo, coreVersionSafe,
1992
2043
  // adopt vs greenfield onboarding (ADR 0121, task 1002562)
1993
2044
  onboardMode, alreadyScaffolded,
1994
2045
  zoneForDomain, dnsUpsertEnv, classifyZoneScope, checkDnsTokenScope,
@@ -124,6 +124,94 @@ function gitTreeClean(instanceDir, run = spawnSync) {
124
124
  return { ok: dirty === '', dirty };
125
125
  }
126
126
 
127
+ // ---- persisting the pin (task 1002712) --------------------------------------
128
+ //
129
+ // THE BUG THIS EXISTS FOR. `bongos upgrade` rewrites package.json +
130
+ // package-lock.json in the instance WORKING TREE and stops there. On a
131
+ // pull-deploy instance that is not durable, and the way it fails is silent:
132
+ //
133
+ // deploy.sh short-circuits while HEAD == origin/main, so a freshly-upgraded
134
+ // (but uncommitted) tree survives — health-green, ledger-recorded, apparently
135
+ // done. Then ANY later push to the instance repo moves origin/main, deploy.sh
136
+ // runs `git reset --hard origin/main`, and the pin reverts to whatever is
137
+ // COMMITTED. The lockfile hash changes with it, which trips deploy.sh's
138
+ // `npm ci`, which reinstalls the OLD core. Nothing errors, at any point.
139
+ //
140
+ // cloudbongos.com sat on 1.19.56 for thirteen releases exactly this way: the
141
+ // core_upgrades ledger holds two successful `auto-subscription (patch)` rows
142
+ // (1.19.56→1.19.60, 1.19.60→1.19.62) whose result no longer existed.
143
+ //
144
+ // The pin is REPO state, so the repo of record is the right place for it —
145
+ // deploy.sh treating origin/main as authoritative is correct, not the bug.
146
+ // Hence: --commit-pin lands it, and its absence WARNS rather than staying quiet.
147
+ const PIN_FILES = ['package.json', 'package-lock.json'];
148
+
149
+ function gitCurrentBranch(instanceDir, run = spawnSync) {
150
+ const r = run('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: instanceDir, encoding: 'utf8' });
151
+ if (!r || r.error || r.status !== 0) return null;
152
+ const b = (r.stdout || '').trim();
153
+ return b && b !== 'HEAD' ? b : null;
154
+ }
155
+
156
+ // Which of the pin files git currently reports as modified.
157
+ function dirtyPinFiles(instanceDir, run = spawnSync) {
158
+ const r = run('git', ['status', '--porcelain', '--', ...PIN_FILES], { cwd: instanceDir, encoding: 'utf8' });
159
+ if (!r || r.error || r.status !== 0) return null; // not a git repo — nothing to persist
160
+ return (r.stdout || '').split('\n').map((l) => l.slice(3).trim()).filter(Boolean);
161
+ }
162
+
163
+ function persistPin({ instanceDir, fromVersion, toVersion, commitPin, remote = 'origin', dryRun = false },
164
+ run = spawnSync, log = console.log, err = console.error) {
165
+ const dirty = dirtyPinFiles(instanceDir, run);
166
+ if (dirty === null) return { applicable: false, reason: 'not a git repo' };
167
+ if (!dirty.length) return { applicable: false, reason: 'pin files already match the repo' };
168
+
169
+ const branch = gitCurrentBranch(instanceDir, run);
170
+
171
+ if (!commitPin) {
172
+ // LOUD — this is the whole point. A silent uncommitted pin is the bug.
173
+ err(` ! PIN NOT COMMITTED — ${dirty.join(', ')} changed in the working tree only.`);
174
+ err(' On a pull-deploy instance the next push to the repo of record makes deploy.sh');
175
+ err(` 'git reset --hard ${remote}/${branch || 'main'}', which REVERTS this pin and lets npm ci`);
176
+ err(` reinstall ${fromVersion || 'the previous core'}. Re-run with --commit-pin, or commit + push:`);
177
+ // Single-quote the interpolated values. Nothing here executes this string — it is
178
+ // printed for a human to paste — but an unquoted branch or remote carrying shell
179
+ // metacharacters would misbehave once pasted, so emit it paste-safe.
180
+ const q = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
181
+ const at = `git -C ${q(instanceDir)}`;
182
+ err(` ${at} add ${PIN_FILES.map(q).join(' ')} && ${at} commit -m ${q(`pin @bongos/core ${toVersion}`)} && ${at} push ${q(remote)} ${q(`HEAD:${branch || 'main'}`)}`);
183
+ return { applicable: true, committed: false, pushed: false, dirty, warned: true };
184
+ }
185
+
186
+ if (dryRun) {
187
+ log(` [dry-run] would commit + push ${dirty.join(', ')} to ${remote}/${branch || 'main'}`);
188
+ return { applicable: true, committed: false, pushed: false, dirty, dryRun: true };
189
+ }
190
+
191
+ const message = `pin @bongos/core ${toVersion}${fromVersion ? ` (was ${fromVersion})` : ''}`;
192
+ const add = run('git', ['add', ...PIN_FILES], { cwd: instanceDir, encoding: 'utf8' });
193
+ if (!add || add.error || add.status !== 0) {
194
+ err(' ! could not stage the pin files — commit + push them by hand or the next deploy reverts this bump.');
195
+ return { applicable: true, committed: false, pushed: false, dirty, error: 'git add failed' };
196
+ }
197
+ const commit = run('git', ['commit', '-m', message], { cwd: instanceDir, encoding: 'utf8' });
198
+ if (!commit || commit.error || commit.status !== 0) {
199
+ err(' ! could not commit the pin — commit + push it by hand or the next deploy reverts this bump.');
200
+ return { applicable: true, committed: false, pushed: false, dirty, error: 'git commit failed' };
201
+ }
202
+ log(` ✓ committed the pin (${dirty.join(', ')})`);
203
+
204
+ const push = run('git', ['push', remote, `HEAD:${branch || 'main'}`], { cwd: instanceDir, encoding: 'utf8' });
205
+ if (!push || push.error || push.status !== 0) {
206
+ // Committed but not pushed is still doomed: deploy.sh resets to the REMOTE.
207
+ err(` ! commit landed locally but the push to ${remote} FAILED — this bump is still not durable.`);
208
+ err(` Push it: git -C ${instanceDir} push ${remote} HEAD:${branch || 'main'}`);
209
+ return { applicable: true, committed: true, pushed: false, dirty, error: 'git push failed' };
210
+ }
211
+ log(` ✓ pushed the pin to ${remote}/${branch || 'main'} — a pull-deploy reset can no longer revert it`);
212
+ return { applicable: true, committed: true, pushed: true, dirty };
213
+ }
214
+
127
215
  // Copy the source .tgz (+ its side-car manifest, if present) into the consumer's vendor/ dir under the
128
216
  // canonical name. Returns the vendor-relative tarball path to pin.
129
217
  function vendorTarball({ instanceDir, fromTgz, targetVersion }, fsImpl = fs) {
@@ -203,6 +291,34 @@ function npmInstall(instanceDir, run = spawnSync) {
203
291
  return !r || r.error ? { ok: false, reason: r && r.error ? r.error.message : 'spawn failed' } : { ok: r.status === 0, code: r.status };
204
292
  }
205
293
 
294
+ // migrate.sh reaches Postgres over the local socket as the CURRENT OS USER (peer
295
+ // auth) — so running an upgrade under `sudo` connects as `root`, which usually has
296
+ // no Postgres role. The failure surfaces mid-run as a raw
297
+ // `FATAL: role "root" does not exist`, AFTER the pin has already moved, which then
298
+ // triggers the whole auto-rollback dance for what is really an operator mistake.
299
+ // This names it up front instead (task 1002712).
300
+ function preflightDbIdentity({ instanceDir, db }, run = spawnSync) {
301
+ const r = run('psql', ['-tAc', 'select 1'], {
302
+ cwd: instanceDir, encoding: 'utf8',
303
+ env: { ...process.env, ...(db ? { PGDATABASE: db } : {}) },
304
+ });
305
+ if (!r || r.error) return { ok: true, skipped: 'psql not on PATH — leaving it to migrate' };
306
+ if (r.status === 0) return { ok: true };
307
+
308
+ const out = `${r.stderr || ''}${r.stdout || ''}`;
309
+ const m = out.match(/role "([^"]+)" does not exist/i);
310
+ if (!m) return { ok: true, skipped: 'psql failed for another reason — leaving it to migrate' };
311
+ return {
312
+ ok: false,
313
+ role: m[1],
314
+ reason: `Postgres has no role "${m[1]}" — migrate would fail after the pin moved.\n`
315
+ + ` migrate.sh connects over the local socket as the CURRENT OS USER. Running the\n`
316
+ + ` upgrade under sudo connects as root, which is almost never a Postgres role.\n`
317
+ + ` Re-run as the user that owns the instance (e.g. sudo -u <owner> …), passing the\n`
318
+ + ` npm token through if the core comes from the registry.`,
319
+ };
320
+ }
321
+
206
322
  function runMigrate(instanceDir, run = spawnSync) {
207
323
  const r = run('npm', ['run', 'migrate'], { cwd: instanceDir, stdio: 'inherit' });
208
324
  return !r || r.error ? { ok: false, reason: r && r.error ? r.error.message : 'spawn failed' } : { ok: r.status === 0, code: r.status };
@@ -628,6 +744,15 @@ async function runUpgrade(opts, deps = {}) {
628
744
  // when someone is reading this output (ADR 0166).
629
745
  (deps.reportModulePreflight || reportModulePreflight)(mods, targetVersion, { log, err });
630
746
  if (!mods.ok) return { ok: false, error: `module pre-check failed: ${mods.blocking.map((b) => b.key).join(', ')} incompatible with core ${targetVersion}` };
747
+ // Fail on the DB identity BEFORE anything moves — a wrong OS user is an operator
748
+ // mistake, and finding it at migrate means unwinding a pin that never should have
749
+ // been written (task 1002712).
750
+ if (!opts.skipMigrate) {
751
+ const dbId = (deps.preflightDbIdentity || preflightDbIdentity)({ instanceDir, db: process.env.PGDATABASE }, run);
752
+ if (!dbId.ok) return { ok: false, error: dbId.reason };
753
+ if (dbId.skipped) log(` • db identity pre-check: ${dbId.skipped}`);
754
+ else log(' ✓ pre-flight: database reachable as the current user');
755
+ }
631
756
 
632
757
  // 1b. mandatory post-restart health check (audit H9 / task 1002222, finding F5). A bump that
633
758
  // brings the new core live via a managed restart (--service / --restart-cmd) MUST confirm it
@@ -794,9 +919,20 @@ async function runUpgrade(opts, deps = {}) {
794
919
  else if (ledger.skipped) log(` • ledger skipped (${ledger.reason})`);
795
920
  else err(` ! ledger insert failed (${ledger.reason}) — record it manually`);
796
921
 
922
+ // Make the pin durable (task 1002712). Runs LAST — only a verified-healthy
923
+ // upgrade is worth persisting, and a rolled-back one returned long before here.
924
+ const pin = persistPin({
925
+ instanceDir,
926
+ fromVersion,
927
+ toVersion: targetVersion,
928
+ commitPin: opts.commitPin,
929
+ remote: opts.gitRemote || 'origin',
930
+ dryRun: opts.dryRun,
931
+ }, run, log, err);
932
+
797
933
  const ok = versionOk && healthOk !== false;
798
934
  log(ok ? `\n✓ upgrade complete — core ${fromVersion || '(none)'} → ${targetVersion}` : `\n! upgrade finished with warnings — review above.`);
799
- return { ok, fromVersion, toVersion: targetVersion, versionOk, healthOk, ledger: ledger.ok };
935
+ return { ok, fromVersion, toVersion: targetVersion, versionOk, healthOk, ledger: ledger.ok, pin };
800
936
  }
801
937
 
802
938
  function parseArgs(argv) {
@@ -811,6 +947,8 @@ function parseArgs(argv) {
811
947
  note: arg('--note', argv),
812
948
  pinPath: arg('--pin-path', argv),
813
949
  registry: hasFlag('--registry', argv), // ADR 0134: pull @bongos/core@<--to> from the npm registry instead of vendoring
950
+ commitPin: hasFlag('--commit-pin', argv), // task 1002712: commit + push package.json/-lock so a pull-deploy reset cannot revert the bump
951
+ gitRemote: arg('--git-remote', argv),
814
952
  dryRun: hasFlag('--dry-run', argv),
815
953
  force: hasFlag('--force', argv),
816
954
  skipMigrate: hasFlag('--skip-migrate', argv),
@@ -867,7 +1005,7 @@ async function main(argv = process.argv.slice(2)) {
867
1005
  module.exports = {
868
1006
  runUpgrade, parseArgs, main, rollback,
869
1007
  versionFromTgzPath, versionFromDep, readInstalledCoreVersion, readPinnedCoreVersion, writePin, writeRegistryPin, restorePin,
870
- vendorTarball, gitTreeClean, preflightModules, reportModulePreflight, npmInstall, runMigrate, regenerateApiArtifacts, regenerateNavDocs, NAV_WHOLE_FILE_GENERATORS, missingDocAssets, healDocAssets, DOC_ASSET_GENERATORS, restartService, recordLedger, pollHealth,
1008
+ vendorTarball, gitTreeClean, persistPin, dirtyPinFiles, gitCurrentBranch, PIN_FILES, preflightDbIdentity, preflightModules, reportModulePreflight, npmInstall, runMigrate, regenerateApiArtifacts, regenerateNavDocs, NAV_WHOLE_FILE_GENERATORS, missingDocAssets, healDocAssets, DOC_ASSET_GENERATORS, restartService, recordLedger, pollHealth,
871
1009
  readManifest, resolveReferenceManifest, verifyInstalledPin, // task 1002216 (audit H8) — integrity-pin verification
872
1010
  CORE_PKG, ARTIFACT,
873
1011
  };
package/src/module-api.js CHANGED
@@ -567,8 +567,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
567
567
  // landed since 1.19.68 with no explicit bump. run 31608503813. (task 1002620)
568
568
  // 1.19.70 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
569
569
  // landed since 1.19.69 with no explicit bump. run 31612248859. (task 1002620)
570
+ // 1.19.71 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
571
+ // landed since 1.19.70 with no explicit bump. run 31613220077. (task 1002620)
572
+ // 1.19.72 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
573
+ // landed since 1.19.71 with no explicit bump. run 31617976225. (task 1002620)
570
574
  // ---------------------------------------------------------------------------
571
- const CORE_VERSION = '1.19.70'; // CI auto-patch carrier (ADR 0161); see changelog above
575
+ const CORE_VERSION = '1.19.72'; // CI auto-patch carrier (ADR 0161); see changelog above
572
576
 
573
577
  // A namespaced logger so a module's log lines are attributable + consistent.
574
578
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -489,7 +489,8 @@ t('installedCoreTarball: reuses a consumer instance file: core pin; null on a so
489
489
  rmSync(join(inst, 'vendor', 'bongos-core-1.17.4.tgz'));
490
490
  assert.equal(P.installedCoreTarball({ instanceRoot: inst }), null, 'a dangling file: pin resolves to null');
491
491
 
492
- // source checkout: a caret range (no file: pin) → null → the caller rebuilds via package-core.js
492
+ // a caret range (no file: pin) → null HERE — the registry-consumer case is
493
+ // installedCorePackDir's (task 1002706); only a true source checkout rebuilds
493
494
  const src = mkdtempSync(join(tmpdir(), 'cptgz-source-'));
494
495
  writeFileSync(join(src, 'package.json'), JSON.stringify({ dependencies: { '@bongos/core': '^1.17.4' } }));
495
496
  assert.equal(P.installedCoreTarball({ instanceRoot: src }), null, 'a source checkout has no file: pin');
@@ -535,6 +536,80 @@ await ta('scaffoldStandaloneRepo: on a consumer control plane it REUSES the pinn
535
536
  }
536
537
  });
537
538
 
539
+ // ── registry-pinned consumer control plane (task 1002706) ─────────────────────────
540
+ // After the mothership's registry cutover (task 1002621) its package.json pins
541
+ // "@bongos/core": "1.19.x" — NO file: pin — so installedCoreTarball returns null and,
542
+ // before this fix, the scaffold fell into the package-core.js source rebuild, which
543
+ // ABORTS on a consumer instance (empty core set → empty child repo — the 1002275
544
+ // failure resurrected). Pack the INSTALLED core instead: the child pins the SAME,
545
+ // CURRENT core the hub runs (also kills the frozen-1.19.2 born-stale bug, gap 13).
546
+ t('installedCorePackDir: a registry-pinned consumer with an installed core → its node_modules dir; file:/uninstalled/bare → null', () => {
547
+ const reg = mkdtempSync(join(tmpdir(), 'cpdir-reg-'));
548
+ mkdirSync(join(reg, 'node_modules', '@bongos', 'core'), { recursive: true });
549
+ writeFileSync(join(reg, 'node_modules', '@bongos', 'core', 'package.json'), JSON.stringify({ name: '@bongos/core', version: '1.19.56' }));
550
+ writeFileSync(join(reg, 'package.json'), JSON.stringify({ dependencies: { '@bongos/core': '1.19.56' } }));
551
+ const hit = P.installedCorePackDir({ instanceRoot: reg });
552
+ assert.ok(hit, 'a registry-pinned consumer resolves');
553
+ assert.equal(hit.dir, join(reg, 'node_modules', '@bongos', 'core'));
554
+ assert.equal(hit.version, '1.19.56');
555
+ assert.equal(hit.tarballName, 'bongos-core-1.19.56.tgz', 'npm pack naming for the scoped package');
556
+
557
+ // a file: pin belongs to installedCoreTarball, never this path
558
+ const filePinned = mkdtempSync(join(tmpdir(), 'cpdir-file-'));
559
+ writeFileSync(join(filePinned, 'package.json'), JSON.stringify({ dependencies: { '@bongos/core': 'file:vendor/bongos-core-1.17.4.tgz' } }));
560
+ assert.equal(P.installedCorePackDir({ instanceRoot: filePinned }), null, 'file: pin → null');
561
+
562
+ // registry pin but the dep is not installed → null → the source rebuild stays the fallback
563
+ const uninstalled = mkdtempSync(join(tmpdir(), 'cpdir-unin-'));
564
+ writeFileSync(join(uninstalled, 'package.json'), JSON.stringify({ dependencies: { '@bongos/core': '^1.19.0' } }));
565
+ assert.equal(P.installedCorePackDir({ instanceRoot: uninstalled }), null, 'uninstalled → null');
566
+
567
+ // no package.json at all → null
568
+ const bare2 = mkdtempSync(join(tmpdir(), 'cpdir-bare-'));
569
+ assert.equal(P.installedCorePackDir({ instanceRoot: bare2 }), null, 'no package.json → null');
570
+
571
+ // the @cloudbongos/core alias is honored (matches installedCoreTarball + core-node.sh),
572
+ // and the tarball name follows the INSTALLED package's own name
573
+ const alt2 = mkdtempSync(join(tmpdir(), 'cpdir-alt-'));
574
+ mkdirSync(join(alt2, 'node_modules', '@cloudbongos', 'core'), { recursive: true });
575
+ writeFileSync(join(alt2, 'node_modules', '@cloudbongos', 'core', 'package.json'), JSON.stringify({ name: '@cloudbongos/core', version: '2.0.0' }));
576
+ writeFileSync(join(alt2, 'package.json'), JSON.stringify({ dependencies: { '@cloudbongos/core': '^2.0.0' } }));
577
+ assert.equal(P.installedCorePackDir({ instanceRoot: alt2 }).tarballName, 'cloudbongos-core-2.0.0.tgz');
578
+
579
+ [reg, filePinned, uninstalled, bare2, alt2].forEach((d) => rmSync(d, { recursive: true, force: true }));
580
+ });
581
+
582
+ await ta('scaffoldStandaloneRepo: a REGISTRY-pinned consumer control plane PACKS its installed core — current version, no source rebuild (task 1002706)', async () => {
583
+ const cp = mkdtempSync(join(tmpdir(), 'cpdir-cp-'));
584
+ mkdirSync(join(cp, 'node_modules', '@bongos', 'core'), { recursive: true });
585
+ writeFileSync(join(cp, 'node_modules', '@bongos', 'core', 'package.json'), JSON.stringify({ name: '@bongos/core', version: '1.19.56' }));
586
+ writeFileSync(join(cp, 'package.json'), JSON.stringify({ dependencies: { '@bongos/core': '1.19.56' } }));
587
+ const base = mkdtempSync(join(tmpdir(), 'cpdir-standalone-')); // FRESH base → the scaffold proceeds
588
+ const origBase = P.CONFIG.standaloneBase;
589
+ P.CONFIG.standaloneBase = base;
590
+ try {
591
+ const { deps, cmds } = recorder({ apply: true });
592
+ await P.scaffoldStandaloneRepo(
593
+ { slug: 'demo', hosting_shape: 'standalone', target_ref: 'lars589/demo' },
594
+ { ...deps, instanceRoot: cp },
595
+ );
596
+ const joined = cmds.join('\n');
597
+ assert.ok(!/package-core\.js/.test(joined), 'does NOT rebuild from source (the empty-set abort on a consumer)');
598
+ assert.match(joined, /npm pack /, 'packs the installed core');
599
+ assert.match(joined, /--vendor-core \S*bongos-core-1\.19\.56\.tgz/, 'vendors the CURRENT core the hub runs into the child');
600
+ assert.ok(!/--registry-core/.test(joined), 'the child stays file:-pinned — its owner needs no npm token');
601
+ // the temp pack dir is version-keyed (no request-derived slug in the command) and
602
+ // cleaned up once init has vendored the tarball into the child
603
+ assert.match(joined, /mkdir -p \S*bongos-core-pack-1\.19\.56/, 'pack dir is keyed by core version');
604
+ const initAt = joined.indexOf('--vendor-core');
605
+ const rmAt = joined.indexOf('rm -rf');
606
+ assert.ok(rmAt > initAt && /rm -rf \S*bongos-core-pack-1\.19\.56/.test(joined), 'temp pack dir removed AFTER init consumed the tarball');
607
+ } finally {
608
+ P.CONFIG.standaloneBase = origBase;
609
+ [cp, base].forEach((d) => rmSync(d, { recursive: true, force: true }));
610
+ }
611
+ });
612
+
538
613
  // ── scaffold push via the owner OAuth token (task 1002317) ────────────────────────
539
614
  // Deploy keys are per-repo; a shared key can't write to a NEW project repo. Push with the
540
615
  // owner's token (which created the repo) instead — WITHOUT leaking it into a logged command.
package/tests/upgrade.mjs CHANGED
@@ -181,7 +181,11 @@ t('full: pre-flight → pin → install → migrate → materialize → restart
181
181
  // steps ran, in the expected order
182
182
  assert.deepEqual(
183
183
  calls.filter((c) => c.startsWith('npm') || c.startsWith('git') || c.startsWith('systemctl')),
184
- ['git status --porcelain', 'npm install --no-audit --no-fund', 'npm run migrate', 'systemctl restart emersonian.service']
184
+ // The trailing `git status` is persistPin's read-only look at whether the new
185
+ // pin is committed (task 1002712). It runs LAST — after verify — and without
186
+ // --commit-pin it only warns, so no git add/commit/push appears here.
187
+ ['git status --porcelain', 'npm install --no-audit --no-fund', 'npm run migrate', 'systemctl restart emersonian.service',
188
+ 'git status --porcelain -- package.json package-lock.json']
185
189
  );
186
190
  // materialize pointed at the freshly-installed core
187
191
  assert.ok(materialized && materialized.coreRoot.endsWith(join('node_modules', '@bongos', 'core')));
@@ -0,0 +1,191 @@
1
+ // tests/upgrade_persist_pin.mjs
2
+ //
3
+ // Covers persistPin (task 1002712) — the step that makes a core bump DURABLE.
4
+ //
5
+ // The bug: `bongos upgrade` rewrote package.json + package-lock.json in the
6
+ // working tree and stopped. deploy.sh short-circuits while HEAD == origin/main,
7
+ // so the upgraded-but-uncommitted tree survived and looked done — health-green,
8
+ // ledger-recorded. Then any later push moved origin/main, deploy.sh ran
9
+ // `git reset --hard origin/main`, the pin reverted, the lockfile hash changed,
10
+ // and `npm ci` reinstalled the OLD core. Silent at every step.
11
+ //
12
+ // cloudbongos.com lost thirteen releases this way: two successful
13
+ // `auto-subscription (patch)` rows in core_upgrades whose result no longer
14
+ // existed on disk.
15
+ //
16
+ // `run` is injected throughout, so nothing here touches a real repo, remote, or
17
+ // network — the assertions are on the git argv persistPin CHOOSES.
18
+ //
19
+ // Run: node --test tests/upgrade_persist_pin.mjs
20
+
21
+ import { test } from 'node:test';
22
+ import assert from 'node:assert/strict';
23
+ import { createRequire } from 'node:module';
24
+
25
+ const require = createRequire(import.meta.url);
26
+ const { persistPin, dirtyPinFiles, preflightDbIdentity, PIN_FILES } = require('../scripts/gds/upgrade.js');
27
+
28
+ // A fake `run` driven by a table of [matcher, result]. Records every invocation.
29
+ function fakeRun(handlers = {}) {
30
+ const calls = [];
31
+ const fn = (cmd, args) => {
32
+ calls.push([cmd, ...args].join(' '));
33
+ const sub = args[0];
34
+ const h = handlers[sub];
35
+ if (typeof h === 'function') return h(args);
36
+ return h || { status: 0, stdout: '' };
37
+ };
38
+ fn.calls = calls;
39
+ return fn;
40
+ }
41
+
42
+ const DIRTY = { status: 0, stdout: ' M package.json\n M package-lock.json\n' };
43
+ const CLEAN = { status: 0, stdout: '' };
44
+ const NOT_A_REPO = { status: 128, stdout: '', error: null };
45
+ const ON_MAIN = { status: 0, stdout: 'main\n' };
46
+
47
+ const base = { instanceDir: '/srv/inst', fromVersion: '1.19.56', toVersion: '1.19.69' };
48
+ const quiet = () => {};
49
+
50
+ test('a non-git instance is simply out of scope', () => {
51
+ const run = fakeRun({ status: NOT_A_REPO });
52
+ const r = persistPin({ ...base, commitPin: true }, run, quiet, quiet);
53
+ assert.equal(r.applicable, false);
54
+ assert.ok(!run.calls.some((c) => c.includes('commit')), 'must not try to commit');
55
+ });
56
+
57
+ test('an already-matching pin is a no-op', () => {
58
+ const run = fakeRun({ status: CLEAN });
59
+ const r = persistPin({ ...base, commitPin: true }, run, quiet, quiet);
60
+ assert.equal(r.applicable, false);
61
+ assert.ok(!run.calls.some((c) => c.includes('commit')));
62
+ });
63
+
64
+ test('without --commit-pin it WARNS and names the revert mechanism', () => {
65
+ // The silence is the bug, so the warning is the deliverable.
66
+ const warnings = [];
67
+ const run = fakeRun({ status: DIRTY, 'rev-parse': ON_MAIN });
68
+ const r = persistPin({ ...base, commitPin: false }, run, quiet, (m) => warnings.push(m));
69
+
70
+ assert.equal(r.warned, true);
71
+ assert.equal(r.committed, false);
72
+ assert.ok(!run.calls.some((c) => c.includes('git commit')), 'must NOT commit without the flag');
73
+
74
+ const text = warnings.join('\n');
75
+ assert.match(text, /PIN NOT COMMITTED/);
76
+ assert.match(text, /reset --hard/, 'names the mechanism that reverts it');
77
+ assert.match(text, /npm ci/, 'and that npm ci then reinstalls the old core');
78
+ assert.match(text, /1\.19\.56/, 'names the version it would fall back to');
79
+ assert.match(text, /--commit-pin/, 'and the fix');
80
+ });
81
+
82
+ test('with --commit-pin it stages, commits, and pushes to the tracked branch', () => {
83
+ const run = fakeRun({ status: DIRTY, 'rev-parse': ON_MAIN });
84
+ const r = persistPin({ ...base, commitPin: true }, run, quiet, quiet);
85
+
86
+ assert.deepEqual(r, { applicable: true, committed: true, pushed: true, dirty: PIN_FILES });
87
+ assert.ok(run.calls.some((c) => c === `git add ${PIN_FILES.join(' ')}`), 'stages exactly the pin files');
88
+ assert.ok(run.calls.some((c) => /^git commit -m pin @bongos\/core 1\.19\.69 \(was 1\.19\.56\)$/.test(c)),
89
+ `commit message names both versions; got ${JSON.stringify(run.calls)}`);
90
+ assert.ok(run.calls.some((c) => c === 'git push origin HEAD:main'), 'pushes to the branch it is on');
91
+ });
92
+
93
+ test('a detached HEAD still pushes somewhere sane', () => {
94
+ const run = fakeRun({ status: DIRTY, 'rev-parse': { status: 0, stdout: 'HEAD\n' } });
95
+ persistPin({ ...base, commitPin: true }, run, quiet, quiet);
96
+ assert.ok(run.calls.some((c) => c === 'git push origin HEAD:main'), 'falls back to main');
97
+ });
98
+
99
+ test('--git-remote is honoured', () => {
100
+ const run = fakeRun({ status: DIRTY, 'rev-parse': ON_MAIN });
101
+ persistPin({ ...base, commitPin: true, remote: 'deploy' }, run, quiet, quiet);
102
+ assert.ok(run.calls.some((c) => c === 'git push deploy HEAD:main'));
103
+ });
104
+
105
+ test('a FAILED PUSH is loud — committed-but-unpushed is still doomed', () => {
106
+ // deploy.sh resets to the REMOTE, so a local commit alone saves nothing. This
107
+ // must never read as success.
108
+ const warnings = [];
109
+ const run = fakeRun({ status: DIRTY, 'rev-parse': ON_MAIN, push: { status: 1, stdout: '' } });
110
+ const r = persistPin({ ...base, commitPin: true }, run, quiet, (m) => warnings.push(m));
111
+
112
+ assert.equal(r.committed, true);
113
+ assert.equal(r.pushed, false);
114
+ assert.equal(r.error, 'git push failed');
115
+ assert.match(warnings.join('\n'), /still not durable/i);
116
+ });
117
+
118
+ test('a failed commit does not claim to have pushed', () => {
119
+ const run = fakeRun({ status: DIRTY, 'rev-parse': ON_MAIN, commit: { status: 1, stdout: '' } });
120
+ const r = persistPin({ ...base, commitPin: true }, run, quiet, quiet);
121
+ assert.equal(r.committed, false);
122
+ assert.equal(r.pushed, false);
123
+ assert.ok(!run.calls.some((c) => c.startsWith('git push')), 'must not push an uncommitted pin');
124
+ });
125
+
126
+ test('--dry-run describes the commit without making one', () => {
127
+ const run = fakeRun({ status: DIRTY, 'rev-parse': ON_MAIN });
128
+ const r = persistPin({ ...base, commitPin: true, dryRun: true }, run, quiet, quiet);
129
+ assert.equal(r.dryRun, true);
130
+ assert.equal(r.committed, false);
131
+ assert.ok(!run.calls.some((c) => c.includes('git commit') || c.includes('git push')));
132
+ });
133
+
134
+ test('the copy-pasteable fix line is shell-quoted', () => {
135
+ // Nothing executes this string — it is printed for a human to paste — but a
136
+ // branch or remote carrying shell metacharacters must not misbehave when it is.
137
+ const warnings = [];
138
+ const run = fakeRun({ status: DIRTY, 'rev-parse': { status: 0, stdout: "main; rm -rf ~\n" } });
139
+ persistPin({ ...base, commitPin: false }, run, quiet, (m) => warnings.push(m));
140
+ const cmd = warnings.find((w) => w.includes('git -C'));
141
+ assert.ok(cmd, 'a paste-ready command is offered');
142
+ // The payload may appear — what matters is that it is INSIDE single quotes, so a
143
+ // paste runs one git command rather than two.
144
+ assert.match(cmd, /push '[^']*' 'HEAD:main; rm -rf ~'/, 'quoted as a single argument');
145
+ // Balanced quoting is the real property: an odd count would mean a value broke out.
146
+ assert.equal((cmd.match(/'/g) || []).length % 2, 0, 'every quote is closed');
147
+ });
148
+
149
+ // ── preflightDbIdentity (task 1002712) ──────────────────────────────────────
150
+ // migrate.sh uses peer auth, so `sudo bongos upgrade` connects as root, which is
151
+ // almost never a Postgres role. That surfaced as a raw FATAL mid-run, AFTER the
152
+ // pin had moved, triggering a full auto-rollback for an operator mistake.
153
+
154
+ test('a missing Postgres role is caught BEFORE anything moves', () => {
155
+ const run = () => ({ status: 2, stderr: 'psql: error: FATAL: role "root" does not exist\n', stdout: '' });
156
+ const r = preflightDbIdentity({ instanceDir: '/srv/inst', db: 'cloudbongos' }, run);
157
+ assert.equal(r.ok, false);
158
+ assert.equal(r.role, 'root');
159
+ assert.match(r.reason, /current OS user/i, 'explains peer auth');
160
+ assert.match(r.reason, /sudo/, 'and names sudo as the likely cause');
161
+ });
162
+
163
+ test('a reachable database passes quietly', () => {
164
+ const r = preflightDbIdentity({ instanceDir: '/srv/inst' }, () => ({ status: 0, stdout: '1\n' }));
165
+ assert.equal(r.ok, true);
166
+ assert.equal(r.skipped, undefined);
167
+ });
168
+
169
+ test('an unrelated psql failure is NOT turned into a refusal', () => {
170
+ // Only the identity problem is diagnosable here; everything else stays migrate's
171
+ // business, so a pre-check quirk can never block an otherwise fine upgrade.
172
+ const run = () => ({ status: 2, stderr: 'could not connect to server: No such file or directory\n', stdout: '' });
173
+ const r = preflightDbIdentity({ instanceDir: '/srv/inst' }, run);
174
+ assert.equal(r.ok, true);
175
+ assert.ok(r.skipped, 'defers rather than refusing');
176
+ });
177
+
178
+ test('no psql on PATH defers instead of refusing', () => {
179
+ const r = preflightDbIdentity({ instanceDir: '/srv/inst' }, () => ({ error: new Error('ENOENT') }));
180
+ assert.equal(r.ok, true);
181
+ assert.ok(r.skipped);
182
+ });
183
+
184
+ test('dirtyPinFiles only ever reports the pin files', () => {
185
+ // It scopes `git status` to PIN_FILES, so unrelated instance edits (a config
186
+ // tweak, a doc) can never be swept into the pin commit.
187
+ const run = fakeRun({ status: DIRTY });
188
+ dirtyPinFiles('/srv/inst', run);
189
+ const statusCall = run.calls.find((c) => c.startsWith('git status'));
190
+ assert.match(statusCall, /--\s+package\.json package-lock\.json$/, 'pathspec-limited');
191
+ });