@bongos/core 1.19.568 → 1.19.569

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.568",
6
- "core_contract": "1.19.568",
7
- "source_commit": "80d5678233ede0a618699eacc63a9a06a0a2a510",
5
+ "core_version": "1.19.569",
6
+ "core_contract": "1.19.569",
7
+ "source_commit": "5cdee5559949c616ec9d68e725884fb87424850e",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-06T20:30:33.304Z",
9
+ "built_at": "2026-09-06T20:34:30.476Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 448,
12
+ "docs_redacted": 449,
13
13
  "agent_docs_stubbed": 24,
14
- "functional_verbatim": 2037,
14
+ "functional_verbatim": 2038,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2509,
20
- "tree_sha256": "904d5ba5cfe93e9add9a20263710481eb5b6619d9997eae48c9b22c1fc0eee00",
19
+ "file_count": 2511,
20
+ "tree_sha256": "5c3c486ba0465ca763a11d15487bb2b540558af8430a93ea27a07323d75352e1",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -1794,10 +1794,15 @@
1794
1794
  "mode": "0000644",
1795
1795
  "sha256": "2c82050a733a0a82dde5079bbc486fc3378b2be954817a811aab92b9e46c9c5b"
1796
1796
  },
1797
+ {
1798
+ "path": "docs/adr/0256-the-visibility-ceiling-is-the-sign-in-gate-too-and-the-reader-moves-to-the-composer.md",
1799
+ "mode": "0000644",
1800
+ "sha256": "4b7d5b4ca5ad92a128235a6c3407617f2d8b34c26ed6e1c8b2a72e63eed7adac"
1801
+ },
1797
1802
  {
1798
1803
  "path": "docs/adr/README.md",
1799
1804
  "mode": "0000644",
1800
- "sha256": "44acc139af799118137f253f6eb2a43863b5b33edee05716e073cf4aa8a43859"
1805
+ "sha256": "52422b934d0b3245605ec0913049be65768a814a6f9aee5f7ad8149c4221a093"
1801
1806
  },
