@bongos/core 1.19.830 → 1.19.832

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.830",
6
- "core_contract": "1.19.830",
7
- "source_commit": "f052e69e7ed4925bc7578f461eed7f296c70bc24",
5
+ "core_version": "1.19.832",
6
+ "core_contract": "1.19.832",
7
+ "source_commit": "c8e1d760c29ed41a5d66af6b1c4f9936a7fd0f32",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-17T14:39:41.882Z",
9
+ "built_at": "2026-09-17T15:29:22.894Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 491,
12
+ "docs_redacted": 492,
13
13
  "agent_docs_stubbed": 26,
14
- "functional_verbatim": 2242,
14
+ "functional_verbatim": 2244,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2759,
20
- "tree_sha256": "07ddb7b8f4644eedae95fe56586daa10e5e895b3dd6304e8239da98e8151097c",
19
+ "file_count": 2762,
20
+ "tree_sha256": "0795b0e4670053ed5c638dac4719252988b0dbfd0d863dafef3df5b466a11f45",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -407,7 +407,7 @@
407
407
  {
408
408
  "path": "docker-compose.yml",
409
409
  "mode": "0000644",
410
- "sha256": "1742d8f1fe0795a1b1396e0e59b17bb324cba1101cb89b17336a866a34eeddd1"
410
+ "sha256": "098a2645a43876141b6c711362c2a93e134d1de7533cb6e17de9e80ccfb1327e"
411
411
  },
412
412
  {
413
413
  "path": "docker-entrypoint.sh",
@@ -1984,10 +1984,15 @@
1984
1984
  "mode": "0000644",
1985
1985
  "sha256": "f52bf2568d02d5cf8589dc394dc76cbf0ea3a7ba193275f300e320c2ab64a51c"
1986
1986
  },
1987
+ {
1988
+ "path": "docs/adr/0300-a-missing-protected-surface-registry-is-a-broken-install.md",
1989
+ "mode": "0000644",
1990
+ "sha256": "7c5d4c49dc98d37a8a41aa0cddd158c3a46a60cdb4e79b018d68c6b5222b7e79"
1991
+ },
1987
1992
  {
1988
1993
  "path": "docs/adr/README.md",
1989
1994
  "mode": "0000644",
1990
- "sha256": "0b4e0cd78a8bd6ae3d41c0d010634dfdc29ecd4dca1f8b49127791d1b4cd7450"
1995
+ "sha256": "8af8b8c5ea9b368bb790280eadd167b99ed9f864350e101ba18a4b0ec8f5ed5e"
1991
1996
  },