1802
1807
  {
1803
1808
  "path": "docs/api-reference.md",
@@ -2672,7 +2677,7 @@
2672
2677
  {
2673
2678
  "path": "docs/file-map.md",
2674
2679
  "mode": "0000644",
2675
- "sha256": "8b756e1acdec79324f026463292134b1e80613f207015d65fe5f1d158476c306"
2680
+ "sha256": "dd40cd29025789ec5a9447aa44ec6c7d3bee6aaae183c65713a6a3ca8ed8275b"
2676
2681
  },
2677
2682
  {
2678
2683
  "path": "docs/handoff-template.md",
@@ -2682,7 +2687,7 @@
2682
2687
  {
2683
2688
  "path": "docs/module-api-changelog.md",
2684
2689
  "mode": "0000644",
2685
- "sha256": "6c7437c85c872c6cece307ea499b855f6fa6b54ce91059168a255c70edf7b5b9"
2690
+ "sha256": "5942f6c9fae093d6fbb85032ca496e5f0549cc5044d8ffa4ecb90c3c9d2695dd"
2686
2691
  },
2687
2692
  {
2688
2693
  "path": "docs/modules-contract.md",
@@ -7532,12 +7537,12 @@
7532
7537
  {
7533
7538
  "path": "package-lock.json",
7534
7539
  "mode": "0000644",
7535
- "sha256": "8ada761ecbd4a9f7adad006ce8f1d51fcd120001c4f7cdfd305d03c6e61a72fd"
7540
+ "sha256": "e2279fd35b142a93a9037638445d5e6e616c69cd8322d05fad2aa228a5de3515"
7536
7541
  },
7537
7542
  {
7538
7543
  "path": "package.json",
7539
7544
  "mode": "0000644",
7540
- "sha256": "b2a6bf7c81f157286bbccad28bbd150aee2a5ee74b79dc1f045028280f0b9c51"
7545
+ "sha256": "1ba4dccdc1e8d591a783bdac3b0ac4f0d121ad687fef73347ac56e32e69524f7"
7541
7546
  },
7542
7547
  {
7543
7548
  "path": "public-docs/index.html",
@@ -7977,7 +7982,7 @@
7977
7982
  {
7978
7983
  "path": "scripts/gds/fitness.js",
7979
7984
  "mode": "0000644",
7980
- "sha256": "c825842ed0e4bf34fa9fa7bc7e711967d1aa0efdde4132253c51231c6ccbd22e"
7985
+ "sha256": "a93e0d79fe1d557d09711e9b9bb62ffc7687b91ee2981b71eb89176eb0fc2ce6"
7981
7986
  },
7982
7987
  {
7983
7988
  "path": "scripts/gds/gate-review.js",
@@ -8947,7 +8952,7 @@
8947
8952
  {
8948
8953
  "path": "src/bongos/auth-admission.js",
8949
8954
  "mode": "0000644",
8950
- "sha256": "46dc4adc21f138b4d9fdfe06c85f71d591a81357050d48d72b6272ba86a09d3b"
8955
+ "sha256": "8dc4cc0ab956d387b8044c22e83fce883f2f0129ed27c03503f7348e9d4f00bb"
8951
8956
  },
8952
8957
  {
8953
8958
  "path": "src/bongos/auth-config.js",
@@ -9082,7 +9087,7 @@
9082
9087
  {
9083
9088
  "path": "src/bongos/project-door.js",
9084
9089
  "mode": "0000644",
9085
- "sha256": "045c0edceb0f71c3b488471ffb3a1c20c58014d0592c9ac44572245a7eb08960"
9090
+ "sha256": "60a342af5b1d4aaeb781faf5bfea75d69a32b9bba71d0740ccc4ca1a9af2bba4"
9086
9091
  },
9087
9092
  {
9088
9093
  "path": "src/bongos/project-settings.js",
@@ -9122,7 +9127,7 @@
9122
9127
  {
9123
9128
  "path": "src/bongos/routes/auth.js",
9124
9129
  "mode": "0000644",
9125
- "sha256": "4c26db2cae7070a44523053d369da83a41ed38677b46292da927b061a398a4eb"
9130
+ "sha256": "f7e24d83d5fceceb33eacc7d82ee8136a2177bb190e5b912e5857ffc07090009"
9126
9131
  },
9127
9132
  {
9128
9133
  "path": "src/bongos/routes/backup.js",
@@ -9147,7 +9152,7 @@
9147
9152
  {
9148
9153
  "path": "src/bongos/routes/instance.js",
9149
9154
  "mode": "0000644",
9150
- "sha256": "5b85f187b94282cd1ec5ece022238c4b30048f999c4347f19081bbaed32795fb"
9155
+ "sha256": "c3bfdf5f1242fe4bbbb51ea79401dd5ae09cca95adc475e0dc0e5c76055677c4"
9151
9156
  },
9152
9157
  {
9153
9158
  "path": "src/bongos/routes/live.js",
@@ -9217,7 +9222,7 @@
9217
9222
  {
9218
9223
  "path": "src/branding.js",
9219
9224
  "mode": "0000644",
9220
- "sha256": "8885ee12599cdfdaf9bdb073185c83a5532bdc46d07edaeab42e36ccdc12f8e1"
9225
+ "sha256": "2bbb5774c9ae8659b4d5ce9b62cb558d28d036b2aa0075f9329ae85c15734a2d"
9221
9226
  },
9222
9227
  {
9223
9228
  "path": "src/build-info.js",
@@ -9237,7 +9242,7 @@
9237
9242
  {
9238
9243
  "path": "src/module-api.js",
9239
9244
  "mode": "0000644",
9240
- "sha256": "4e9a58070edc3a59a9915b3f4048ad973460ee7a6dda9d647ee3e60f8ef43b92"
9245
+ "sha256": "6c1005562c9950b7cee858382e3cfd643f5c2be27918859de46927c2fbd29c06"
9241
9246
  },
9242
9247
  {
9243
9248
  "path": "src/module-loader/catalog.js",
@@ -9309,6 +9314,11 @@
9309
9314
  "mode": "0000644",
9310
9315
  "sha256": "f7434d510d656e5081f735d1f9356ecb11e8c3630a86a86137c3290134541c8d"
9311
9316
  },
9317
+ {
9318
+ "path": "tests/admission_ceiling_gate.mjs",
9319
+ "mode": "0000644",
9320
+ "sha256": "79c216e3ad3d91388a4a48f4ec5f3af9cf440d82791f05a1eb89860573be8dff"
9321
+ },
9312
9322
  {
9313
9323
  "path": "tests/adopt_detect.mjs",
9314
9324
  "mode": "0000644",
@@ -11017,7 +11027,7 @@
11017
11027
  {
11018
11028
  "path": "tests/joinability_invite_only.mjs",
11019
11029
  "mode": "0000644",
11020
- "sha256": "5311e076e8a72b993a235e57ebc54c9ecd529ccd31b65609432677a12e15e85d"
11030
+ "sha256": "0c2c540a794fcdafbdf4c496f2dd3efc508edfb9f30f88a6c0f977fc99bb75cc"
11021
11031
  },
11022
11032
  {
11023
11033
  "path": "tests/joinability_open.mjs",
@@ -0,0 +1,204 @@
1
+ # ADR 0256 — The visibility ceiling binds the SIGN-IN gate too, and the knob reader moves to the composer
2
+
3
+ - **Status:** accepted
4
+ - **Date:** 2026-09-06
5
+ - **Task:** [task 1003579](https://cloudbongos.com/builders#/task/1003579) (BONGOS-V1, goal 1000046 — *Project creation*, criterion `proj-join-policy`)
6
+ - **Deciders:** Claude, under the Archon's standing scope
7
+ - **Closes:** [ADR 0247](<redacted>.md)'s written *KNOWN LIMIT* — *"the door narrows, the sign-in gate does not"*
8
+ - **Implements:** [ADR 0182](<redacted>.md) D6's **Self-join** column (the half ADR 0247 enforced was the *Applications* column)
9
+ - **Related:** [ADR 0194](<redacted>.md) (the one-reader move this repeats one level up; D2's fail-closed rule) · [ADR 0192](<redacted>.md) §2 (where that fail-closed rule comes from) · [ADR 0050](<redacted>.md) (the legacy `OPEN_XENOS_ENROLLMENT` flag) · [ADR 0190](<redacted>.md) (how a knob reaches the instance)
10
+
11
+ ---
12
+
13
+ ## Context
14
+
15
+ ADR 0182 D6 gave the project plane a two-column table — who may **self-join**, and who
16
+ may **apply** — and one composition rule: *visibility sets the ceiling; join policy may
17
+ only narrow it.* ADR 0247 built that composition (`src/bongos/project-door.js`,
18
+ `projectJoinDoor()`) and wired it to the **applications** column: the public
19
+ `POST /access-requests`, and through it the hall's landing form, the CLI and the hub's
20
+ join relay.
21
+
22
+ It did not wire the **self-join** column, and said so in as many words:
23
+
24
+ > **KNOWN LIMIT — the door narrows, the sign-in gate does not.** `openEnrollmentEnabled()`
25
+ > is still `joinabilityMode() === 'open'` … Closing it means composing the ceiling into
26
+ > `src/bongos/auth-admission.js`, a kernel admission surface this task was scoped out of
27
+ > touching.
28
+
29
+ So an owner who took their project off the map — `visibility: private`, or `stealth`, or
30
+ `joinGrant: view` — while leaving `joinability: open` got two surfaces answering the same
31
+ stranger differently: the application endpoint queued a request for review, and a
32
+ first-time GitHub sign-in handed the same person a `xenos` account with no request and no
33
+ invite. Half of D6's table held only where the owner had never explicitly opened enrolment.
34
+
35
+ The gap's direction was safe — the door was *stricter* than the gate, never looser, so
36
+ nobody was admitted whom `joinability` alone had not already admitted — which is why
37
+ ADR 0247 shipped it as a written limit rather than halting.
38
+
39
+ ## Decision 1 — `openEnrollmentEnabled()` reads the composed door, and nothing else moves
40
+
41
+ ```js
42
+ function openEnrollmentEnabled(deps = {}) {
43
+ return effectiveJoinDoor(deps) === 'open';
44
+ }
45
+ ```
46
+
47
+ That one expression is the whole behavioural change. It is deliberately the *same* SR-19
48
+ move ADR 0194 D1 made when `joinabilityMode` replaced the raw env flag, one level further
49
+ up: the four sign-in call sites (`auth-github.js` web + device, `auth-idp.js` federated
50
+ twins), `firstTimeAdmissionBlocked`, `webAdmissionStatus` and the manifest's
51
+ `admission.policy` all read this one function, so they follow without being touched. Two
52
+ surfaces that ask the same function cannot answer one stranger differently; two surfaces
53
+ that each read "the relevant knob" always eventually do.
54
+
55
+ **Nothing changes for any existing project.** `src/branding.js` normalises an absent
56
+ `visibility` to `public` and an absent `joinGrant` to `full` (ADR 0247 D1's "the default
57
+ is the value that narrows nothing"), so `public` + `full` puts the ceiling at `open` and
58
+ the composed door is `joinability` verbatim. Every live project is `public`; a self-hosted
59
+ instance carrying the legacy `OPEN_XENOS_ENROLLMENT` flag (ADR 0050) still resolves to
60
+ `open` through the same branch it always did.
61
+
62
+ **The ceiling closes self-enrolment and nothing else.** An invited first-timer, an
63
+ existing builder and the configured `firstAdmin` are all admitted to a `private` or
64
+ `stealth` project exactly as before — `firstTimeAdmissionBlocked`'s other three terms are
65
+ untouched. An owner taking their project off the map is closing the front door, not
66
+ evicting the people they let in, and a `private` project that locked out its own founder
67
+ would be the R62 bootstrap dead-end all over again.
68
+
69
+ ## Decision 2 — The manifest's `admission.policy` reports the GATE; `brand.project` keeps reporting the SETTING
70
+
71
+ `GET /instance` carries both, and after this change they can differ: a `private` +
72
+ `joinability: open` project reports `brand.project.joinability: "open"` and
73
+ `admission.policy: "invite-only"`.
74
+
75
+ That is not a contradiction, it is the two questions the manifest has always been
76
+ answering — *what did the owner set?* and *what will the gate do?* — finally giving
77
+ honest answers to both. Before this, `admission.policy` said `open` while the gate would
78
+ have refused, and a CLI reading it to decide whether to offer `bongos login` believed it.
79
+
80
+ **The narrowed value leaks no state.** `admission.policy` is binary and `invite-only` is
81
+ already what every `apply` project reports, so `private`, `stealth`, a `view` grant and a
82
+ plain `invite_only` project remain indistinguishable through it — ADR 0247 D3's rule that
83
+ a refusal may never be read backwards into a state, preserved because the field had only
84
+ two values to begin with. Keeping the read-back honest on `brand.project` is the other
85
+ half of the same rule (ADR 0194 D1: the manage page's *"did my save take effect?"* must
86
+ show the owner's own value, which is why the ceiling is composed on the instance rather
87
+ than folded into what the platform pushes).
88
+
89
+ ## Decision 3 — The knob reader moves to the composer, because the alternative is a require cycle
90
+
91
+ `joinabilityMode()` and its `legacyOpenEnrollmentFlag()` helper moved from
92
+ `src/bongos/auth-admission.js` into `src/bongos/project-door.js`. `auth-admission`
93
+ **re-exports** `joinabilityMode`, so `auth.js`, the doorway's `api.joinabilityMode` and
94
+ every existing import keep resolving; the doorway surface is unchanged, so this needs no
95
+ `module.json` floor bump (contrast ADR 0194 D7, which added an export).
96
+
97
+ The reason is mechanical and worth writing down, because the obvious patch does not
98
+ compile past the gate. `project-door.js` resolved its own `joinability` input with a lazy
99
+ `require('./auth-admission')`. The moment the admission gate requires the door back, the
100
+ two files point at each other — and `fitness-ratchets.js` counts require cycles from the
101
+ **source text**, so a lazy require inside a function body is still an edge. The
102
+ `require_cycle_count` ratchet is shrink-only at 4; adding a fifth is a hard build failure,
103
+ and it would be right to fail: a cycle here is a real one, not a measurement artifact.
104
+
105
+ Three ways out were considered:
106
+
107
+ 1. **Duplicate the composition in the gate.** Rejected on sight — it is the exact drift
108
+ SR-19, ADR 0194 and ADR 0247 were each written to prevent, and the ratchet would only
109
+ be measuring that we had hidden it.
110
+ 2. **Make `joinabilityMode` a required argument of `effectiveJoinDoor`, supplied by the
111
+ doorway.** Zero new files and no cycle, but it turns `effectiveJoinDoor()` — the
112
+ no-argument call ADR 0247 D2 published as *the* one call a door surface makes — into a
113
+ footgun that silently answers `invite_only` for any future caller who forgets.
114
+ 3. **Move the reader to the composer** (chosen). The file whose header already says it is
115
+ *"composed from every knob that can close it"* now owns the readers for all of them.
116
+ The dependency runs one way — gate → door → branding/env — `effectiveJoinDoor()` keeps
117
+ working with no arguments, and one lazy cross-require disappears rather than a second
118
+ appearing.
119
+
120
+ `joinabilityMode` was only ever in the admission file because the admission gate was its
121
+ first caller (ADR 0194); the layering was accidental, and this task is what surfaced it.
122
+ Its `[auth]` log prefix is kept verbatim across the move — operators grep sign-in refusals
123
+ by it, and the child logger's `src` field already says which file emitted the line.
124
+
125
+ ## Decision 4 — `locked` still cannot fire from the sign-in gate
126
+
127
+ `openEnrollmentEnabled` passes **no** `publishable` verdict, so the tri-state stays `null`
128
+ — *no verdict* — and the door is answered on the three stored knobs alone. ADR 0247 D4's
129
+ finding has not changed: neither plane holds a live, correct dark-matter verdict, the
130
+ instance's branding pack carries no `description`, and the hub's cached copy says every
131
+ live project is dark matter. A gate that read that column would refuse every first sign-in
132
+ on the platform. The `deps` object is forwarded verbatim, so wiring a verdict later is one
133
+ argument at one call site, not a redesign.
134
+
135
+ ## Decision 5 — The sign-in refusal page tests the door, not the knob
136
+
137
+ `routes/auth.js` chose between its two refusal pages on
138
+ `joinabilityMode() === 'invite_only'`, which was equivalent to the door only while the
139
+ knob *was* the door. Under Decision 1 the ceiling can close a project whose `joinability`
140
+ still reads `apply` — `stealth`, or a `view` grant — and that page would have handed
141
+ the stranger a "request access at the hall" form the application endpoint 403s. The test
142
+ becomes `effectiveJoinDoor() !== 'apply'`: `apply` is the only door that accepts an
143
+ application, so everything else lands on the invite-only page.
144
+
145
+ **Both closed doors show the same sentence, and that is the point.** Distinguishing them
146
+ would be the state read-out ADR 0247 D3 forbids; showing one honest "this project takes no
147
+ applications, an invite is the way in" for all of them is the same indistinguishability
148
+ the application endpoint already has. `open` cannot reach this branch (the gate would have
149
+ admitted) and `locked` cannot fire at all (D4), so in practice it is `invite_only`.
150
+
151
+ ## What was deliberately not built
152
+
153
+ - **No new refusal COPY.** The two existing pages are reused verbatim; only which one is
154
+ chosen changed (Decision 5). Saying *"this project is private"* would be the state
155
+ read-out ADR 0247 D3 forbids, and a sentence written for this case alone would make the
156
+ refusals distinguishable — the same leak by another route.
157
+ - **The hall landing's door copy is left on the raw knob — a NAMED limit, in the same
158
+ shape ADR 0247 named this one.** `modules/hall-ui/public/hall-render.js` renders one
159
+ landing per `window.__BRANDING__.project.joinability`, and its own comment already calls
160
+ that *"cosmetic — the server refuses on its own terms either way"*. After this change
161
+ a `private` + `open` project's landing still reads "sign in and you're in" while the gate
162
+ refuses, so the visitor meets the truth one click later instead of on the page. Fixing it
163
+ properly means the server injecting the COMPOSED door into the pack the browser reads
164
+ — re-deriving the composition in browser JS is the drift this whole line of ADRs
165
+ exists to prevent — which is a public-field addition, not a side effect of this one.
166
+ It is reachable only on a project whose owner has taken it off the map, and every live
167
+ project is `public` (ADR 0247's own Consequences).
168
+ - **No `webAdmissionStatus` special case.** It reads `openEnrollmentEnabled()` like the
169
+ gate does, and `tests/web_admission_status.mjs` already sweeps the input space asserting
170
+ the probe and the gate agree. The composition arrives there for free; a second code path
171
+ would be a second thing to keep in sync.
172
+ - **No manage-page control for the grant.** Still a rendering task, unchanged from
173
+ ADR 0247's "what was deliberately not built".
174
+
175
+ ## Consequences
176
+
177
+ - ADR 0182 D6's table is enforced in **both** columns, from one function, on every surface
178
+ that admits or refuses a stranger.
179
+ - `tests/admission_ceiling_gate.mjs` boots a real instance on `visibility: private` +
180
+ `joinability: open` — the exact pair ADR 0247's limit paragraph named — through the
181
+ production channel (`<PREFIX>_VISIBILITY` / `<PREFIX>_JOINABILITY` in the environment,
182
+ read once through the memoized pack, as the provisioning runner writes them into
183
+ `web.env`). It then sweeps all 75 knob combinations asserting `openEnrollmentEnabled()`
184
+ equals *"the composed door is `open`"*, and that **exactly one** combination —
185
+ `public` / `full` / `open` — self-enrols.
186
+ - `src/bongos/project-door.js` no longer imports `src/bongos/auth-admission.js`; the
187
+ `KERNEL_FILES` note in `fitness.js` that described that import is updated with it.
188
+ `require_cycle_count` stays at 4.
189
+ - A surface that genuinely wants the owner's *setting* rather than the *door* — the
190
+ manifest read-back, the hall's door copy — still calls `joinabilityMode()`. The
191
+ distinction is now written on the function itself, because "read the knob" and "decide
192
+ admission" being the same call is what produced this bug.
193
+
194
+ ## Alternatives rejected
195
+
196
+ - **Composing the ceiling into the `joinability` value the platform pushes**, so the
197
+ instance needs no second reader. Rejected here for the same reasons ADR 0247 rejected
198
+ it: stored and pushed values would disagree, the manage page's read-back would break,
199
+ and it cannot express `locked`. This task is additional evidence for that call — the
200
+ read-back is exactly what `tests/admission_ceiling_gate.mjs` now pins.
201
+ - **Leaving the gap and documenting it harder.** It was already documented; a written
202
+ limit that survives two releases is a bug with good manners. The direction being safe
203
+ bought the deferral once, not indefinitely — and the owner-facing statement *"my project
204
+ is private"* is false while any stranger can still sign themselves in.
@@ -340,6 +340,7 @@ This keeps the decision history honest and traceable.
340
340
  | 0250 | [**Strict versioning — the version boundary is the scope gate** ([task 1003589](https://cloudbongos.com/builders#/task/1003589) · goal 1000086). The work hierarchy ([ADR 0086](<redacted>.md)) has a bottom and no top: tasks close, criteria close, goals close, and a version never does. Measured live — **14 goals reading `achieved`/`archived` while holding 35 unfinished tasks**, 523 open tasks across 37 open goals, and **two versions `building` on one project**; every number was worse than the planning session had recorded two days earlier, which is the argument: the drift is the steady state, not a backlog. The cause is four mechanical gaps, not a social one — `POST /goals` fetches the version row and never reads its `status`; goal achievement is computed from criteria alone and never consults `tasks` (both the `db-goals.js` writer and the `done-when.js` cascade); `POST /goals/:id/archive` checks nothing at all; and no route ships a version, so version status has only ever moved as hand-run SQL on the droplet. **Decision, five rules:** the goal set closes when a version starts `building` (new goals go on the `planning` version — where the next version is scoped in advance) with a named 409 an agent can act on; a goal reaches `achieved` only when its criteria are satisfied **and** no task in it is non-terminal, where terminal is `shipped`|`abandoned` per [task 1003588](https://cloudbongos.com/builders#/task/1003588)'s one vocabulary and the mid-ship `completed`/`confirmed` hold it open; archiving a goal that holds open tasks refuses until the caller dispositions each (abandon with the reason, or move to a named surviving goal); `goal_id` becomes required and the per-version catch-all fallback is deleted; and exactly one version builds per project, auto-closing when its last non-maintenance goal achieves, with the single `planning` version promoting itself so the project is never without a live train. **The escape hatches are deliberate and visible**: an Archon may admit one *named* goal to a building version with a reason, and the overrides are counted on the version (the standard failure of a hard scope gate is a fake hotfix version cut to hold what did not fit — an untraced override is just a slower version of no rule); plus one auto-created maintenance goal per version, exempt from the auto-close count, so bugs never need an override to be filed. **Nothing here can refuse a ship, a claim, or a task** — every rule acts on a *closing* move. Rejected: counting `completed`/`confirmed` as finished (they can fail a grade or strand — the goals **board** buckets them as done, but that is a progress ring, and `task-status.js` keeps the two questions apart); a nightly reconciler that reopens wrongly-closed goals (detection is what you build when prevention is unavailable, and it is available here); a human review gate before a goal may close (the queue [ADR 0183](<redacted>.md) dismantled — the check is a fact the table already holds); concurrent building versions scoped by module (two live trains means every "what is in this version" question has two answers); retiring versions entirely (the version is the unit the owner sets scope in and `limitations/<version>-shipped.md` archives). **Amends [ADR 0157](<redacted>.md) for exactly one key:** `version.create` returns to the Archon floor. 0157's "authoring delegates, disposition does not" was right about what a version *was* — but once the boundary carries the scope gate, cutting a new version is no longer authoring, it is the way *around* the gate, so create joins close as a disposition over scope.](<redacted>.md) | lifecycle / versioning / scope |
341
341
  | 0251 | [**A suggestion of who to invite is a recruiting surface, and owes the recruiting opt-out** ([task 1002347](https://cloudbongos.com/builders#/task/1002347) · goal 1000046, criterion `proj-invite-builders` / C6). BV1.R28 fills the seam [ADR 0239](<redacted>.md) §6 left open: the wizard’s pure rule engine emits a **seek** (`solo` → `null`, `small-team` → 3, `community` → 5) and never a list, because ranking builders is a directory read a pure file may not run and `provisioning` may not import a sibling. Two readers could answer “who are the top builders” and they differ by **one SQL predicate**: `economy`’s `leaderboard()` joins `rollupVisibleSql` and deliberately honours **no** recruiting opt-out, while `platform-identity`’s `listScoutingRollup()` joins `scoutingListableSql`, which carries it. The leaderboard is cheaper in every mechanical sense — already a public route, no port, callable straight from the browser. **Decision: use the recruiting directory anyway**, because `account-visibility.js` says the split in its own words (`hide_stats` suppresses *numbers* everywhere; the recruiting opt-out “removes an account from the RECRUITING SURFACE only”, community spec D3), and a list of people the platform proposes you recruit **is** that surface — sourcing it from the leaderboard would route around the one control built for this exact case and name every builder who asked not to be recruited. The rule for later readers: **the cheapness of a public read is not an argument about who consented to appear in it.** The lookup ships as `listInviteSuggestions` on the existing `platform-identity.hub` port, reached with `resolveOptional` and NOT declared in `consumes` (the `catalog-bridge.js` precedent — platform-identity is hub-only, so its absence is “no suggestions”, never a 500); `github_id` **does not cross the port** (explicit whitelist — login, display name, avatar, disciplines, two counts — so a new rollup column cannot leak by default), and the owner-exclusion is applied inside, over-read by the exclusion count so filtering never returns short. The route `GET /provisioning/instances/:id/invite-suggestions` is own-scoped by the **existing** `instanceAccessDenied` (same 404 as the manage read) and deliberately does NOT reuse `/scouting`’s gate ([ADR 0210](<redacted>.md)): a brand-new owner has no hub-witnessed owner row at the moment the done panel paints, so the correct-looking gate would refuse the exact caller the feature exists for — and reaching the route IS the ownership answer ([ADR 0249](<redacted>.md) §4). On the surface: a **second door in** (`setInviteSuggestions`) beside `setInviteSlug`, so the widget still answers neither “signed in” nor “theirs”; the reason is **always** said including when the answer is nobody (`seeking: null` costs no query — rendering a stated answer as an empty list would make it look like a failed lookup); a picked name fills the field and takes the **same write path** as a typed one, so the refusal vocabulary cannot fork; a filed invite stops being a suggestion; and every failure is silent, because a suggestion is an extra and a just-created project must not look broken over one. Rejected: the browser reading the economy leaderboard (the whole decision — consent, not architecture); reusing `/scouting` (right data, gate that refuses this caller); filling `invites.suggested` inside the engine (I/O in a pure file + a sibling import); a `provisioning → economy` port (legal, still the wrong directory — the boundary was never the objection).](<redacted>.md) | provisioning / platform-identity / privacy |
342
342
  | 0252 | [**The self-hosted visibility report is a PULL, and auto-appear buys a retraction lever** ([task 1002321](https://cloudbongos.com/builders#/task/1002321) · goal 1000046, criterion `proj-visibility-states` / C1, BV1.R02 — a spike whose output is this decision; gates [task 1002327](https://cloudbongos.com/builders#/task/1002327) / R08). **The premise failed first:** a self-hosted instance has no channel to the hub and no credential to open one — `registerClient` has exactly one caller in the tree (the operator's own `provision-net.js`), gated `hosting_shape !== 'co-tenant'`, and no route registers a client. So *federated* ⊆ *operator-provisioned co-tenant* and *self-hosted* holds nothing, deliberately: [ADR 0145](<redacted>.md) says do NOT extend federation to `standalone`, and [ADR 0144](<redacted>.md)'s Known Limitation rests on "every secret-holder is an operator-provisioned, trusted instance … the proper fix must land before federation opens to untrusted third-party clients." [ADR 0182](<redacted>.md) D5's "accepts a report from the authenticated `sso_client`" described a client that, for this population, does not exist. **Decision 1 — the channel is a PULL of the instance's already-public manifest** (`GET <origin>/api/gds/instance` → `branding.project.visibility`, a value every core already serves and whose author anticipated this consumer). It buys five things a push must build: no credential (0144's tripwire untouched — the whole reason), **no replay surface** (a pull reads current state, so the `jti` machinery D5 handed R02 is moot — replay is answered by not having a message), no wire change on another instance's upgrade clock ([ADR 0205](<redacted>.md)'s and [ADR 0201](<redacted>.md) D2.2's rejected-required-field problem simply absent), the hub still authorising nothing (0182 D5 satisfied, [ADR 0238](<redacted>.md)'s rejected `PATCH /projects/:id/visibility` still rejected), and a fetch that already exists behind a real SSRF guard (`project-stats.js` — DNS-resolve-all, `redirect: 'error'`, TTL + negative caches). The pull carries `visibility` and NOTHING else, so the property the map has today — a project owner controls no free-text field that reaches the feed — is not spent. **Decision 2 (the Archon's) — auto-appear:** no human stands between a public project and the map; `featured` keeps only the ranking job [ADR 0182](<redacted>.md) D3 left it. Conditional on Decision 4. **Decision 3 (the Archon's) — trust follows provenance:** both populations auto-appear, and the asymmetry is in the GUARDS, not a gate — a control-plane row's `visibility` is *assigned* by the bridge from an own-scoped audited route (authoritative), a self-hosted row's is *pulled* from a manifest the hub does not control (a cached claim with a recency stamp, able to move its own row and nothing else). An instance is authoritative about its own door and about nothing else. **Absent is a THIRD state and must not purge:** `null` (never read) ≠ `false` (read, negative). 0182 D5's fail-closed default holds only for a self-hosted row that has NEVER answered; a row that answered before and is unreachable now keeps its last state with a recency stamp ([ADR 0199](<redacted>.md), [ADR 0226](<redacted>.md)), because [ADR 0247](<redacted>.md) D4 found the live shape of that trap — every catalog row at `description: null`, so naive fail-closed would have emptied the map at once. Narrowing is free; widening is what costs. **Decision 4 — auto-appear buys a retraction lever, in the same task** ([ADR 0047](<redacted>.md): the straight-through path and its abuse guard are one task, treated as binding): `platform_identity_projects.status` is `(active,hidden)` and **no route writes it** — takedown is hand SQL today, and a one-way door onto a public surface is not a door. R08 ships `PATCH /projects/:id/status` (curate floor, audited), a per-IP limit on the unmetered CORS-open feed read ([ADR 0209](<redacted>.md)), and above all **`hidden` STICKY against the pull** — a retracted project that keeps reporting `public` stays hidden, or the next sweep silently undoes every takedown and the lever is decorative. Also named: `hall_origin` reaches the feed from the remote's own discovery index checked only for *differing* from the project host — the opposite of containment — and gets `shipUrlWithinClientOrigin`'s downward-only rule, filed against R08. Rejected: a pushed credentialed report (the literal R08 — needs the credentials 0144/0145 forbid, plus replay, rate and a wire-compatible field, none of which the pull has); `PATCH /projects/:id/visibility` (0238 already); a one-time admission and ongoing curation (both offered to the Archon, both declined); trusting the pull to move `status`; enforcing "unreported ⇒ dark matter" across all rows; letting the pull carry name/tagline/art. The later ANALYTICS feed is deliberately NOT decided here — it carries per-person data, exactly what [ADR 0205](<redacted>.md) proved cannot ride an unwitnessed self-report, and it needs the 0144 credential work first.](<redacted>.md) | platform-identity / provisioning / federation trust |
343
+ | 0256 | [**The visibility ceiling binds the SIGN-IN gate too, and the knob reader moves to the composer** ([task 1003579](https://cloudbongos.com/builders#/task/1003579) · goal 1000046, criterion `proj-join-policy`). Closes the *KNOWN LIMIT* [ADR 0247](<redacted>.md) wrote down: it composed `visibility × joinGrant × joinability` into one door and enforced it at the public application endpoint, leaving `openEnrollmentEnabled()` reading the `joinability` knob alone — so a project set `private` (or `stealth`, or `joinGrant: view`) while `joinability` stayed `open` **queued applications for review and still let any first-time GitHub account sign itself in at xenos**. Half of [ADR 0182](<redacted>.md) D6’s table — its whole *Self-join* column — was unenforced wherever the owner had explicitly opened enrolment. **Decision 1:** `openEnrollmentEnabled()` becomes `effectiveJoinDoor(deps) === 'open'`, the same SR-19 move [ADR 0194](<redacted>.md) D1 made one level down, so the four sign-in call sites, `firstTimeAdmissionBlocked`, `webAdmissionStatus` and the manifest all follow untouched — and **nothing changes for any existing project**, because `branding.js` normalises an absent visibility to `public` and an absent grant to `full`, the pair that narrows nothing. The ceiling closes **self-enrolment only**: an invited first-timer, an existing builder and the configured `firstAdmin` still get in, or a private project locks out its own founder. **Decision 2:** the manifest’s `admission.policy` now reports the GATE while `brand.project` keeps reporting the SETTING — they may differ, because they answer different questions, and `invite-only` is already what every `apply` project reports, so narrowing into it names no state ([ADR 0247](<redacted>.md) D3). **Decision 3 — the mechanical one:** `joinabilityMode()` moves from `src/bongos/auth-admission.js` into `src/bongos/project-door.js` (re-exported, so no import path and no doorway export changes). The door resolved its own joinability input with a lazy `require('./auth-admission')`; the moment the gate requires the door back the two files point at each other, and the `require_cycle_count` ratchet counts edges from **source text**, so laziness is no escape. Rejected: duplicating the composition in the gate (the drift SR-19 exists to prevent), and making `joinabilityMode` a required argument of `effectiveJoinDoor` (turns the published no-argument call into a footgun that silently answers `invite_only`). The reader was only in the admission file because the gate was its first caller; the composer owns its inputs. **Decision 4:** the gate passes no `publishable` verdict, so `locked` still cannot fire from it — [ADR 0247](<redacted>.md) D4’s finding stands, and a gate reading the hub’s cached column would refuse every first sign-in on the platform. Also rejected: refusal copy that names the private state (the read-out D3 forbids, and a case-specific sentence makes the refusals distinguishable by another route); a `webAdmissionStatus` special case (it reads the same function, and its anti-drift sweep already pins the agreement). Proof: `tests/admission_ceiling_gate.mjs` boots a real instance on `private` + `open` through the production env channel, then sweeps all 75 knob combinations asserting the gate **is** the door and that exactly one combination — `public`/`full`/`open` — self-enrols.](<redacted>.md) | admission / project visibility / kernel layering |
343
344
  | 0253 | [**The takedown lever has two writers, and provenance has to be a column** ([task 1002327](https://cloudbongos.com/builders#/task/1002327) · goal 1000046, criterion `proj-visibility-states` / C1, BV1.R08 — the build). Amends [ADR 0252](<redacted>.md) §4/§5: three premises that did not survive contact with the tree. The decisions stand; what changes is what has to be BUILT. **(1) `hidden` is sticky against TWO writers, and 0252 names the wrong one.** 0252 §5 says "no route writes [`status`]" and defends the takedown against the PULL. But `project-catalog.js:upsertOwnedProject` writes `status = EXCLUDED.status` on every conflict, sourced from `catalog-bridge.js:catalogStatusForInstance` — which returns `active` for any instance not torn down — and [ADR 0246](<redacted>.md) re-takes that projection at EVERY provisioning write. So the live failure is: curator hides a project → its owner saves any setting → it is back on the map. Stickiness therefore belongs to the ROW (`status_pinned`, set by the curated PATCH, honoured by the projection), not to one writer's manners; the pull separately writes no `status` at all, asserted against the statement text. `retireOwnedProject` deliberately does NOT honour the pin — it is a safety retraction for an origin the project no longer answers on ([ADR 0181](0181-teardown-state-gate-and-address-retention.md) §2), and narrowing is always free. **(2) Provenance must be a stored column.** 0252 §4 tabulates control-plane vs self-hosted rows as though the catalog knew which was which; it has no `hosting_shape` (that lives on `provisioning_instances`, in a module platform-identity may not import — [ADR 0083](<redacted>.md)) and `owner_github_id` separates nothing (set by the bridge, set by `registerClient`, null on hand-seeded rows). `visibility_source` (`control-plane`|`pull`|NULL) records it on the row, re-asserted in the UPDATE's own WHERE so a mid-sweep projection cannot lose the race — making "the two never write the same row" an invariant the SQL enforces. Rejected a `provisioning.instanceShape` port: a cross-module read in a background loop, for a value that never changes per row. **(3) The tri-state cannot live in `visibility`** (`NOT NULL DEFAULT 'public'`), so the recency stamp IS the null-carrier: `visibility_reported_at IS NULL` is the never-read state, and a failed read writes nothing — which is what makes "keep the last known state" fall out rather than be implemented. The fail-closed feed predicate needed a BACKFILL for [ADR 0247](<redacted>.md) D4's exact reason: both columns are new, so unbackfilled it would have emptied the public map on deploy. Migration 020 stamps every existing row `control-plane`, which is safe because it is TRUE — no instance has ever been asked. Two smaller corrections: the manifest key is **`brand`**, not `branding` (0252 §2 and the task body both misname it; reading only the documented spelling makes every pull a silent no-verdict), and the pull is a **POLLER, not a passenger on the feed's fan-out** — the feed excludes exactly the rows that need pulling (a never-answered row is dark matter, so it is never enriched, so it never answers), stealth rows are excluded from enrichment by design, and a read-path pull would make a DB write a side effect of an anonymous CORS-open read. Honest limit recorded: 0247 D4's `description: null` problem is NOT fixed here — 0252 §7 refuses to let the pull carry name/tagline/description, so the publish gate stays unmet for self-hosted rows. The pull moves the door; it does not publish the project.](<redacted>.md) | platform-identity / provisioning / abuse guard |
344
345
  | 0255 | [**A public list's ordering is part of its payload, so an anonymous row must be ordered by nothing** ([task 1003561](https://cloudbongos.com/builders#/task/1003561) · goal 1000046, which absorbed [task 1003560](https://cloudbongos.com/builders#/task/1003560)). The public projects feed redacts a stealth row down to an allow-list of `state` + `orb` and carries **no stable per-project key on purpose** ([ADR 0182](<redacted>.md) D2a) — then sorted `ORDER BY featured DESC, updated_at DESC` and handed the observer one anyway. **Ordering is a covert column:** a position is a comparison result, and enough comparisons reconstruct the key. Three consequences, the third load-bearing — `updated_at` was published despite being deliberately withheld from the projection (it lies as a public "last updated"); a black hole sat BETWEEN NAMED NEIGHBOURS whose order is visible, bracketing its timestamp between two identifiable rows; and it **MOVED when its hidden project was touched**, which is an activity oracle on a public, CORS-open, unauthenticated route, and stable enough between touches to follow one black hole across snapshots. **Decision: a row may be ordered only by what it publishes.** An identified row publishes `created_at` + `origin`, so it sorts by those under `featured` (D3's remaining ranking job) — an order that is a PURE FUNCTION OF THE WIRE PAYLOAD, reproducible by a reader holding only the response. An anonymous row publishes nothing, so it is ordered by nothing about itself: the black holes collate into ONE TRAILING BLOCK permuted by a keyed hash. Four sort terms; the first partitions the blocks so no later term compares across them, and **terms 2-4 are GUARDED** — the "anonymous rows go last" half without the "and are blanked" half is not a fix, since the trailing block would simply re-sort by `created_at`, which narrows *which* project it is for anyone who watched the catalog fill. The identified set is DERIVED from `ORB_BY_STATE` (never re-typed, so SQL and `feedShapeFor()` cannot drift) and is an ALLOW-list, so an unknown fourth enum value sorts anonymous as well as drawing anonymous. The permutation salt is **secret** (origins are guessable domain names — a predictable salt lets an observer hash candidates and match ranks) and **rotates on a 24h epoch** (a permanently stable permutation still makes POSITION a durable handle); random per process, since nothing persists or reads it back. **Pagination is the same decision, not a neighbouring one** — task 1003560's premise was already stale (`<redacted> = 500` shipped with the feed; its own comment called the cap "the cheap half" and pagination "its own task"), and the surviving half edits this exact statement, so it merged in: `?limit=&offset=` per [ADR 0119](0119-api-pagination-contract.md), **no cursor** because the obvious encoding is the last row's timestamp — the very channel this closes — and the **default page IS the maximum (500)** so a static orbs site in ANOTHER REPO is byte-for-byte unchanged, with `page` added beside `projects` rather than wrapping it. Paging cannot aim more fan-out than one request already could: `?limit=` only shrinks, and `?offset=` is bounded by `project-stats.js`'s 10-min per-origin cache, not the request count ([ADR 0252](<redacted>.md) §5.3's per-IP limiter unchanged). Rejected: sorting everything by `created_at` (positions a black hole by a date its row refuses to carry); alphabetical `origin` order for the block (an oracle against a candidate set); `ORDER BY random()` (breaks paging — a row could appear twice or not at all); coarse `updated_at` buckets (a sort key that must be blurred should not be there). **Honest limits, stated rather than implied fixed:** existence and count stay public by design; **the identified set still CHANGES when a project goes stealth**, a LARGER channel than this one and untouched here; and position is stable within one epoch.](<redacted>.md) | platform-identity / privacy boundary / public API |
345
346
  | 0254 | [**A relayed link is contained downward, or it is not published** ([task 1003670](https://cloudbongos.com/builders#/task/1003670) · goal 1000046, criterion `proj-visibility-states` / C1) — the gap [ADR 0252](<redacted>.md) §5 named and filed. **The defect:** `hall_origin` reaches the public projects feed from the REMOTE instance’s own discovery index (`GET <origin>/api/bongos` → `index.hall`, `project-stats.js`) and the hub renders it as an `<a href>` under that project’s name — checked only for **differing** from the project’s site host, which is containment turned inside out: it admits every host on the internet and refuses the one that is certainly safe. `{"hall": "https://evil.example/builders"}` was published on the hub’s own domain, attached to the victim. The rule answered a DIFFERENT question correctly ([task 1003257](https://cloudbongos.com/builders#/task/1003257), owner decision (a): *a door only where it opens somewhere else*); nobody had asked it the security one. It stopped being survivable when [ADR 0252](<redacted>.md) §3 replaced “whom the operator provisioned” with “whoever points a domain at the hub”. **Decision:** the same **downward-only** rule a pushed ship link already obeys — a relayed URL must point AT OR BELOW the origin that reported it (own host or a **dot-anchored** subdomain; `http(s)` only; no embedded credentials; **refused whole at the length cap, never truncated**, because slicing a URL changes where it points). Containment runs FIRST and compares **hostnames** (a port is not a trust boundary); the unchanged “different door” test runs second and compares **hosts with the port** (a hall on another port really does open elsewhere). Net: a hall link survives only as a strict subdomain — or another port — of the project’s own origin. **One home:** `shipUrlWithinClientOrigin` (a private helper in `platform-identity.js`) is lifted to `origin-containment.js:urlWithinOrigin` and required by BOTH callers — the defect WAS two surfaces answering one question differently, and leaving the answer where only one could reach it is what let them differ. Its own file for the reason its siblings give: `platform-identity.js` is AT the 1500-line ratchet (the lift shrinks it by 11), and deliberately NOT re-exported there — a re-export is a third name for one answer. **Downward only, and the cost stated:** the mirror rule looks symmetric and is not (on a shared apex it lets `foo.github.io` publish links on bare `github.io`, and vouch for every neighbour), so a project whose hall is a SIBLING of its site (site `www.example.com`, hall `builders.example.com`) loses the glyph — the hall stays reachable, the hub stops vouching for the address. [ADR 0253](<redacted>.md) §2’s asymmetry again: **narrowing is free; widening is what costs**. One guard added on the way in: a hostless origin (`file:`, `data:` — a stored value is not a validated one) is refused outright, because the old suffix test would have matched a legal trailing-dot FQDN like `evil.example.` against the bare `.` it leaves behind. **Rejected:** mirroring the check in the browser ([ADR 0182](<redacted>.md) D2a — the page is not the gate, and a second copy of a containment rule is the drift this ends); a per-project allow-list of hall hosts; copying the rule into `project-stats.js` (the cheapest diff, and it re-creates the cause); truncating at the cap (fine for an `<img src>` display bound, wrong for a containment decision); folding both tests into one hostname comparison (it would silently drop a legitimate same-host, different-port hall).](<redacted>.md) | platform-identity / public feed / link containment |
package/docs/file-map.md CHANGED
@@ -245,7 +245,7 @@
245
245
  │ │ ├── secret-box.js / sensitive-surfaces.js ← at-rest secret encryption + the sensitive-file classifier
246
246
  │ │ ├── cascade-dispatch.js ← (dormant System 3, ADR 0079) the model-tier escalation ladder for autonomous dispatch
247
247
  │ │ ├── goal-scope-check.js / hierarchy-config.js / module-scope-map.js ← goal-scope protected-path checks, the configurable work-hierarchy tiers, and the module→file-glob map
248
- │ │ ├── project-door.js ← the project's COMPOSED join/apply door — visibility × join_grant × joinability → open | apply | invite_only | locked (BV1.R12 / task 1002331, ADR 0182 D6 + ADR 0245). PURE + kernel; published as api.projectJoinDoor() so the public POST /access-requests, the hall landing form, the CLI and the hub's join relay cannot answer one stranger differently
248
+ │ │ ├── project-door.js ← the project's COMPOSED join/apply door — visibility × join_grant × joinability → open | apply | invite_only | locked (BV1.R12 / task 1002331, ADR 0182 D6 + ADR 0247). PURE + kernel; published as api.projectJoinDoor() so the public POST /access-requests, the hall landing form, the CLI and the hub's join relay cannot answer one stranger differently — and since task 1003579 (ADR 0256) the SIGN-IN gate reads it too (openEnrollmentEnabled), which is why joinabilityMode() lives here rather than in auth-admission.js
249
249
  │ │ ├── path-match.js / repo-info.js / metrics.js / uptime-poller.js / app-pair.js ← touches[] overlap matcher, git-remote parser, Prometheus metrics endpoint, uptime poller, Claude-Desktop pairing store
250
250
  │ └── world/
251
251
  │ ├── terrain.js ← (Phase D + ADR 0012, CJS): TERRAIN + OBJECT enums + BLOCKING_TERRAINS + BLOCKING_OBJECTS (server twin of public/game/world/terrain.js)
@@ -1585,5 +1585,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
1585
1585
  landed since 1.19.566 with no explicit bump. run 34057551680. (task 1002620)
1586
1586
  1.19.568 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1587
1587
  landed since 1.19.567 with no explicit bump. run 34058174866. (task 1002620)
1588
+ 1.19.569 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1589
+ landed since 1.19.568 with no explicit bump. run 34058387853. (task 1002620)
1588
1590
  ---------------------------------------------------------------------------
1589
1591
  ```
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.568",
3
+ "version": "1.19.569",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.568",
9
+ "version": "1.19.569",
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.568",
3
+ "version": "1.19.569",
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",
@@ -534,9 +534,11 @@ const KERNEL_FILES = [
534
534
  // route-rank-check.js and goal-scope-check.js above (a trust decision the doorway
535
535
  // hands to modules so no module re-derives it). The doorway re-exports it, so
536
536
  // leaving it off this roster reads as a kernel->non-kernel leak; it imports only
537
- // branding.js and auth-admission.js — both on this roster — so joining costs
538
- // nothing and buys the import-direction guarantee on the file that decides who
539
- // may knock.
537
+ // branding.js, instance-config.js and logger.js — all on this roster — so
538
+ // joining costs nothing and buys the import-direction guarantee on the file that
539
+ // decides who may knock. (Task 1003579 removed its require of auth-admission.js
540
+ // and moved joinabilityMode the other way: the gate now reads the composed door,
541
+ // so the reader had to sit with the composer or the two files would have cycled.)
540
542
  'src/bongos/project-door.js',
541
543
  ];
542
544
 
@@ -29,6 +29,10 @@ const seams = require('../module-seams');
29
29
  const ic = require('../instance-config');
30
30
  const { branding, userAgent } = require('../branding');
31
31
  const { loadIdpConfig } = require('./auth-config.js');
32
+ // task 1003579: the sign-in gate reads the project's COMPOSED join door, not the
33
+ // single `joinability` knob it used to. joinabilityMode moved into that file with
34
+ // the two knobs it composes against and is re-exported below, unchanged.
35
+ const { effectiveJoinDoor, joinabilityMode } = require('./project-door.js');
32
36
  // task 1003208: structured logging — was console.*. A core file logs through
33
37
  // the kernel logger directly; only MODULES go via api.logger (the doorway).
34
38
  const log = require('./logger').logger.child({ src: 'auth-admission' });
@@ -201,57 +205,41 @@ async function reportActivityRollupForShippedTask(task) {
201
205
  // already exists for their github_id) ALWAYS passes — the gate never locks out
202
206
  // someone already enrolled.
203
207
  //
204
- // The project's JOINABILITY decides which of those a first-timer gets (task
205
- // 1003044, goal 1000058; the owner's 2026-08-16 decision). The owner picks it on
206
- // the hub's manage page; the platform pushes it into this instance's branding
207
- // pack as project.joinability (ADR 0190) and it is read through branding(),
208
- // like platformVisibility — memoized for the process, applied by a restart:
209
- // open a first-time GitHub sign-in self-enrols at xenos — no request,
210
- // no invite (what OTB_OPEN_XENOS_ENROLLMENT used to switch on)
211
- // apply (the default — today's behaviour) a stranger files an access
212
- // request and an Archon marks it 'invited'; then sign-in admits
213
- // invite_only the public POST /access-requests refuses; only an access
214
- // request an Archon CREATED at 'invited' admits a first-timer
215
- // The legacy OPEN_XENOS_ENROLLMENT flag (ADR 0050) still opens an *apply*
216
- // project, so a self-hosted instance that set it keeps its open door across the
217
- // upgrade — but it does not override an explicit invite_only: the stricter,
218
- // newer choice wins. An unreadable pack fails CLOSED (invite_only): when the
219
- // door's policy is unknown the widest door is never the fallback.
220
- function legacyOpenEnrollmentFlag() {
221
- const v = ic.resolveEnv('OPEN_XENOS_ENROLLMENT');
222
- return v === '1' || v === 'true';
223
- }
224
-
225
- // joinabilityMode — the project's effective membership door policy: 'open' |
226
- // 'apply' | 'invite_only'. The one reader every admission surface uses (both
227
- // sign-in flows, the public access-request route via the doorway, the manifest,
228
- // the refusal pages), so none of them can disagree about which door this is.
229
- // `read` is injectable for the unit tests only (the pack is memoized).
230
- let warnedUnknownJoinability = false;
231
- function joinabilityMode(read = branding) {
232
- let v;
233
- try { v = read().project?.joinability; } catch (err) {
234
- log.error('[auth] joinability: branding pack unreadable — treating the project as invite-only:', err.message);
235
- return 'invite_only';
236
- }
237
- if (v === 'open' || v === 'invite_only') return v;
238
- if (v === undefined || v === null || v === '' || v === 'apply') {
239
- return legacyOpenEnrollmentFlag() ? 'open' : 'apply';
240
- }
241
- // A value this core cannot read ('invite-only' with a hyphen, a truncated
242
- // write) is a policy it does not know — never the wider door.
243
- if (!warnedUnknownJoinability) {
244
- warnedUnknownJoinability = true;
245
- log.error(`[auth] joinability: unrecognised value ${JSON.stringify(v)} — treating the project as invite-only`);
246
- }
247
- return 'invite_only';
248
- }
208
+ // WHICH of those a first-timer gets is the project's JOIN DOOR — and the door is
209
+ // three owner-set knobs composed into one answer, not the `joinability` knob
210
+ // alone (visibility × joinGrant × joinability; ADR 0182 D6, ADR 0247, and
211
+ // src/bongos/project-door.js for the table). The owner picks them on the hub's
212
+ // manage page; the platform pushes them into this instance's branding pack (ADR
213
+ // 0190) and they are read through branding() — memoized for the process, applied
214
+ // by a restart.
215
+ //
216
+ // THE COMPOSITION IS WHY THIS READS THE DOOR AND NOT THE KNOB (task 1003579).
217
+ // ADR 0247 shipped the composed door at the public application endpoint and left
218
+ // this gate reading `joinability` alone, so an owner who set their project
219
+ // `private` (or `stealth`, or joinGrant `view`) while leaving `joinability: open`
220
+ // got a door that queued applications and a sign-in that still self-enrolled
221
+ // strangers — half of D6's table unenforced. Both surfaces now ask the same
222
+ // function, which is the only arrangement in which they cannot answer one
223
+ // stranger differently.
249
224
 
250
- // openEnrollmentEnabled — true when a first-time GitHub account self-enrols
251
- // (joinability 'open'). Kept as the name the four sign-in call sites and the
252
- // manifest read; the decision itself is joinabilityMode().
253
- function openEnrollmentEnabled() {
254
- return joinabilityMode() === 'open';
225
+ // openEnrollmentEnabled — true when a first-time GitHub account self-enrols: the
226
+ // project's composed join door is `open`. Kept as the name the four sign-in call
227
+ // sites, webAdmissionStatus and the public manifest read, so composing the
228
+ // ceiling in changed one expression rather than six surfaces (the same SR-19 move
229
+ // ADR 0194 made when joinabilityMode replaced the raw env flag).
230
+ //
231
+ // A door that is `apply`, `invite_only` or `locked` all answer false here, and
232
+ // deliberately with the SAME false: the gate must not leak which knob closed it
233
+ // (ADR 0247 D3 — stealth's anonymity is a privacy primitive, so a refusal may
234
+ // never be read backwards into a state). It passes NO publish verdict, so the
235
+ // `locked` branch cannot fire from here — this plane holds no trustworthy
236
+ // dark-matter verdict yet (ADR 0247 D4).
237
+ //
238
+ // `deps` is effectiveJoinDoor's own injection seam ({ branding, joinabilityMode,
239
+ // publishable }), forwarded verbatim so a unit test can drive the whole matrix
240
+ // without booting a pack. Production calls it with none.
241
+ function openEnrollmentEnabled(deps = {}) {
242
+ return effectiveJoinDoor(deps) === 'open';
255
243
  }
256
244
 
257
245
  // PURE admission decision shared by both flows so they CANNOT drift (SR-19).
@@ -259,6 +247,12 @@ function openEnrollmentEnabled() {
259
247
  // builder row (or null) from getBuilderByGithubId; `approved` is the result of
260
248
  // hasInvitedAccessRequest. Blocks only when: not already a builder AND open
261
249
  // enrollment is off AND not invite-approved. Exported for direct unit testing.
250
+ //
251
+ // Note what the visibility ceiling does and does not close (task 1003579): it
252
+ // narrows SELF-enrolment only. An invited first-timer, an existing builder and
253
+ // the configured firstAdmin are all admitted to a `private` or `stealth` project
254
+ // exactly as before — an owner who takes their project off the map is closing the
255
+ // front door, not evicting the people they let in.
262
256
  function firstTimeAdmissionBlocked({ existing, approved, login }) {
263
257
  if (existing || openEnrollmentEnabled() || approved) return false;
264
258
  // The configured firstAdmin is always admitted on a fresh instance so the
@@ -403,6 +397,10 @@ async function safeSyncRankRole(builder) {
403
397
  module.exports = {
404
398
  firstAdminLogin,
405
399
  firstTimeAdmissionBlocked,
400
+ // Re-exported, not defined here (task 1003579 moved it to project-door.js with
401
+ // the two knobs it composes against). auth.js, the doorway's api.joinabilityMode
402
+ // and four test files import it by this path, and the read-back it answers —
403
+ // "what did the owner set?" — is still an admission-surface question.
406
404
  joinabilityMode,
407
405
  maybeSeatFirstAdmin,
408
406
  seedKickoffChecklist,
@@ -27,6 +27,15 @@
27
27
  // plus `publishable`, which is DERIVED and never stored (ADR 0182 D1/D4: a project
28
28
  // with no name or no description is "dark matter" — off the map and un-joinable).
29
29
  //
30
+ // THE KNOB READERS LIVE HERE TOO. `joinabilityMode()` moved in from
31
+ // src/bongos/auth-admission.js (task 1003579, ADR 0256). It was born in the
32
+ // admission gate (ADR 0194) because the gate was its only caller; once the gate
33
+ // started reading the COMPOSED door instead of that one knob, leaving the reader
34
+ // on the far side would have made this file require the whole admission gate back
35
+ // to answer one question — the two files pointing at each other. The composer owns
36
+ // its inputs and the gate reads the answer, so the dependency runs one way.
37
+ // auth-admission re-exports the name, so every existing import still resolves.
38
+ //
30
39
  // THE REFUSAL NEVER NAMES THE STATE. `stealth`, `public`+`view`, and an explicit
31
40
  // `invite_only` all answer with the SAME door, so a refusal can never be read
32
41
  // backwards to learn which state a project is in. That is not a nicety: ADR 0182
@@ -42,6 +51,12 @@
42
51
 
43
52
  'use strict';
44
53
 
54
+ // The ONE non-lazy require in this file. logger.js pulls in pino and node
55
+ // built-ins and nothing else — no branding pack, no env, no database — so it does
56
+ // not cost the pure half its "importable with nothing behind it" property, which
57
+ // is why every OTHER require here is still deferred to call time.
58
+ const log = require('./logger').logger.child({ src: 'project-door' });
59
+
45
60
  // The door vocabulary, ORDERED widest → strictest. The order IS the composition
46
61
  // rule: two knobs never negotiate, the higher index simply wins.
47
62
  const JOIN_DOORS = Object.freeze(['open', 'apply', 'invite_only', 'locked']);
@@ -102,13 +117,70 @@ function projectJoinDoor({ visibility, joinGrant, joinability, publishable = nul
102
117
  return strictestDoor(ceiling, JOINABILITY_DOOR[joinability] || 'invite_only');
103
118
  }
104
119
 
120
+ // The legacy OPEN_XENOS_ENROLLMENT escape hatch (ADR 0050). It still opens an
121
+ // *apply* project, so a self-hosted instance that set it keeps its open door
122
+ // across the upgrade — but it never reopens an explicit `invite_only`: the
123
+ // stricter, newer choice wins.
124
+ function legacyOpenEnrollmentFlag() {
125
+ const ic = require('../instance-config'); // eslint-disable-line global-require
126
+ const v = ic.resolveEnv('OPEN_XENOS_ENROLLMENT');
127
+ return v === '1' || v === 'true';
128
+ }
129
+
130
+ // joinabilityMode — the project's stored membership door policy, and ONLY that:
131
+ // 'open' | 'apply' | 'invite_only' (ADR 0194). The one reader for the knob, so
132
+ // the manifest's read-back, the hall's door copy and the refusal pages cannot
133
+ // disagree about what the owner set:
134
+ //
135
+ // open a first-time GitHub sign-in self-enrols at xenos — no request,
136
+ // no invite (what OTB_OPEN_XENOS_ENROLLMENT used to switch on)
137
+ // apply (the default — today's behaviour) a stranger files an access
138
+ // request and an Archon marks it 'invited'; then sign-in admits
139
+ // invite_only the public application endpoint refuses; only an access request
140
+ // an Archon CREATED at 'invited' admits a first-timer
141
+ //
142
+ // IT IS NOT THE DOOR. `projectJoinDoor` below may narrow this further, and since
143
+ // task 1003579 every surface that DECIDES whether someone gets in — the public
144
+ // application endpoint and the sign-in gate both — reads that composed answer
145
+ // instead. Read this one only where the question really is "what did the owner
146
+ // set?"; reading it to admit someone is the drift ADR 0247 was written against.
147
+ //
148
+ // An unreadable pack, or a value this core cannot read, fails CLOSED to
149
+ // `invite_only`: when the door's policy is unknown the widest door is never the
150
+ // fallback (ADR 0192). The `[auth]` log prefix is kept verbatim across the move —
151
+ // operators grep sign-in refusals by it, and the child logger's `src` already
152
+ // says which file emitted the line. `read` is injectable for the unit tests only
153
+ // (the pack is memoized for the process).
154
+ let warnedUnknownJoinability = false;
155
+ function joinabilityMode(read) {
156
+ const readPack = read || require('../branding').branding; // eslint-disable-line global-require
157
+ let v;
158
+ try { v = readPack().project?.joinability; } catch (err) {
159
+ log.error('[auth] joinability: branding pack unreadable — treating the project as invite-only:', err.message);
160
+ return 'invite_only';
161
+ }
162
+ if (v === 'open' || v === 'invite_only') return v;
163
+ if (v === undefined || v === null || v === '' || v === 'apply') {
164
+ return legacyOpenEnrollmentFlag() ? 'open' : 'apply';
165
+ }
166
+ // A value this core cannot read ('invite-only' with a hyphen, a truncated
167
+ // write) is a policy it does not know — never the wider door.
168
+ if (!warnedUnknownJoinability) {
169
+ warnedUnknownJoinability = true;
170
+ log.error(`[auth] joinability: unrecognised value ${JSON.stringify(v)} — treating the project as invite-only`);
171
+ }
172
+ return 'invite_only';
173
+ }
174
+
105
175
  // effectiveJoinDoor — this project's LIVE door: projectJoinDoor() over the policy
106
176
  // this instance actually resolved. The one call a door surface makes; published
107
177
  // through the module doorway as `api.projectJoinDoor()` so a module never
108
178
  // re-derives the composition (the drift `joinabilityMode` exists to prevent).
109
179
  //
110
180
  // The requires are deliberately lazy: this file's pure half must stay importable by
111
- // a test with no branding pack, no env and no database behind it.
181
+ // a test with no branding pack, no env and no database behind it. The joinability
182
+ // reader is now a local function rather than a require of the admission gate, which
183
+ // is what lets the gate read this door without the two files forming a cycle.
112
184
  //
113
185
  // A pack that will not load leaves BOTH visibility and the grant unknown, and
114
186
  // `visibilityCeiling` answers that with `invite_only` — the same fail-closed door
@@ -116,7 +188,7 @@ function projectJoinDoor({ visibility, joinGrant, joinability, publishable = nul
116
188
  // something that reader had already closed.
117
189
  function effectiveJoinDoor(deps = {}) {
118
190
  const readBranding = deps.branding || require('../branding').branding; // eslint-disable-line global-require
119
- const readJoinability = deps.joinabilityMode || require('./auth-admission').joinabilityMode; // eslint-disable-line global-require
191
+ const readJoinability = deps.joinabilityMode || joinabilityMode;
120
192
  let project = {};
121
193
  try { project = readBranding().project || {}; } catch { project = {}; }
122
194
  return projectJoinDoor({
@@ -133,6 +205,7 @@ module.exports = {
133
205
  PROJECT_JOIN_GRANTS,
134
206
  strictestDoor,
135
207
  visibilityCeiling,
208
+ joinabilityMode,
136
209
  projectJoinDoor,
137
210
  effectiveJoinDoor,
138
211
  };
@@ -31,6 +31,10 @@ const crypto = require('node:crypto');
31
31
 
32
32
  const auth = require('../auth');
33
33
  const { reportActivityRollupForShippedTask } = require('../auth-admission.js');
34
+ // task 1003579: the sign-in refusal page names the door the project actually
35
+ // runs, so it cannot offer an application form to a project whose application
36
+ // endpoint refuses. Same composed answer the gate itself reads.
37
+ const { effectiveJoinDoor } = require('../project-door.js');
34
38
  const db = require('../db');
35
39
  const { pairStore } = require('../app-pair');
36
40
  const { publicOrigin, parseCookie, validateOrRespond } = require('./_helpers');
@@ -705,10 +709,19 @@ module.exports = function buildAuthRouter() {
705
709
  // approval brings them back to it — the return cookie says where.
706
710
  const founderReturn = safeReturnTo(parseCookie(req, RETURN_COOKIE)) || '';
707
711
  const onWayToProject = founderReturn.startsWith('/projects');
708
- // An invite-only project (task 1003044) takes no applications: say so,
709
- // and name the one way in — an invite from the admitter — instead of
710
- // sending a stranger to a request form the project refuses.
711
- if (auth.joinabilityMode() === 'invite_only') {
712
+ // A project that takes no applications says so, and names the one way in
713
+ // — an invite from the admitter — instead of sending a stranger to a
714
+ // request form the project will refuse (task 1003044).
715
+ //
716
+ // The test is the COMPOSED door, not the `joinability` knob (task 1003579):
717
+ // `apply` is the only door that accepts an application, so `stealth` and a
718
+ // `view` grant must land on this page too, or the refusal hands a stranger
719
+ // a form the application endpoint 403s. Both closed doors show the SAME
720
+ // sentence, which is the point — a refusal that distinguished them would
721
+ // be the state read-out ADR 0247 D3 forbids. `open` cannot reach here (the
722
+ // gate would have admitted) and `locked` cannot fire at all (no publish
723
+ // verdict is passed, ADR 0247 D4), so this is `invite_only` in practice.
724
+ if (effectiveJoinDoor() !== 'apply') {
712
725
  return res.status(403).type('text/html').send(renderAuthErrorPage(
713
726
  'This project is invite-only',
714
727
  `Signing in with GitHub is only for builders the ${admitterLabel()} has invited. This project doesn’t take applications, so there is nothing to request here. If you know the ${admitterLabel()}, ask them for an invite — once you’re invited, this sign-in lets you in.`,
@@ -124,6 +124,13 @@ module.exports = function buildInstanceRouter() {
124
124
  rankTier: RANK_TIER,
125
125
  repo: loadRepoInfo(),
126
126
  authConfigured: authConfigured(),
127
+ // The COMPOSED join door, not the raw `joinability` knob (task 1003579):
128
+ // `admission.policy` says whether a stranger can actually sign themselves
129
+ // in, so a project that is off the map reports 'invite-only' even while its
130
+ // `joinability` reads back 'open' under brand.project. The two fields answer
131
+ // different questions on purpose — one is the owner's setting, the other is
132
+ // what the gate will do — and 'invite-only' already covers every non-open
133
+ // door, so narrowing it names no state (ADR 0247 D3).
127
134
  openEnrollment: openEnrollmentEnabled(),
128
135
  devBoxEnabled: isModuleEnabled('dev-box'),
129
136
  // Project OUT the client secret — the manifest is public; only origin +
package/src/branding.js CHANGED
@@ -220,7 +220,8 @@ function clientBranding(b = branding()) {
220
220
  // the honest place to learn what it RESOLVED from its environment is its own
221
221
  // GET /instance manifest. Enforced on the instance by the platform-visibility
222
222
  // gate (task 1003043, ADR 0192) and the admission machinery (task 1003044,
223
- // ADR 0194 — joinabilityMode() in auth-admission.js).
223
+ // ADR 0194 — joinabilityMode() in project-door.js, which composes it with
224
+ // visibility + joinGrant into the one door every admission surface reads).
224
225
  project: {
225
226
  platformVisibility: b.project?.platformVisibility || 'public',
226
227
  joinability: b.project?.joinability || 'apply',
package/src/module-api.js CHANGED
@@ -49,7 +49,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
49
49
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
50
50
  // the entry to that file. Look for a version's history there, not here.
51
51
  // ---------------------------------------------------------------------------
52
- const CORE_VERSION = '1.19.568'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
52
+ const CORE_VERSION = '1.19.569'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
53
53
 
54
54
  // A namespaced logger so a module's log lines are attributable + consistent.
55
55
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,203 @@
1
+ // tests/admission_ceiling_gate.mjs — the visibility ceiling reaches the SIGN-IN
2
+ // gate, not only the application door (task 1003579, goal 1000046; ADR 0256).
3
+ //
4
+ // THE BUG THIS PINS. ADR 0247 composed the project's join door — visibility ×
5
+ // joinGrant × joinability — and enforced it at the public application endpoint,
6
+ // but left `openEnrollmentEnabled()` reading the `joinability` knob alone. So an
7
+ // owner who took their project off the map (`visibility: private`, or `stealth`,
8
+ // or `joinGrant: view`) while leaving `joinability: open` got a door that queued
9
+ // applications for review and a sign-in that still handed any first-time GitHub
10
+ // account a xenos account. Half of ADR 0182 D6's table — its whole "Self-join"
11
+ // column — was unenforced wherever the owner had explicitly opened enrolment.
12
+ //
13
+ // The boot half sets the knobs the way production does: <PREFIX>_VISIBILITY and
14
+ // <PREFIX>_JOINABILITY in the environment, read once through the memoized
15
+ // branding pack, which is exactly what the provisioning runner writes into
16
+ // web.env before restarting the service (ADR 0190). `private` + `open` is the
17
+ // precise pair ADR 0247's "KNOWN LIMIT" paragraph named.
18
+ //
19
+ // The sweep half drives effectiveJoinDoor's injection seam across every knob
20
+ // combination and asserts the gate is the door EVERYWHERE, not just at the one
21
+ // booted point — the property that makes this a fix rather than a special case.
22
+ //
23
+ // DB-free: every decision under test answers from the pack and the pure
24
+ // composition, before any lookup. Run: node --test tests/admission_ceiling_gate.mjs
25
+
26
+ import assert from 'node:assert/strict';
27
+ import { test, before, after } from 'node:test';
28
+ import http from 'node:http';
29
+ import { createRequire } from 'node:module';
30
+
31
+ const require = createRequire(import.meta.url);
32
+
33
+ // Set BEFORE the first require that could read branding(): the pack is memoized
34
+ // and deep-frozen for the process lifetime, which is why a real instance applies
35
+ // one of these with a restart rather than a re-read.
36
+ process.env.CLOUDBONGOS_VISIBILITY = 'private';
37
+ process.env.CLOUDBONGOS_JOINABILITY = 'open';
38
+ // The bootstrap owner, so the test can prove the ceiling does not gate them out
39
+ // of their own instance. The neutral pack ships firstAdmin: "" (an unclaimed
40
+ // instance has no owner yet), so it is configured through the documented env
41
+ // override rather than a pack edit.
42
+ const FIRST_ADMIN = 'configured-owner';
43
+ process.env.CLOUDBONGOS_FIRST_ADMIN = FIRST_ADMIN;
44
+
45
+ const {
46
+ joinabilityMode, openEnrollmentEnabled, firstTimeAdmissionBlocked, firstAdminLogin,
47
+ } = require('../src/bongos/auth-admission.js');
48
+ const { effectiveJoinDoor, projectJoinDoor } = require('../src/bongos/project-door.js');
49
+ const platformMod = require('../src/platform-server.js');
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // The booted instance: private on the map, enrolment explicitly open.
53
+
54
+ test('the stored knob still reads back exactly what the owner set', () => {
55
+ // The composition may narrow what the door DOES; it may never rewrite what the
56
+ // owner chose, or the manage page's "did my save take effect?" read-back lies
57
+ // (ADR 0194 D1 — the reason the ceiling is composed on the instance instead of
58
+ // folded into the pushed value at the platform).
59
+ assert.equal(joinabilityMode(), 'open');
60
+ assert.equal(firstAdminLogin(), FIRST_ADMIN);
61
+ });
62
+
63
+ test('the application door still queues — private accepts applications', () => {
64
+ // private's ceiling is `apply`, and `open` cannot widen it back. The door and
65
+ // the gate are allowed to differ; what they may not do is disagree about the
66
+ // ceiling, which is the whole bug.
67
+ assert.equal(effectiveJoinDoor(), 'apply');
68
+ });
69
+
70
+ test('THE FIX: the sign-in gate obeys the ceiling — a stranger no longer self-enrols', () => {
71
+ assert.equal(openEnrollmentEnabled(), false, 'private + joinability:open must not self-enrol');
72
+ assert.equal(
73
+ firstTimeAdmissionBlocked({ existing: null, approved: false, login: 'stranger' }), true,
74
+ 'a first-time GitHub account is blocked at the gate, not admitted at xenos',
75
+ );
76
+ });
77
+
78
+ test('the ceiling narrows SELF-enrolment only — invited, existing and the owner still get in', () => {
79
+ // An owner taking their project off the map is closing the front door, not
80
+ // evicting the people they let in. If this ever flips, a `private` project
81
+ // locks out its own members and its own founder.
82
+ assert.equal(firstTimeAdmissionBlocked({ existing: null, approved: true, login: 'invited' }), false);
83
+ assert.equal(firstTimeAdmissionBlocked({ existing: { id: 42 }, approved: false, login: 'member' }), false);
84
+ assert.equal(firstTimeAdmissionBlocked({ existing: null, approved: false, login: FIRST_ADMIN }), false);
85
+ assert.equal(firstTimeAdmissionBlocked({ existing: null, approved: false, login: FIRST_ADMIN.toUpperCase() }), false);
86
+ });
87
+
88
+ test('the gate IS the door — one decision, not two that happen to agree', () => {
89
+ assert.equal(openEnrollmentEnabled(), effectiveJoinDoor() === 'open');
90
+ });
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // The manifest, from the real booted app.
94
+
95
+ let server;
96
+ let port;
97
+ before(async () => {
98
+ server = http.createServer(platformMod.buildPlatformApp());
99
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
100
+ port = server.address().port;
101
+ });
102
+ after(async () => {
103
+ await new Promise((r) => server.close(r));
104
+ });
105
+
106
+ function request(pathname) {
107
+ return new Promise((resolve, reject) => {
108
+ const req = http.request({ host: '127.0.0.1', port, path: pathname, method: 'GET' }, (res) => {
109
+ let data = '';
110
+ res.on('data', (c) => { data += c; });
111
+ res.on('end', () => resolve({ status: res.statusCode, body: data }));
112
+ });
113
+ req.on('error', reject);
114
+ req.end();
115
+ });
116
+ }
117
+
118
+ test('booted: the manifest reports the GATE, while the brand block reports the SETTING', async () => {
119
+ const res = await request('/api/bongos/instance');
120
+ assert.equal(res.status, 200);
121
+ const m = JSON.parse(res.body);
122
+ // The owner's settings, unchanged — a client can still say what the owner chose.
123
+ assert.equal(m.brand.project.visibility, 'private');
124
+ assert.equal(m.brand.project.joinability, 'open');
125
+ // ...and admission.policy now answers the different question it always claimed
126
+ // to: can a stranger sign themselves in? Before this task it said 'open' here
127
+ // and the CLI believed it.
128
+ assert.equal(m.admission.policy, 'invite-only');
129
+ });
130
+
131
+ test('booted: the refusal names no state — every closed door reports the same word', async () => {
132
+ // 'invite-only' is what an `apply` project has always reported, so narrowing a
133
+ // private project into it adds no way to tell private, stealth, a `view` grant
134
+ // and a plain invite-only project apart from the manifest (ADR 0247 D3 —
135
+ // stealth's anonymity is a privacy primitive, not a nicety).
136
+ const m = JSON.parse((await request('/api/bongos/instance')).body);
137
+ const closed = ['apply', 'invite_only'].map((j) => (effectiveJoinDoor({
138
+ branding: () => ({ project: { visibility: 'public', joinGrant: 'full' } }),
139
+ joinabilityMode: () => j,
140
+ }) === 'open' ? 'open' : 'invite-only'));
141
+ assert.deepEqual([...new Set([...closed, m.admission.policy])], ['invite-only']);
142
+ });
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // The sweep: the gate equals the door across the whole knob space.
146
+
147
+ const VISIBILITIES = ['public', 'private', 'stealth', 'unlisted', undefined];
148
+ const GRANTS = ['full', 'apply', 'view', 'FULL', undefined];
149
+ const JOINABILITIES = ['open', 'apply', 'invite_only'];
150
+
151
+ const packOf = (visibility, joinGrant) => () => ({ project: { visibility, joinGrant } });
152
+
153
+ test('every knob combination: openEnrollmentEnabled === (the composed door is open)', () => {
154
+ let checked = 0;
155
+ for (const visibility of VISIBILITIES) {
156
+ for (const joinGrant of GRANTS) {
157
+ for (const joinability of JOINABILITIES) {
158
+ const gate = openEnrollmentEnabled({
159
+ branding: packOf(visibility, joinGrant), joinabilityMode: () => joinability,
160
+ });
161
+ const door = projectJoinDoor({ visibility, joinGrant, joinability });
162
+ assert.equal(
163
+ gate, door === 'open',
164
+ `drift: visibility=${visibility} grant=${joinGrant} joinability=${joinability} → gate=${gate} door=${door}`,
165
+ );
166
+ checked++;
167
+ }
168
+ }
169
+ }
170
+ assert.equal(checked, VISIBILITIES.length * GRANTS.length * JOINABILITIES.length);
171
+ });
172
+
173
+ test('exactly ONE combination self-enrols: on the map, granting membership, open', () => {
174
+ // The inverse statement of the sweep, written out so a future widening of any
175
+ // one knob's table fails here loudly instead of quietly admitting strangers.
176
+ const opened = [];
177
+ for (const visibility of VISIBILITIES) {
178
+ for (const joinGrant of GRANTS) {
179
+ for (const joinability of JOINABILITIES) {
180
+ if (openEnrollmentEnabled({ branding: packOf(visibility, joinGrant), joinabilityMode: () => joinability })) {
181
+ opened.push([visibility, joinGrant, joinability].join('/'));
182
+ }
183
+ }
184
+ }
185
+ }
186
+ assert.deepEqual(opened, ['public/full/open']);
187
+ });
188
+
189
+ test('a pack that will not load fails CLOSED at the gate, exactly as it does at the door', () => {
190
+ const boom = () => { throw new Error('pack unreadable'); };
191
+ assert.equal(openEnrollmentEnabled({ branding: boom, joinabilityMode: () => 'open' }), false);
192
+ assert.equal(openEnrollmentEnabled({ branding: () => ({}), joinabilityMode: () => 'open' }), false,
193
+ 'a pack with no project block is not a public project by default');
194
+ });
195
+
196
+ test('the gate forwards a publish verdict when its caller holds one', () => {
197
+ // Nothing in the sign-in path passes one today — this plane holds no
198
+ // trustworthy dark-matter verdict yet (ADR 0247 D4) — but the seam is the
199
+ // door's own, so wiring the verdict later is one argument, not a redesign.
200
+ const pack = packOf('public', 'full');
201
+ assert.equal(openEnrollmentEnabled({ branding: pack, joinabilityMode: () => 'open', publishable: false }), false);
202
+ assert.equal(openEnrollmentEnabled({ branding: pack, joinabilityMode: () => 'open', publishable: true }), true);
203
+ });
@@ -158,7 +158,13 @@ const src = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
158
158
 
159
159
  test('the web sign-in refusal names the invite-only door instead of sending a stranger to a request form', () => {
160
160
  const auth = src('src/bongos/routes/auth.js');
161
- assert.match(auth, /auth\.joinabilityMode\(\) === 'invite_only'/);
161
+ // task 1003579: the branch tests the COMPOSED door, not the `joinability` knob.
162
+ // `apply` is the only door that accepts an application, so a project closed by
163
+ // its VISIBILITY ceiling (stealth, or a `view` grant, with `joinability` still
164
+ // reading `apply`) lands on this page too instead of being handed a request form
165
+ // the application endpoint refuses. Both closed doors show the same sentence,
166
+ // which is ADR 0247 D3 — a refusal may not be read backwards into a state.
167
+ assert.match(auth, /effectiveJoinDoor\(\) !== 'apply'/);
162
168
  assert.match(auth, /'This project is invite-only'/);
163
169
  assert.match(auth, /doesn’t take applications/);
164
170
  });