1992
1997
  {
1993
1998
  "path": "docs/api-reference.md",
@@ -2552,7 +2557,7 @@
2552
2557
  {
2553
2558
  "path": "docs/module-api-changelog.md",
2554
2559
  "mode": "0000644",
2555
- "sha256": "bb0d3a828688ed757efe4e321907837a4c15754b4ab72baaa31b88eca7b0a7c4"
2560
+ "sha256": "02d7156a9ee2a022e71835b03c120059c44b36cf9358085bf2c2d1fb91a72bd4"
2556
2561
  },
2557
2562
  {
2558
2563
  "path": "docs/modules-contract.md",
@@ -2782,7 +2787,7 @@
2782
2787
  {
2783
2788
  "path": "docs/recipes/self-host.md",
2784
2789
  "mode": "0000644",
2785
- "sha256": "8f97eeefdd7e2e0bd15ff066a7a7ab10378716182bbcc494ef0bdde1fd7c3f2b"
2790
+ "sha256": "2108b55f6e3264af35c2db885b5a98ff8cc9a5c5c68881d3f5ce8037db1bf159"
2786
2791
  },
2787
2792
  {
2788
2793
  "path": "docs/recipes/server-bring-up.md",
@@ -7837,12 +7842,12 @@
7837
7842
  {
7838
7843
  "path": "package-lock.json",
7839
7844
  "mode": "0000644",
7840
- "sha256": "86518fa351500812c81035964030dba99e78a1e75d8c4a1e584c13b5a96d4f7d"
7845
+ "sha256": "f833d910fdbed07c2c2a6a1597d5d6315780e4633c6015bde77629b7682f8c8d"
7841
7846
  },
7842
7847
  {
7843
7848
  "path": "package.json",
7844
7849
  "mode": "0000644",
7845
- "sha256": "a61e5b17236e35d52bd8047254f0c40a0dc9cb1eeb6c836905d1cb8e9787e18f"
7850
+ "sha256": "6fd9cd7067b463895344e6de0710ce6eda471f96d659161c56f9ce3cc9c0edb1"
7846
7851
  },
7847
7852
  {
7848
7853
  "path": "public-docs/index.html",
@@ -9467,7 +9472,7 @@
9467
9472
  {
9468
9473
  "path": "src/bongos/auth-admission.js",
9469
9474
  "mode": "0000644",
9470
- "sha256": "27b9f2ed74d5abbf8dade5199da278fe758c0e6472fbdb1d280bfbb9c24e4596"
9475
+ "sha256": "2d11e7b745a0476fa0643fd997c2e17a49709bae1ef93a7c4636f7efd18c7ab0"
9471
9476
  },
9472
9477
  {
9473
9478
  "path": "src/bongos/auth-config.js",
@@ -9492,7 +9497,7 @@
9492
9497
  {
9493
9498
  "path": "src/bongos/auth.js",
9494
9499
  "mode": "0000644",
9495
- "sha256": "0cfc14e764b04d21b710278132ed35a297417954b1200db0db7f61ece224a389"
9500
+ "sha256": "7e9b3c48e6d4bc3e0370e6ff4dba799691abeb02181d8910d0807651656f7d7e"
9496
9501
  },
9497
9502
  {
9498
9503
  "path": "src/bongos/cascade-dispatch.js",
@@ -9507,7 +9512,7 @@
9507
9512
  {
9508
9513
  "path": "src/bongos/db-kernel.js",
9509
9514
  "mode": "0000644",
9510
- "sha256": "e9a566e63b95225e3ee15331424af831875f75b5ea977c73f077bb50f46c4c6b"
9515
+ "sha256": "1fffc39fb9b70cd009d7d239729a9ff329cd41ca1f35b50b7c9e6b89aa83624e"
9511
9516
  },
9512
9517
  {
9513
9518
  "path": "src/bongos/db.js",
@@ -9592,7 +9597,7 @@
9592
9597
  {
9593
9598
  "path": "src/bongos/permission-path-check.js",
9594
9599
  "mode": "0000644",
9595
- "sha256": "14ba2ffb603cbea7d8388c196b74f8e7db92b777fb0da7c69c7d3c7c9e27f386"
9600
+ "sha256": "75264cfa68b2a8344c38565d96d16c28e101d286ad0b5794baa83ab774269d74"
9596
9601
  },
9597
9602
  {
9598
9603
  "path": "src/bongos/platform-visibility-gate.js",
@@ -9652,7 +9657,7 @@
9652
9657
  {
9653
9658
  "path": "src/bongos/routes/auth.js",
9654
9659
  "mode": "0000644",
9655
- "sha256": "40ff17b595791bf6a8c7ae1cd17c54d8ce5cc6fa805622fd8fbc2dc2c398c961"
9660
+ "sha256": "66abf424a565cdcc4de85350774d91e93be826a3b01a23ad75fee1feb7977a3c"
9656
9661
  },
9657
9662
  {
9658
9663
  "path": "src/bongos/routes/backup.js",
@@ -9762,7 +9767,7 @@
9762
9767
  {
9763
9768
  "path": "src/module-api.js",
9764
9769
  "mode": "0000644",
9765
- "sha256": "a717714b35118b907e81f90fdb5e10be2e85552d8abfd8463f70da6b1f07274a"
9770
+ "sha256": "8e0d57c0e24d78b95565b3c81fdae6ca50647ad6f84ea337eed213109594056e"
9766
9771
  },
9767
9772
  {
9768
9773
  "path": "src/module-loader/catalog.js",
@@ -10544,6 +10549,11 @@
10544
10549
  "mode": "0000644",
10545
10550
  "sha256": "3f9e3ea6d2b88cd6592ba7b0575908eb020a072e6ccdb8cdf908bfba65b5191c"
10546
10551
  },
10552
+ {
10553
+ "path": "tests/compose_demo_credentials.mjs",
10554
+ "mode": "0000644",
10555
+ "sha256": "1d625746b022de4f4e23ce071e276ce5758e048f5997fce690ac8e301b6c6721"
10556
+ },
10547
10557
  {
10548
10558
  "path": "tests/conductor.mjs",
10549
10559
  "mode": "0000644",
@@ -10969,6 +10979,11 @@
10969
10979
  "mode": "0000644",
10970
10980
  "sha256": "8006f4696e6bae21b84c9e8ec25cb7a7d34c5899f187d2ac59b2a6dcbb6f92d1"
10971
10981
  },
10982
+ {
10983
+ "path": "tests/first_admin_unset_signin.mjs",
10984
+ "mode": "0000644",
10985
+ "sha256": "88b3d0c7fb278e560850f440ec6721655efb62bcafcd983deb7e9ddb25103b65"
10986
+ },
10972
10987
  {
10973
10988
  "path": "tests/first_run_engine_check.mjs",
10974
10989
  "mode": "0000644",
@@ -11367,7 +11382,7 @@
11367
11382
  {
11368
11383
  "path": "tests/government_protected_surfaces.mjs",
11369
11384
  "mode": "0000644",
11370
- "sha256": "4478853e79266130c2ad3ab7a523f0f20fb98a97e88f1dd2476ef09db6b96dcc"
11385
+ "sha256": "1d8502e8d25eedb54ed0fcbe3ce1a0fe87495d03bd8bda7322e55d6004669a63"
11371
11386
  },
11372
11387
  {
11373
11388
  "path": "tests/government_rank_sync.mjs",
@@ -14,7 +14,10 @@ services:
14
14
  image: postgres:16-alpine
15
15
  environment:
16
16
  POSTGRES_USER: cloudbongos
17
- POSTGRES_PASSWORD: cloudbongos
17
+ # Under 8 characters on purpose: the leak scan treats any 8+ character value of a
18
+ # secret-named variable as a live credential, and this file ships in the core
19
+ # (task 1002717). The db port is not published; this value never leaves the network.
20
+ POSTGRES_PASSWORD: demo-pw
18
21
  POSTGRES_DB: cloudbongos
19
22
  volumes:
20
23
  - cloudbongos-db:/var/lib/postgresql/data
@@ -35,7 +38,7 @@ services:
35
38
  PGHOST: db
36
39
  PGPORT: "5432"
37
40
  PGUSER: cloudbongos
38
- PGPASSWORD: cloudbongos
41
+ PGPASSWORD: demo-pw
39
42
  PGDATABASE: cloudbongos
40
43
  # Bind to all interfaces inside the container so the published port works.
41
44
  HOST: 0.0.0.0
@@ -0,0 +1,109 @@
1
+ # ADR 0300 — A missing protected-surface registry is a broken install, not a module opt-out
2
+
3
+ **Status:** accepted · **Date:** 2026-09-17 · **Task:** [task 1003662](https://cloudbongos.com/builders#/task/1003662) (goal 1000091 — BONGOS-V2)
4
+
5
+ **Related:** [ADR 0043](0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md) (the protected-surface boundary this registry enforces) · [ADR 0083](0083-modular-architecture-strangler-build-plan.md) (core never depends on a module's behavior) · [ADR 0108](0108-instance-composition-configurable-root.md) §1 (the core is consumed as a versioned npm package, and `modules/` ships inside it)
6
+
7
+ ## Context
8
+
9
+ [task 1003662](https://cloudbongos.com/builders#/task/1003662) was filed as a bug with a
10
+ concrete remedy attached:
11
+
12
+ > `require('./module-scope-map')` throws on any checkout where
13
+ > `modules/government/protected-surfaces.json` is absent … This breaks any checkout that has
14
+ > disabled or not yet materialized the government module … Make `module-scope-map.js` degrade
15
+ > gracefully (empty protected set + a loud warning) when the government module isn't present,
16
+ > matching the `resolveOptional` pattern used elsewhere for optional module capabilities.
17
+
18
+ The throw is real and reproduces exactly as described. Moving the registry aside and requiring
19
+ the kernel file gives:
20
+
21
+ ```
22
+ protected-surface registry unreadable at <root>/modules/government/protected-surfaces.json:
23
+ ENOENT: no such file or directory. Refusing to run with an empty protected set (ADR 0043).
24
+ ```
25
+
26
+ Everything after that first sentence is wrong, in four separate ways — and the remedy it
27
+ proposes would disable the ADR 0043 boundary on exactly the installs least able to notice.
28
+
29
+ **1. The throw is not in the file the task names.** `module-scope-map.js` contains no `throw`
30
+ and no file read. It does `require('./permission-path-check')` at line 39, and
31
+ `permission-path-check.js`'s `loadRegistry()` throws at module scope. Any "fix" applied to the
32
+ file in the title would be applied to the wrong file; the behavior is owned by its sibling.
33
+
34
+ **2. The throw is a written contract, not an oversight.** `permission-path-check.js` states it
35
+ in its own header, in capitals, above the code:
36
+
37
+ > FAIL-CLOSED: a missing/empty/malformed registry THROWS. It never degrades to "nothing is
38
+ > protected" — an unreadable registry is a broken install, not an opted-out module.
39
+
40
+ The remedy asks for the one behavior the header names and refuses. That header also pre-answers
41
+ the architectural worry the title gestures at (a kernel file reaching into `modules/`): the read
42
+ is `fs`, never `require`, because ADR 0083's one-way rule forbids core from requiring a module's
43
+ *code* — and this is not code, it is a static declarative data file. The sibling trust primitive
44
+ `route-rank-check.js` reads `modules/*/routes/*.js` off disk the same way. Code still flows one
45
+ way; nothing in core requires the module.
46
+
47
+ **3. Disabling the government module cannot produce the ENOENT.** Module enablement is a
48
+ runtime flag, not a file deletion — disabling a module leaves its files on disk untouched. The
49
+ registry read never consults enablement, deliberately: per the same header, a module's
50
+ enable/disable state must never be something the kernel depends on. So the "disabled" half of
51
+ the premise describes a state that cannot occur.
52
+
53
+ **4. The "not yet materialized" half does not occur either, because the file ships with the
54
+ core.** `package.json` declares no `files[]` allowlist, so the whole directory ships.
55
+ `npm pack --dry-run` on this commit puts `modules/government/protected-surfaces.json` in the
56
+ tarball at 13,274 bytes, one of 3,419 files. Per [ADR 0108](0108-instance-composition-configurable-root.md)
57
+ §1 the core is consumed as a versioned npm dependency and `resolveCoreRoot()` resolves the core
58
+ package's own `modules/` inside `node_modules`. You cannot have `@bongos/core` without this
59
+ file. If it is absent, the install is damaged — a partial extract, a lossy copy, a `.gitignore`
60
+ that ate a JSON file — and that is a fact worth surfacing loudly, not smoothing over.
61
+
62
+ **The `resolveOptional` precedent does not transfer.** `resolveOptional` is real and used in
63
+ roughly a dozen places, but it is the module **seam/port resolver**: `api.resolveOptional('grade')`
64
+ lets one module degrade when another module's *runtime capability* is absent, which is exactly
65
+ right for an optional capability. A protected-surface registry is not a capability being
66
+ resolved at a seam. It is the static data the trust boundary is made of, read synchronously from
67
+ disk by enforcers that run where there is no server and no database — a git hook on a laptop, a
68
+ CI job. Borrowing the seam pattern here reads as consistency, but it silently converts "the
69
+ boundary is unreadable" into "there is no boundary".
70
+
71
+ ## Decision
72
+
73
+ **Fail-closed stays. The registry is not optional, and a missing registry is a broken install.**
74
+
75
+ Concretely:
76
+
77
+ 1. **No behavior change to `permission-path-check.js` or `module-scope-map.js`.** The throw is
78
+ correct, its placement is correct, and the header already documents why.
79
+ 2. **The task's described remedy is rejected**, on the grounds above — chiefly that an empty
80
+ protected set is not a degraded mode of a trust boundary, it is the absence of one.
81
+ 3. **The error message gains a remediation line.** This is the one genuine defect the report
82
+ surfaced: the message states the refusal and cites ADR 0043, but never tells the reader what
83
+ to *do*, which is precisely how a reader concludes the module must be optional and files this
84
+ task. It now names the likely cause and the fix.
85
+
86
+ The asymmetry is the whole point. A false "nothing is protected" is silent, permanent, and
87
+ grants authority; a false "this install is broken" is loud, immediate, and grants nothing. Only
88
+ one of those is safe to be wrong about.
89
+
90
+ ## Consequences
91
+
92
+ **A damaged install fails loudly at require time, and now says what to do about it.** That was
93
+ already true of the first half; the second half is new. The reader who hits this next gets
94
+ pointed at reinstalling the core rather than at weakening the check.
95
+
96
+ **The report will recur in some form, and that is acceptable.** Anyone meeting this ENOENT
97
+ cold sees a kernel file reaching into `modules/` and reasonably suspects a layering bug. This
98
+ ADR is the durable answer to that suspicion, which is why it exists rather than a won't-fix
99
+ note: the next agent to wonder can be pointed at a number.
100
+
101
+ **If a legitimate need for a core-only checkout without `modules/` ever appears, this decision
102
+ is the thing to revisit** — and the revisit is "where should the registry live", not "should it
103
+ be optional". Relocating the canonical data into `src/` and having the government module
104
+ contribute to it would preserve fail-closed while removing the cross-directory read. Nothing
105
+ today needs that, and doing it speculatively would move a trust primitive for no gain.
106
+
107
+ **What this does not decide:** whether other `modules/` reads from core (`route-rank-check.js`
108
+ reading `modules/*/routes/*.js`) should follow the same rule. They share the mechanism and the
109
+ justification, but each is its own trust surface and none is currently in question.
@@ -391,3 +391,4 @@ This keeps the decision history honest and traceable.
391
391
  | 0297 | [**A task’s creator may retire it, and that is the ownership axis’s first route** ([task 1002443](https://cloudbongos.com/builders#/task/1002443), goal 1000095 — owner request 2026-07-25: *"the creator of that task should be able to abandon that task directly inside of the UI"*). Attribution itself shipped earlier and elsewhere (`core_227`, [task 1003278](https://cloudbongos.com/builders#/task/1003278)); what had not moved is that **retiring a task was a rank power and nothing else** — `task.abandon` floors at `metic` (ADR 0152), so a sub-Metic builder could file a task and then could not withdraw their own proposal. The model already had the vocabulary and had never used it: ADR 0151 §3 defines an **ownership axis** where a `scope:'own'` key authorizes nothing until a handler supplies the row's owner, R100 made `requirePermission` refuse such a key outright so `.own` can never degrade into `.any`, and it recorded that **zero call sites passed one**. This is that axis’s first real consumer. **Decision: two authorities, only one rank-shaped** — `task.abandon` (metic, any task) OR new `task.abandon.own` (xenos, a task you created, while it is still backlog/ready/blocked). Route becomes `requireBuilder` + an in-handler decision (the `POST /goals/:id/members` shape), pin `metic+archon`→`any-builder` so it now catches an accidental **tightening** as loudly as a gate removal. **The two facts resolve in two places on purpose:** the RANK fact is request-scoped and resolves before the transaction (uncached, ADR 0016); the OWNER fact is a column that can change under us, so `created_by` joins `abandonTask`’s existing `FOR UPDATE` SELECT and a new optional `authorize` callback decides under that lock. Reading `created_by` in the route would be a TOCTOU window; resolving permissions inside the transaction would hold a row lock across a second pool acquire. The decision is a PURE module (`abandon-authz.js`, the `goal-route-authz.js` precedent) so every branch is pinned DB-free and the hall hint and server verdict provably read one rule. **A creator’s window stops at the grader** — completed/confirmed stay cross-owner, because past the grader a retire is a ledger correction, not an author changing their mind; nothing is subtracted from `task.abandon`, pinned by a test. `counterpart: ['task.abandon']` is declared not derived, verb `act` not `manage` — `manage` would silently dominate every future narrower `task.*.own` key (the verb-blind escalation task 1003195 closed). **A null creator belongs to nobody and that is load-bearing:** ~1,357 pre-`core_227` tasks plus seed/restock/discord vectors are unattributed, so the owner comparison is the government resolver’s own — injected, never re-implemented, since a duplicated ownership comparison is how one quietly widens — and one slip would open every historical task to a xenos grant at once. Fail-closed throughout: a missing `government` port, a throwing resolver, and anything but an explicit `{ok:true}` all deny, and `authorize` runs BEFORE the idempotent already-abandoned return and the shipped/active-claim guards so an unauthorized caller cannot probe a row through a 200. No guard removed. Does NOT widen edit/promote/dependency (still Metic), does not touch `task.merge`, and does not backfill attribution — the control simply does not appear on tasks predating `core_227`.](0297-a-tasks-creator-may-retire-it.md) | authority / lifecycle / ownership |
392
392
  | 0298 | [**The grader gets a reader lens: advisory, scoped to copy-bearing diffs, and it waits on a written voice** ([task 1003937](https://cloudbongos.com/builders#/task/1003937), goal 1000095 — Working area 6; owner decision 2026-09-17). Every artist review the cascade files opened with *“REVIEW THE GRADER … coach it”*, and it could not be followed: the v4 panel runs narc, quality, hacker and efficiency — completion, code quality, security, runtime cost — and not one of them reads a sentence for whether it is true, clear, in the project’s voice or worth a person’s attention. Four consecutive reviews hit the same wall and all four graded ships deservedly PASSED on the axes that were measured; the gate is not lenient, it is not pointed at this. `grader-prompt.js` already injects the craft’s written responsibility, but only when `discipline === ‘artist’`, so the unjudged case is copy shipped by someone whose craft is not the copy. **Decision: a fifth ADVISORY `reader` worker, on three settled terms.** It runs only where `modules/lifecycle/cascade.js`’s existing `hasCopyOrVisualWork` says the ship is copy-bearing — the same predicate the after-the-fact review uses, so the two can never disagree about coverage, and every other ship costs exactly what it costs today. It records `issues[]` and a `copy_score` and can never verdict `fail` or drag the panel to `concerns`, because [ADR 0241](0241-the-artist-gate-is-a-per-project-deploy-gate-that-reads-a-state.md) put the artist’s judgement on the DEPLOY and off the ship path; gating would need that ADR amended first. And it is NOT BUILT until the instance writes a voice guide — today the whole written standard is one dash rule in `DESIGN.md`, a tagline, a registry of which 1,860 strings exist, and one responsibility sentence, so a lens built now would record taste and call it a finding. Until the reader exists, STEP 1 asks the artist to WRITE DOWN what the grade could not see rather than to coach a lens that is not listening — followable today, and the corpus both the guide and the lens get written from. Nothing in the rubric, the active panel or any existing worker changes.](0298-the-reader-lens-is-advisory-and-waits-on-a-voice-guide.md) | grading / the artist’s loop / voice |
393
393
  | 0299 | [**The nod queue stays scoped to routed filings; the backlog walk is the widening** ([task 1003748](https://cloudbongos.com/builders#/task/1003748), goal 1000089). `listRoutedAwaitingNod` filters `origin='route'`, and on 2026-09-08 showed 11 of 232 backlog rows. Re-measured after `/backlog-review` (task 1003746) shipped: 115 backlog rows = 109 dep-gated + 2 spikes (not a person's decision) + 1 stranded + 3 awaiting a nod, all four walked by `/backlog-review`. **Decision: do not widen the card.** The omitted population already has a walkable review with the dependency predicate any widening needed; the copy says *reported* work, so the name is honest; a card slice would be a second copy of the walk's four-bucket partition. Revisit if the un-routed nod bucket outgrows a daily walk (then a count + link, derived from the same partition) or if the walk stops being run.](0299-the-nod-queue-stays-scoped-to-routed-filings.md) | ideas / backlog / attention |
394
+ | 0300 | [**A missing protected-surface registry is a broken install, not a module opt-out** ([task 1003662](https://cloudbongos.com/builders#/task/1003662), goal 1000091). Filed as a bug with a remedy attached: `require('./module-scope-map')` throws when `modules/government/protected-surfaces.json` is absent, so make it degrade to an empty protected set + a warning, matching `resolveOptional`. The throw reproduces, and the rest is wrong four times over. **The throw is not in the named file** — `module-scope-map.js` has no throw and no file read; it requires `permission-path-check.js`, whose `loadRegistry()` throws at module scope. **It is a written contract, not an oversight** — that file's header says FAIL-CLOSED in capitals and already pre-answers the layering worry (the read is `fs`, never `require`, so ADR 0083's one-way rule holds; static data, not code; `route-rank-check.js` reads `modules/*/routes/*.js` the same way). **Disabling the module cannot cause it** — enablement is a runtime flag, never a file deletion, and the read never consults it. **"Not yet materialized" cannot either** — no `files[]` allowlist, so `npm pack --dry-run` puts the 13,274-byte registry in the tarball; per ADR 0108 §1 you cannot have `@bongos/core` without it. The `resolveOptional` precedent does not transfer: that is the module **seam/port** resolver for a missing runtime *capability*, whereas this is the static data the boundary is made of, read where there is no server and no DB (a git hook, a CI job) — borrowing it converts "the boundary is unreadable" into "there is no boundary". **Decision: fail-closed stays, no behavior change, the remedy is rejected — and the one genuine defect is fixed: the message now names the cause and the fix** (reinstall the core; do not empty the set), because stating a refusal without a remedy is exactly how a reader concludes the module is optional and files this task. The asymmetry is the point: a false "nothing is protected" is silent, permanent and grants authority; a false "this install is broken" is loud and grants nothing. Revisit only if a core-only checkout without `modules/` becomes legitimate — and then the question is *where the registry lives*, not whether it is optional.](0300-a-missing-protected-surface-registry-is-a-broken-install.md) | governance / permissions |
@@ -2134,5 +2134,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2134
2134
  landed since 1.19.828 with no explicit bump. run 35234300466. (task 1002620)
2135
2135
  1.19.830 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2136
2136
  landed since 1.19.829 with no explicit bump. run 35235005422. (task 1002620)
2137
+ 1.19.831 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2138
+ landed since 1.19.830 with no explicit bump. run 35238952391. (task 1002620)
2139
+ 1.19.832 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2140
+ landed since 1.19.831 with no explicit bump. run 35240512571. (task 1002620)
2137
2141
  ---------------------------------------------------------------------------
2138
2142
  ```
@@ -18,6 +18,10 @@ That builds the image, starts Postgres, applies the build-system migrations (gam
18
18
 
19
19
  To stop and wipe: `docker compose down -v` (the `-v` drops the `cloudbongos-db` volume).
20
20
 
21
+ **Before your first sign-in, set `BONGOS_FIRST_ADMIN` to your GitHub login** (in `docker-compose.override.yml`, below). A new instance has no owner to approve anyone, so without it every sign-in is refused, yours included, and the page says so.
22
+
23
+ **Upgrading from a compose file older than task 1002717?** The demo database password changed from `cloudbongos` to `demo-pw`, and an existing volume keeps the old one. Either wipe it (`docker compose down -v`) or keep your data and update the password in place: `docker compose exec db psql -U cloudbongos -c "ALTER USER cloudbongos PASSWORD 'demo-pw'"`.
24
+
21
25
  ## Branding / configuring your instance
22
26
 
23
27
  The image is intentionally vanilla. To make it yours, supply your own config at runtime by mounting it over the build:
@@ -35,7 +39,7 @@ Author `config/branding.json` from the neutral starter — see [the branding con
35
39
 
36
40
  **Or scaffold it with `bongos init`** ([task #1204](https://example.com/builders#/task/1204)): a short interview (or `bongos init --from spec.json` for a hands-free / CI run) that writes both config files, **seeds your first version + its done-when goal**, and **files the human-only kickoff steps** (register a GitHub OAuth app, first owner sign-in, repo binding, admission) as real tracked tasks — so a fresh instance has a claimable queue on day one instead of an empty hall. It never invents your branding (it points you at the fill prompt for that). Preview the whole plan with `bongos init --dry-run`.
37
41
 
38
- **Production hosting, optional (the `provisioning` module — [ADR 0111](../adr/0111-instance-hosting-provisioning-module.md) §3).** If you enable the `provisioning` module and answer the interview's hosting questions (shape — co-tenant `$0` or a dedicated droplet — bring-your-own domain, credential ref, tier), init turns those answers into a real **provisioning request** (a queued `provision` intent the control-plane runner drains) instead of only filing a manual "production topology" task. The two irreducibly-human legs — the GitHub OAuth app and the first owner sign-in — stay guided/manual. If the module isn't enabled on the target instance, init falls back to the manual production-topology task, so nothing is silently skipped.
42
+ **Production hosting, optional (the `provisioning` module — [ADR 0111](../adr/0111-instance-hosting-provisioning-module.md) §3).** If you enable the `provisioning` module and answer the interview's hosting questions (shape — co-tenant `$0` or a dedicated droplet — bring-your-own domain, credential ref, tier), init turns those answers into a real **provisioning request** (a queued `provision` intent the control-plane runner drains) instead of only filing a manual "production topology" task. **A queued request does nothing until a runner drains it:** on the control plane that is a systemd timer running `node scripts/gds/provision.js run-intents --apply`. This Docker image cannot run that today (no git in the image, systemd and a DigitalOcean token assumed; task 1002718), so on a compose self-host a new project waits indefinitely. The two irreducibly-human legs — the GitHub OAuth app and the first owner sign-in — stay guided/manual. If the module isn't enabled on the target instance, init falls back to the manual production-topology task, so nothing is silently skipped.
39
43
 
40
44
  ## Configuration surface
41
45
 
@@ -46,6 +50,8 @@ Author `config/branding.json` from the neutral starter — see [the branding con
46
50
  | `MEDUSA_GDS_ONLY` | `1` = hard force-skip the OTB **instance-seed** migrations (`migrations/instance/`) — belt-and-suspenders over the default. A vanilla instance never wants OTB's versions/goals/criteria (task 1704 / [ADR 0105](../adr/0105-instance-seed-migrations-out-of-core.md)). | `1` |
47
51
  | `GDS_APPLY_INSTANCE_SEEDS` | `1` = opt IN to applying the bundled OTB instance-seed migrations. Default (unset) = **skip** — so a self-host never inherits OTB scope-truth. Only the owning instance sets this, and only when rebuilding its DB from scratch (DR). | unset |
48
52
  | `BONGOS_PUBLIC_ORIGIN` | public origin for OAuth callbacks etc. | `http://localhost:3000` |
53
+ | `BONGOS_FIRST_ADMIN` | GitHub login seated as the first Archon at their first sign-in. Unset, nobody can sign in, because nobody exists to approve them. | unset |
54
+ | `BONGOS_BUILDERS_ORIGIN` | origin the builders' hall is served on. The vanilla pack uses the public origin's host, which makes the hall the whole site: pages that only live on the apex, such as `/projects` and `/create`, return 404. Set a separate host (e.g. `http://builders.localhost:3000`) when you enable `provisioning`. | same host as the public origin |
49
55
 
50
56
  (The env prefix is the instance's `envPrefix` from its branding pack — `BONGOS` for vanilla.)
51
57
 
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.830",
3
+ "version": "1.19.832",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.830",
9
+ "version": "1.19.832",
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.830",
3
+ "version": "1.19.832",
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",
@@ -16,6 +16,7 @@
16
16
  const {
17
17
  upsertBuilder,
18
18
  seatFirstAdminIfUnclaimed,
19
+ archonExists,
19
20
  createSession,
20
21
  lookupSession,
21
22
  getBuilderById,
@@ -308,6 +309,20 @@ async function webAdmissionStatus(login) {
308
309
  return { admitted, expectedSoon: admitted || state.pending || isFirstAdmin };
309
310
  }
310
311
 
312
+ // instanceNeedsFirstAdmin — true while no Archon exists AND no firstAdmin is
313
+ // configured: the vanilla self-host, where every sign-in (the operator's own
314
+ // included) is refused and the generic page told them to ask an owner who does not
315
+ // exist (task 1002717). A DB error answers false, leaving the generic page.
316
+ async function instanceNeedsFirstAdmin() {
317
+ if (firstAdminLogin()) return false;
318
+ try {
319
+ return !(await archonExists());
320
+ } catch (err) {
321
+ log.error('[auth] instanceNeedsFirstAdmin', err);
322
+ return false;
323
+ }
324
+ }
325
+
311
326
  // First-admin bootstrap (R62 / task 1201, ADR 0062). The configured firstAdmin
312
327
  // login — an install-time env override (<PREFIX>_FIRST_ADMIN, incl. legacy) wins,
313
328
  // else branding.firstAdmin. Empty/unset (the vanilla starter) means "no one is
@@ -456,6 +471,7 @@ async function readApplicantProfileFromHub(githubId) {
456
471
  module.exports = {
457
472
  firstAdminLogin,
458
473
  firstTimeAdmissionBlocked,
474
+ instanceNeedsFirstAdmin,
459
475
  readApplicantProfileFromHub,
460
476
  // Re-exported, not defined here (task 1003579 moved it to project-door.js with
461
477
  // the two knobs it composes against). auth.js, the doorway's api.joinabilityMode
@@ -55,7 +55,7 @@ const ic = require('../instance-config');
55
55
  const { branding, userAgent } = require('../branding');
56
56
 
57
57
  const { SCOPE, authConfigured, grantedScopeCoversRepos, idpConfigured, idpEnabled, loadIdpConfig, scopeForWebFlow } = require('./auth-config.js');
58
- const { firstAdminLogin, firstTimeAdmissionBlocked, joinabilityMode, maybeSeatFirstAdmin, membershipKind, openEnrollmentEnabled, readApplicantProfileFromHub, webAdmissionStatus } = require('./auth-admission.js');
58
+ const { firstAdminLogin, firstTimeAdmissionBlocked, instanceNeedsFirstAdmin, joinabilityMode, maybeSeatFirstAdmin, membershipKind, openEnrollmentEnabled, readApplicantProfileFromHub, webAdmissionStatus } = require('./auth-admission.js');
59
59
  const { buildAuthorizeUrl, completeWebFlow, pollDeviceFlow, startDeviceFlow } = require('./auth-github.js');
60
60
  const { buildIdpAuthorizeUrl, completeDeviceExchange, completeIdpFlow, idpAuthorizeUrl, loadHubPublicKey, logoutTokenJtiSeenAndRecord, verifyHubAssertion, verifyHubLogoutToken } = require('./auth-idp.js');
61
61
  const { addDiscordGuildMember, buildDiscordAuthorizeUrl, discordAuthorizeUrl, discordBotConfigured, discordConfigured, exchangeDiscordCode, fetchDiscordUser, isValidDiscordUserId, loadDiscordBotToken, loadDiscordCreds, removeDiscordGuildMember } = require('./auth-discord.js');
@@ -895,6 +895,7 @@ module.exports = {
895
895
  joinabilityMode,
896
896
  readApplicantProfileFromHub,
897
897
  firstTimeAdmissionBlocked,
898
+ instanceNeedsFirstAdmin,
898
899
  webAdmissionStatus,
899
900
  firstAdminLogin,
900
901
  maybeSeatFirstAdmin,
@@ -212,6 +212,14 @@ async function seatFirstAdminIfUnclaimed(builderId, login) {
212
212
  return rowCount > 0;
213
213
  }
214
214
 
215
+ // archonExists — has anyone been seated as Archon yet? The same predicate the seat
216
+ // above guards on, so "no owner yet" is exactly the window in which a configured
217
+ // firstAdmin would still be seated (task 1002717).
218
+ async function archonExists() {
219
+ const { rows } = await pool.query(`SELECT EXISTS (SELECT 1 FROM builders WHERE rank = 'archon') AS has`);
220
+ return !!(rows[0] && rows[0].has);
221
+ }
222
+
215
223
  // getBuilderByGithubId — minimal identity read by stable GitHub numeric id (login
216
224
  // can change; github_id is the stable key). Backs the approval-gated web sign-in.
217
225
  async function getBuilderByGithubId(githubId) {
@@ -378,6 +386,7 @@ module.exports = {
378
386
  SESSION_TTL_MS,
379
387
  upsertBuilder,
380
388
  seatFirstAdminIfUnclaimed,
389
+ archonExists,
381
390
  getBuilderByGithubId,
382
391
  hasInvitedAccessRequest,
383
392
  accessStateByLogin,
@@ -73,16 +73,31 @@ const RANK_TIER = { xenos: 1, thetes: 2, metic: 3, archon: 4 };
73
73
  // hook.
74
74
  const REGISTRY_PATH = path.join(__dirname, '..', '..', 'modules', 'government', 'protected-surfaces.json');
75
75
 
76
+ // WHY THE REFUSAL NAMES A FIX: the FAIL-CLOSED note above tells the reader what
77
+ // this throw refuses to do, but nothing told them what to DO — and a reader who
78
+ // meets this cold sees a core file reaching into modules/ and concludes the
79
+ // module must be optional. That is not hypothetical: task 1003662 was filed
80
+ // asking for exactly the degrade-to-empty-set this refuses, on that reading. The
81
+ // registry SHIPS INSIDE @bongos/core (modules/ is in the published tarball, ADR
82
+ // 0108 §1), so it cannot be absent on an intact install and disabling the
83
+ // government module cannot remove it — a module's enable/disable state is a
84
+ // runtime flag, never a file deletion. Absent therefore means the install is
85
+ // damaged, and reinstalling is the fix. Full reasoning: ADR 0300.
86
+ const REMEDIATION =
87
+ 'modules/government/protected-surfaces.json ships inside @bongos/core, so an intact install always has it '
88
+ + '— this is a damaged install (partial extract or lossy copy), not a disabled module. Reinstall/repair the '
89
+ + 'core package; do NOT degrade to an empty protected set (ADR 0043, ADR 0300).';
90
+
76
91
  function loadRegistry(file) {
77
92
  let raw;
78
93
  try {
79
94
  raw = JSON.parse(fs.readFileSync(file, 'utf8'));
80
95
  } catch (e) {
81
- throw new Error(`protected-surface registry unreadable at ${file}: ${e.message}. Refusing to run with an empty protected set (ADR 0043).`);
96
+ throw new Error(`protected-surface registry unreadable at ${file}: ${e.message}. Refusing to run with an empty protected set (ADR 0043). ${REMEDIATION}`);
82
97
  }
83
98
  const surfaces = Array.isArray(raw && raw.surfaces) ? raw.surfaces : null;
84
99
  if (!surfaces || surfaces.length === 0) {
85
- throw new Error(`protected-surface registry ${file} declares no surfaces. Refusing to run with an empty protected set (ADR 0043).`);
100
+ throw new Error(`protected-surface registry ${file} declares no surfaces. Refusing to run with an empty protected set (ADR 0043). ${REMEDIATION}`);
86
101
  }
87
102
  const defaultFloor = raw.floor;
88
103
  const defaultPermission = raw.permission;
@@ -40,7 +40,7 @@ const { pairStore } = require('../app-pair');
40
40
  const { publicOrigin, parseCookie, validateOrRespond } = require('./_helpers');
41
41
  const { accountExistenceReadRateLimit } = require('../middleware/rate-limit');
42
42
  const { branding } = require('../../branding');
43
- const { resolveEnv } = require('../../instance-config');
43
+ const { resolveEnv, envPrefix } = require('../../instance-config');
44
44
  const seams = require('../../module-seams');
45
45
  // task 1003208: structured logging — was console.*. A core file logs through
46
46
  // the kernel logger directly; only MODULES go via api.logger (the doorway).
@@ -738,6 +738,19 @@ module.exports = function buildAuthRouter() {
738
738
  if (expectedSoon) {
739
739
  return res.status(202).type('text/html').send(renderAccessPendingPage(err.login, recoveryStartHref(req)));
740
740
  }
741
+ // task 1002717: with no owner and no FIRST_ADMIN, everyone is refused, the
742
+ // operator included. Name the fix instead of pointing at an owner who does not exist.
743
+ if (await auth.instanceNeedsFirstAdmin()) {
744
+ return res.status(403).type('text/html').send(renderAuthErrorPage(
745
+ 'This instance has no owner yet',
746
+ `Nobody can sign in until an owner is configured. If you run this instance, set ${envPrefix()}_FIRST_ADMIN to your GitHub login, restart it, and sign in again. That account becomes the first Archon and can approve everyone after it.`,
747
+ {
748
+ subtitle: 'A new instance needs its owner set before anyone can sign in.',
749
+ startHref: recoveryStartHref(req),
750
+ note: 'The self-host recipe (docs/recipes/self-host.md) lists this setting.',
751
+ },
752
+ ));
753
+ }
741
754
  // The founder-shaped branch (only reachable with open enrollment OFF):
742
755
  // a stranger who was on their way to create a project is told the
743
756
  // approval brings them back to it — the return cookie says where.
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.830'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.832'; // 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');
@@ -0,0 +1,30 @@
1
+ // tests/compose_demo_credentials.mjs — the self-host demo credentials never read as a live secret (task 1002717).
2
+ //
3
+ // docker-compose.yml used PGPASSWORD=cloudbongos. The env-secret leak scan
4
+ // (collectEnvSecrets) treats any secret-named env value of 8+ characters as a live
5
+ // credential, and package-core refuses to build when a functional file contains one.
6
+ // Inside the compose container that meant every file mentioning the brand name, and
7
+ // docker-compose.yml itself ships in the core, so any long demo value trips it.
8
+ //
9
+ // Run: node --test tests/compose_demo_credentials.mjs
10
+
11
+ import assert from 'node:assert/strict';
12
+ import { test } from 'node:test';
13
+ import fs from 'node:fs';
14
+ import { createRequire } from 'node:module';
15
+
16
+ const require = createRequire(import.meta.url);
17
+ const { collectEnvSecrets } = require('../modules/security/secret-scrub.js');
18
+
19
+ const compose = fs.readFileSync(new URL('../docker-compose.yml', import.meta.url), 'utf8');
20
+ const env = {};
21
+ for (const m of compose.matchAll(/^\s+([A-Z][A-Z0-9_]*):\s*"?([^"\r\n#]*?)"?\s*$/gm)) env[m[1]] = m[2];
22
+
23
+ test('the compose file declares the database password for both services', () => {
24
+ assert.ok(env.POSTGRES_PASSWORD, 'db service password');
25
+ assert.equal(env.PGPASSWORD, env.POSTGRES_PASSWORD, 'the app connects with the password the db was created with');
26
+ });
27
+
28
+ test('no compose env value is collected as a live secret', () => {
29
+ assert.deepEqual(collectEnvSecrets(env), []);
30
+ });
@@ -0,0 +1,80 @@
1
+ // tests/first_admin_unset_signin.mjs — a fresh instance with no owner says so (task 1002717).
2
+ //
3
+ // A vanilla docker-compose self-host configures no FIRST_ADMIN, so the very first
4
+ // sign-in (the operator's own) is refused, and the page it lands on said "ask the
5
+ // owner to confirm your request" on an instance that has no owner. The callback
6
+ // now asks auth.instanceNeedsFirstAdmin() first and, when it is true, names the fix.
7
+ //
8
+ // The stub goes into require.cache BEFORE auth.js loads: auth-admission destructures
9
+ // its db-kernel bindings at require time.
10
+ //
11
+ // Run: node --test tests/first_admin_unset_signin.mjs
12
+
13
+ import assert from 'node:assert/strict';
14
+ import { test } from 'node:test';
15
+ import fs from 'node:fs';
16
+ import { createRequire } from 'node:module';
17
+
18
+ const require = createRequire(import.meta.url);
19
+
20
+ const kernelPath = require.resolve('../src/bongos/db-kernel.js');
21
+ const realKernel = require(kernelPath);
22
+ let archon = false;
23
+ let dbThrows = false;
24
+ require.cache[kernelPath].exports = {
25
+ ...realKernel,
26
+ archonExists: async () => {
27
+ if (dbThrows) throw new Error('simulated DB outage');
28
+ return archon;
29
+ },
30
+ };
31
+
32
+ // Any prefix resolveEnv reads — the instance's own and the legacy spellings.
33
+ for (const k of Object.keys(process.env)) if (k.endsWith('_FIRST_ADMIN')) delete process.env[k];
34
+
35
+ const auth = require('../src/bongos/auth.js');
36
+
37
+ test('precondition: this pack configures no firstAdmin', () => {
38
+ assert.equal(auth.firstAdminLogin(), null);
39
+ });
40
+
41
+ test('no owner and no FIRST_ADMIN: the instance needs one', async () => {
42
+ archon = false;
43
+ assert.equal(await auth.instanceNeedsFirstAdmin(), true);
44
+ });
45
+
46
+ test('an Archon exists: an established instance never shows the no-owner page', async () => {
47
+ archon = true;
48
+ assert.equal(await auth.instanceNeedsFirstAdmin(), false);
49
+ });
50
+
51
+ test('FIRST_ADMIN is configured: the bootstrap will seat them, so no', async () => {
52
+ archon = false;
53
+ process.env.OTB_FIRST_ADMIN = 'founding-owner';
54
+ try {
55
+ assert.equal(await auth.instanceNeedsFirstAdmin(), false);
56
+ } finally {
57
+ delete process.env.OTB_FIRST_ADMIN;
58
+ }
59
+ });
60
+
61
+ test('a DB error answers false, leaving the generic page', async () => {
62
+ dbThrows = true;
63
+ try {
64
+ assert.equal(await auth.instanceNeedsFirstAdmin(), false);
65
+ } finally {
66
+ dbThrows = false;
67
+ }
68
+ });
69
+
70
+ test('the callback checks for a missing owner before the generic not-approved pages', () => {
71
+ const src = fs.readFileSync(new URL('../src/bongos/routes/auth.js', import.meta.url), 'utf8').replace(/\r\n/g, '\n');
72
+ const waiting = src.indexOf('if (expectedSoon) {');
73
+ const noOwner = src.indexOf('if (await auth.instanceNeedsFirstAdmin()) {');
74
+ const inviteOnly = src.indexOf("if (effectiveJoinDoor() !== 'apply') {");
75
+ assert.ok(waiting > 0 && noOwner > 0 && inviteOnly > 0, 'all three branches present');
76
+ assert.ok(waiting < noOwner && noOwner < inviteOnly, 'waiting page, then no-owner page, then the refusals');
77
+ const branch = src.slice(noOwner, inviteOnly);
78
+ assert.match(branch, /'This instance has no owner yet'/);
79
+ assert.match(branch, /\$\{envPrefix\(\)\}_FIRST_ADMIN/, 'names the setting under this instance\'s own env prefix');
80
+ });
@@ -742,6 +742,55 @@ test('a missing registry fails CLOSED — it never degrades to "nothing is prote
742
742
  }, /unreadable|Refusing/i);
743
743
  });
744
744
 
745
+ // The refusal text, not just the refusal. Run the kernel load in a cold subprocess
746
+ // with a patched read and hand back stderr, so a message assertion sees what an
747
+ // operator would actually read.
748
+ function refusalStderr(patch) {
749
+ try {
750
+ execFileSync(process.execPath, ['-e',
751
+ "const fs=require('node:fs');const real=fs.readFileSync;" + patch
752
+ + "require('./src/bongos/permission-path-check');",
753
+ ], { cwd: ROOT, stdio: 'pipe' });
754
+ } catch (e) {
755
+ return String(e.stderr || '');
756
+ }
757
+ return '';
758
+ }
759
+
760
+ // WHY ASSERT ON THE WORDS (task 1003662, ADR 0300). The sibling probe above proves
761
+ // the load REFUSES; it cannot tell whether the refusal is actionable. It was not:
762
+ // the message stated the refusal and cited ADR 0043 but never named a fix, and a
763
+ // reader who meets it cold sees a core file reaching into modules/ and concludes
764
+ // the module must be optional. Task 1003662 was filed on exactly that reading,
765
+ // asking for the degrade-to-empty-set this refuses. The remediation line is the
766
+ // fix for that, so it is part of the contract and not decoration.
767
+ //
768
+ // The EMPTY-surfaces branch is covered here for the first time. Until now only the
769
+ // unreadable branch had a probe, so `surfaces: []` — the shape an out-of-band
770
+ // registry write most plausibly produces — could have degraded silently.
771
+ test('both refusal branches name the fix, not just the refusal (ADR 0300)', () => {
772
+ const gone = refusalStderr(
773
+ "fs.readFileSync=(p,...r)=>{ if(String(p).includes('protected-surfaces.json')) throw new Error('gone'); return real(p,...r); };",
774
+ );
775
+ const empty = refusalStderr(
776
+ "fs.readFileSync=(p,...r)=>(String(p).includes('protected-surfaces.json') ? '{\"surfaces\":[]}' : real(p,...r));",
777
+ );
778
+
779
+ assert.match(empty, /declares no surfaces/,
780
+ 'an empty surfaces[] must refuse too — not only an unreadable file');
781
+
782
+ for (const [name, text] of [['unreadable', gone], ['empty', empty]]) {
783
+ assert.match(text, /Refusing to run with an empty protected set/,
784
+ `the ${name} branch must still state the refusal`);
785
+ assert.match(text, /ships inside @bongos\/core/,
786
+ `the ${name} branch must say the registry ships with the core (so absent means damaged, not disabled)`);
787
+ assert.match(text, /[Rr]einstall/,
788
+ `the ${name} branch must name the action that fixes it`);
789
+ assert.match(text, /ADR 0300/,
790
+ `the ${name} branch must cite the ADR that rejected degrading to an empty set`);
791
+ }
792
+ });
793
+
745
794
  // ---------- the matcher, driven by registry data ----------
746
795
 
747
796
  test('a known path is protected at the right floor; an unknown path is not', () => {