@bongos/core 1.19.567 → 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.567",
6
- "core_contract": "1.19.567",
7
- "source_commit": "086c24f62d0d594e3949cda82f35b6c73105e9c8",
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:18:34.727Z",
9
+ "built_at": "2026-09-06T20:34:30.476Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 447,
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": 2508,
20
- "tree_sha256": "0de4688c7206008f5e7ce542823b8efe4c438fc7debe1dffb116e3cfe63558d4",
19
+ "file_count": 2511,
20
+ "tree_sha256": "5c3c486ba0465ca763a11d15487bb2b540558af8430a93ea27a07323d75352e1",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -392,7 +392,7 @@
392
392
  {
393
393
  "path": "clients/bongos-client/index.d.ts",
394
394
  "mode": "0000644",
395
- "sha256": "f7c16a633c0f5c3ba13421815d08f5fb935b8737433acffe72fef018adffbf25"
395
+ "sha256": "2b37686507b5ea701c19a1086352a2f0c84eae46510ef12b3a601c18812610c6"
396
396
  },
397
397
  {
398
398
  "path": "clients/bongos-client/index.mjs",
@@ -1789,10 +1789,20 @@
1789
1789
  "mode": "0000644",
1790
1790
  "sha256": "4e27106f8c9251b788d13bcfc64f29637bb0b009c18102c9b7dd9d70b80400dc"
1791
1791
  },
1792
+ {
1793
+ "path": "docs/adr/0255-a-public-lists-ordering-is-part-of-its-payload.md",
1794
+ "mode": "0000644",
1795
+ "sha256": "2c82050a733a0a82dde5079bbc486fc3378b2be954817a811aab92b9e46c9c5b"
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
+ },
1792
1802
  {
1793
1803
  "path": "docs/adr/README.md",
1794
1804
  "mode": "0000644",
1795
- "sha256": "2e2089caa50d1b7442fee96b14f758e6e29974490a8729cfa9401a59e70077b4"
1805
+ "sha256": "52422b934d0b3245605ec0913049be65768a814a6f9aee5f7ad8149c4221a093"
1796
1806
  },
1797
1807
  {
1798
1808
  "path": "docs/api-reference.md",
@@ -1802,7 +1812,7 @@
1802
1812
  {
1803
1813
  "path": "docs/api/openapi.json",
1804
1814
  "mode": "0000644",
1805
- "sha256": "b4c2891985427587d5a065b6e7d69638c5cd554d42d5eb22572b9c82f898431f"
1815
+ "sha256": "9148616a367b2f7486a8f14c0f7ecf9ed367bd552075b15c4f2b0f3dd40712db"
1806
1816
  },
1807
1817
  {
1808
1818
  "path": "docs/architecture.md",
@@ -2667,7 +2677,7 @@
2667
2677
  {
2668
2678
  "path": "docs/file-map.md",
2669
2679
  "mode": "0000644",
2670
- "sha256": "8b756e1acdec79324f026463292134b1e80613f207015d65fe5f1d158476c306"
2680
+ "sha256": "dd40cd29025789ec5a9447aa44ec6c7d3bee6aaae183c65713a6a3ca8ed8275b"
2671
2681
  },
2672
2682
  {
2673
2683
  "path": "docs/handoff-template.md",
@@ -2677,7 +2687,7 @@
2677
2687
  {
2678
2688
  "path": "docs/module-api-changelog.md",
2679
2689
  "mode": "0000644",
2680
- "sha256": "cb50b3fb2a66dc2e8c4e69f97055192f33e21fb33089f2c77313f43ae6daea09"
2690
+ "sha256": "5942f6c9fae093d6fbb85032ca496e5f0549cc5044d8ffa4ecb90c3c9d2695dd"
2681
2691
  },
2682
2692
  {
2683
2693
  "path": "docs/modules-contract.md",
@@ -6047,7 +6057,7 @@
6047
6057
  {
6048
6058
  "path": "modules/platform-identity/platform-identity.js",
6049
6059
  "mode": "0000644",
6050
- "sha256": "5cccd4a776abc25f0e57814963b1dc00b8a7a49b5024d2c0b88c85d8312091f1"
6060
+ "sha256": "54f44f6292729e822083671913dc89de28ede760dc88028ce4a18219ce006ab3"
6051
6061
  },
6052
6062
  {
6053
6063
  "path": "modules/platform-identity/pollers/code-sweep.js",
@@ -6072,7 +6082,7 @@
6072
6082
  {
6073
6083
  "path": "modules/platform-identity/projects-feed.js",
6074
6084
  "mode": "0000644",
6075
- "sha256": "fac8b2f750a5a9fb3c05290223292449b04a45c33b6976da285126616a8e9e91"
6085
+ "sha256": "9a8a22d21089770ec80f709d27f798eab6101cfaead18942c439f7c8689ef041"
6076
6086
  },
6077
6087
  {
6078
6088
  "path": "modules/platform-identity/publish-gate.js",
@@ -6102,7 +6112,7 @@
6102
6112
  {
6103
6113
  "path": "modules/platform-identity/routes/projects.js",
6104
6114
  "mode": "0000644",
6105
- "sha256": "af098885cd3d7e83a9302c7eb8213c3448d73ab79be53b2b5ff1467fbab3685a"
6115
+ "sha256": "7db130e5fe698bc133475d5c7ab90583fbe3683ac1d11b2542c56e76a7da4cfd"
6106
6116
  },
6107
6117
  {
6108
6118
  "path": "modules/platform-identity/routes/public-profile.js",
@@ -7527,12 +7537,12 @@
7527
7537
  {
7528
7538
  "path": "package-lock.json",
7529
7539
  "mode": "0000644",
7530
- "sha256": "88e992e7dc0acfb523d3c55d84184674c584a95ae5e9b9a7339e1ef8d39954c8"
7540
+ "sha256": "e2279fd35b142a93a9037638445d5e6e616c69cd8322d05fad2aa228a5de3515"
7531
7541
  },
7532
7542
  {
7533
7543
  "path": "package.json",
7534
7544
  "mode": "0000644",
7535
- "sha256": "ef5db9f2e98f980f0eea7ee5409a29d544ac73edcf33a15f8ecde5863db87e8d"
7545
+ "sha256": "1ba4dccdc1e8d591a783bdac3b0ac4f0d121ad687fef73347ac56e32e69524f7"
7536
7546
  },
7537
7547
  {
7538
7548
  "path": "public-docs/index.html",
@@ -7972,7 +7982,7 @@
7972
7982
  {
7973
7983
  "path": "scripts/gds/fitness.js",
7974
7984
  "mode": "0000644",
7975
- "sha256": "c825842ed0e4bf34fa9fa7bc7e711967d1aa0efdde4132253c51231c6ccbd22e"
7985
+ "sha256": "a93e0d79fe1d557d09711e9b9bb62ffc7687b91ee2981b71eb89176eb0fc2ce6"
7976
7986
  },
7977
7987
  {
7978
7988
  "path": "scripts/gds/gate-review.js",
@@ -8942,7 +8952,7 @@
8942
8952
  {
8943
8953
  "path": "src/bongos/auth-admission.js",
8944
8954
  "mode": "0000644",
8945
- "sha256": "46dc4adc21f138b4d9fdfe06c85f71d591a81357050d48d72b6272ba86a09d3b"
8955
+ "sha256": "8dc4cc0ab956d387b8044c22e83fce883f2f0129ed27c03503f7348e9d4f00bb"
8946
8956
  },
8947
8957
  {
8948
8958
  "path": "src/bongos/auth-config.js",
@@ -9077,7 +9087,7 @@
9077
9087
  {
9078
9088
  "path": "src/bongos/project-door.js",
9079
9089
  "mode": "0000644",
9080
- "sha256": "045c0edceb0f71c3b488471ffb3a1c20c58014d0592c9ac44572245a7eb08960"
9090
+ "sha256": "60a342af5b1d4aaeb781faf5bfea75d69a32b9bba71d0740ccc4ca1a9af2bba4"
9081
9091
  },
9082
9092
  {
9083
9093
  "path": "src/bongos/project-settings.js",
@@ -9117,7 +9127,7 @@
9117
9127
  {
9118
9128
  "path": "src/bongos/routes/auth.js",
9119
9129
  "mode": "0000644",
9120
- "sha256": "4c26db2cae7070a44523053d369da83a41ed38677b46292da927b061a398a4eb"
9130
+ "sha256": "f7e24d83d5fceceb33eacc7d82ee8136a2177bb190e5b912e5857ffc07090009"
9121
9131
  },
9122
9132
  {
9123
9133
  "path": "src/bongos/routes/backup.js",
@@ -9142,7 +9152,7 @@
9142
9152
  {
9143
9153
  "path": "src/bongos/routes/instance.js",
9144
9154
  "mode": "0000644",
9145
- "sha256": "5b85f187b94282cd1ec5ece022238c4b30048f999c4347f19081bbaed32795fb"
9155
+ "sha256": "c3bfdf5f1242fe4bbbb51ea79401dd5ae09cca95adc475e0dc0e5c76055677c4"
9146
9156
  },
9147
9157
  {
9148
9158
  "path": "src/bongos/routes/live.js",
@@ -9212,7 +9222,7 @@
9212
9222
  {
9213
9223
  "path": "src/branding.js",
9214
9224
  "mode": "0000644",
9215
- "sha256": "8885ee12599cdfdaf9bdb073185c83a5532bdc46d07edaeab42e36ccdc12f8e1"
9225
+ "sha256": "2bbb5774c9ae8659b4d5ce9b62cb558d28d036b2aa0075f9329ae85c15734a2d"
9216
9226
  },
9217
9227
  {
9218
9228
  "path": "src/build-info.js",
@@ -9232,7 +9242,7 @@
9232
9242
  {
9233
9243
  "path": "src/module-api.js",
9234
9244
  "mode": "0000644",
9235
- "sha256": "318a0b749a46e513641ebec57124282b811631fe0037a166f50abd28d3b63f40"
9245
+ "sha256": "6c1005562c9950b7cee858382e3cfd643f5c2be27918859de46927c2fbd29c06"
9236
9246
  },
9237
9247
  {
9238
9248
  "path": "src/module-loader/catalog.js",
@@ -9304,6 +9314,11 @@
9304
9314
  "mode": "0000644",
9305
9315
  "sha256": "f7434d510d656e5081f735d1f9356ecb11e8c3630a86a86137c3290134541c8d"
9306
9316
  },
9317
+ {
9318
+ "path": "tests/admission_ceiling_gate.mjs",
9319
+ "mode": "0000644",
9320
+ "sha256": "79c216e3ad3d91388a4a48f4ec5f3af9cf440d82791f05a1eb89860573be8dff"
9321
+ },
9307
9322
  {
9308
9323
  "path": "tests/adopt_detect.mjs",
9309
9324
  "mode": "0000644",
@@ -11012,7 +11027,7 @@
11012
11027
  {
11013
11028
  "path": "tests/joinability_invite_only.mjs",
11014
11029
  "mode": "0000644",
11015
- "sha256": "5311e076e8a72b993a235e57ebc54c9ecd529ccd31b65609432677a12e15e85d"
11030
+ "sha256": "0c2c540a794fcdafbdf4c496f2dd3efc508edfb9f30f88a6c0f977fc99bb75cc"
11016
11031
  },
11017
11032
  {
11018
11033
  "path": "tests/joinability_open.mjs",
@@ -12507,7 +12522,7 @@
12507
12522
  {
12508
12523
  "path": "tests/visible_projects_feed.mjs",
12509
12524
  "mode": "0000644",
12510
- "sha256": "c5d4a17ba9765069a7850fef5d3843e076fbd08edbc7d690a36e2ff4299d10c2"
12525
+ "sha256": "a15bfe5f3b664a9daa4f45b80eb7a5bee2b2c630198439352c170fc44c4b2025"
12511
12526
  },
12512
12527
  {
12513
12528
  "path": "tests/wandering_prefs.mjs",
@@ -90,7 +90,7 @@ export interface GetMyProjectsResponse { owned: unknown; building: unknown; appl
90
90
  export interface GetOverrideRequestsResponse { requests: unknown }
91
91
  export interface GetProfilesHandleResponse { account: unknown; cross_project: unknown }
92
92
  export interface GetProjectSettingsResponse { rows: unknown }
93
- export interface GetProjectsFeaturedResponse { projects: unknown }
93
+ export interface GetProjectsFeaturedResponse { projects: unknown; page: PageInfo }
94
94
  export interface GetProjectsResponse { projects: unknown }
95
95
  export interface GetProvisioningFleetResponse { instances: unknown }
96
96
  export interface GetProvisioningGithubAppStatusResponse { ok: boolean; status: unknown; ready: unknown; terminal: unknown; domain: unknown; instance_status: unknown; app_slug: unknown }
@@ -0,0 +1,169 @@
1
+ # ADR 0255 — A public list's ordering is part of its payload, so an anonymous row must be ordered by nothing
2
+
3
+ - **Status:** accepted
4
+ - **Date:** 2026-09-06
5
+ - **Task:** [task 1003561](https://cloudbongos.com/builders#/task/1003561) (BONGOS-V1, goal 1000046 — *Project creation*), which absorbed [task 1003560](https://cloudbongos.com/builders#/task/1003560)
6
+ - **Deciders:** Claude, building against ADR 0182 D2a
7
+ - **Related:** [ADR 0182](<redacted>.md) D1/D2a/D3 (the join door, the stealth redaction, and `featured`'s remaining ranking job) · [ADR 0252](<redacted>.md) §5.3 (the per-IP ceiling on this route) · [ADR 0253](<redacted>.md) (`visibility_source` / `visibility_reported_at`, the `DOOR_KNOWN_SQL` predicate this feed joins) · [ADR 0119](0119-api-pagination-contract.md) (the `?limit=&offset=` contract this feed was the last public list to hold out on)
8
+
9
+ ---
10
+
11
+ ## 1. The leak
12
+
13
+ `modules/platform-identity/projects-feed.js` takes considerable trouble over what a stealth
14
+ project may say. `feedShapeFor()` rebuilds an anonymous row from an allow-list —
15
+ `STEALTH_ANONYMOUS_KEEPS = ['state', 'orb']` — and the constant's own comment states the
16
+ threat model it is defending against:
17
+
18
+ > No stable per-project key appears here on purpose: an id, a slug or a hash of the origin
19
+ > would each let an observer who kept yesterday's public feed name the project that went
20
+ > stealth today. Black holes are interchangeable, which is the whole point of the metaphor.
21
+
22
+ The feed then sorted `ORDER BY featured DESC, updated_at DESC` and handed the observer a
23
+ stable per-project key anyway. **Ordering is a covert column.** Every row's position is the
24
+ result of a comparison, and enough comparisons reconstruct the key the comparisons were made
25
+ on. Three consequences followed, and the third is the one that matters:
26
+
27
+ 1. **`updated_at` was published despite being deliberately withheld.** The projection
28
+ excludes it on purpose — the same file notes that feature toggles and client
29
+ re-registration bump it, so as a public "last updated" it would lie. Sorting by it
30
+ published it anyway, one comparison at a time.
31
+ 2. **A black hole sat between named neighbours**, whose own relative order is visible. That
32
+ brackets the hidden row's `updated_at` between two rows an observer can identify by name,
33
+ which is a far narrower fact about it than "a project exists."
34
+ 3. **A black hole MOVED when its hidden project was touched.** This is the activity oracle.
35
+ Two fetches of a public, CORS-open, unauthenticated feed are enough to learn *that the
36
+ project behind this particular black hole was just active* — and correlating that with
37
+ anything happening in the open (a release, a post, a commit) is how an anonymous row gets
38
+ a name. Position was also stable enough between touches to follow one black hole across
39
+ snapshots, which is precisely the "stable per-project key" the payload refuses to carry.
40
+
41
+ The redaction was doing its job. The `ORDER BY` was undoing it.
42
+
43
+ ## 2. Decision — the rule
44
+
45
+ **A row may be ordered only by what it publishes.**
46
+
47
+ - An **identified** row (`public` → planet, `private` → star) publishes `created_at` and
48
+ `origin`, so it is ordered by those, beneath `featured` — ADR 0182 D3's one remaining
49
+ job, ranking among rows already on the map. The resulting order is a **pure function of
50
+ the wire payload**: a reader holding nothing but the response can reproduce it. That is
51
+ the strongest available statement that a sort leaks nothing, and it is worth stating as
52
+ the positive form of the rule rather than as an absence of known leaks.
53
+ - An **anonymous** row (`stealth`, and anything unrecognised) publishes nothing, so it is
54
+ ordered by nothing about itself. The black holes are collated into **one trailing block**,
55
+ permuted by a **keyed, rotating hash**.
56
+
57
+ Concretely, four sort terms, of which the first partitions the two blocks so that no later
58
+ term ever compares across them:
59
+
60
+ ```sql
61
+ ORDER BY (visibility IN ('public','private')) DESC,
62
+ ((visibility IN ('public','private')) AND featured) DESC,
63
+ (CASE WHEN (visibility IN ('public','private')) THEN created_at END) DESC,
64
+ (CASE WHEN (visibility IN ('public','private')) THEN origin
65
+ ELSE md5(origin || $1::text) END) ASC
66
+ ```
67
+
68
+ Terms 2–4 are **guarded**, and that is the whole trick. Without the guards the trailing
69
+ block would simply be re-sorted by `featured`, then `created_at`, then `origin` — none of
70
+ which a black hole publishes, and `created_at` in particular would place it by its listing
71
+ date, which narrows *which* project it is for anyone who watched the catalog fill up. The
72
+ "anonymous rows go last" half without the "and are blanked" half is not a fix.
73
+
74
+ The identified-state list is **derived from `ORB_BY_STATE`**, never re-typed, and is an
75
+ **allow-list**: a state this file does not know sorts into the anonymous block, the same
76
+ fail-closed direction `orbStateOf()` already takes for rendering. A deny-list would draw a
77
+ future fourth enum value as a black hole and then sort it by its `created_at` — anonymous in
78
+ the payload, identified in the ordering, which is the worst of both.
79
+
80
+ ## 3. Why the anonymous block is keyed, and why it rotates
81
+
82
+ Two separate properties, both load-bearing:
83
+
84
+ **Secret.** Origins are guessable — they are domain names. A permutation an observer could
85
+ compute (`md5(origin)`, `md5(origin || today)`, or plain alphabetical order by `origin`)
86
+ would let them hash their candidate origins, match the resulting ranks against the live
87
+ block, and de-anonymise every black hole in one pass. The salt is therefore a random
88
+ per-process key. Nothing persists it, compares it across restarts, or reads it back — it
89
+ only ever *orders* rows — so there is no key to configure, distribute, rotate by hand, or
90
+ leak from disk.
91
+
92
+ **Rotating.** A permanently stable permutation would still make **position** a durable
93
+ per-project handle: an observer could not name a black hole, but could follow one across
94
+ months and watch it appear and disappear. The salt therefore also carries a coarse epoch
95
+ (24h), so the whole block is re-dealt on a fixed period regardless of process lifetime.
96
+ Within one period the order is stable, which is what lets the pages of a single listing agree
97
+ with each other.
98
+
99
+ `md5` is a permutation here, not a security primitive; the unpredictability comes from the
100
+ secret salt, and it is the most portable hash available without pgcrypto.
101
+
102
+ ## 4. Pagination is the same decision, not a neighbouring one
103
+
104
+ Task 1003560 asked for a cap on an "unbounded" feed. **That premise was already stale:**
105
+ `<redacted> = 500` shipped with the feed itself, and its own comment named the
106
+ cap "the cheap half of that trade — the honest half is pagination, which is its own task."
107
+ The surviving work was pagination only, and pagination edits the same single SQL statement
108
+ this ADR rewrites — so the two tasks were built together and 1003560 was merged into 1003561.
109
+
110
+ - **`?limit=&offset=`, per ADR 0119**, whose helper (`parsePagination` / `pageMeta`) this
111
+ route was the last public list not to use.
112
+ - **No cursor, and that is a security property as much as a style one.** An opaque cursor
113
+ over this feed would have to encode a sort position, and the obvious encoding — the last
114
+ row's timestamp — is exactly the side-channel §2 exists to close. An offset encodes nothing
115
+ about any row.
116
+ - **The default page size *is* the maximum (500), deliberately.** A caller that sends no
117
+ parameters gets byte-for-byte what it got before. One consumer of this feed is a static
118
+ orbs site in **another repository** that this commit cannot update; a smaller default would
119
+ have silently shrunk somebody else's map. `page` is added *beside* the existing `projects`
120
+ key rather than wrapping it, so the wire change is additive and every consumer that
121
+ destructures `data.projects` keeps working.
122
+ - **Paging cannot aim more fan-out than one request already could.** `?limit=` only ever
123
+ shrinks a page. `?offset=` reaches deeper, and the cost of that is bounded not by the
124
+ request count but by `project-stats.js`'s 10-minute per-origin cache: a second page reaches
125
+ origins the cache would have covered anyway, and re-reading any page is free. The per-IP
126
+ limiter (120 reads/60s, ADR 0252 §5.3) is unchanged and still bounds how often.
127
+ - **No `total`.** Counting is a second pass over the same predicate for a number nothing
128
+ reads today; `pageMeta()` makes it optional for that reason. A caller learns there is more
129
+ the standard way: `projects.length === page.limit` means ask for the next offset.
130
+
131
+ ## 5. Alternatives weighed
132
+
133
+ | Option | Why not |
134
+ |---|---|
135
+ | Sort every row by `created_at DESC` | `created_at` is nulled on anonymous rows, so a black hole would be *positioned* by a date its own row refuses to carry — and its listing date is a strong hint at which project it is. Worse than the bug being fixed. |
136
+ | Order the anonymous block alphabetically by `origin` | Deterministic and timing-free, but position then reveals the alphabetical rank of a hidden domain — an oracle against a candidate set. |
137
+ | `ORDER BY random()` for the anonymous block | Kills both the timing signal and the durable handle, maximally. Rejected because it breaks pagination outright: a black hole could appear on two pages or none, and the count of black holes — which *is* public by design — would stop being stable within one listing. |
138
+ | Drop the anonymous rows from the feed entirely | Contradicts ADR 0182 D2a, which exposes existence on purpose. A black hole is a deliberate feature of the map. |
139
+ | Keep `updated_at` but bucket it coarsely (e.g. by week) | Still a timing channel, only quieter, and it would need a defensible bucket width nobody could justify. A sort key that must be blurred is a sort key that should not be there. |
140
+
141
+ ## 6. What this does NOT close
142
+
143
+ Stated plainly, because a privacy fix that oversells itself is worse than none:
144
+
145
+ 1. **Existence and count are public by design.** The number of black holes on the map is
146
+ visible and always was — that is ADR 0182 D2a, not a defect.
147
+ 2. **The identified set still changes when a project goes stealth.** An observer diffing
148
+ yesterday's feed against today's sees a named project leave the identified block while
149
+ the black-hole count rises by one, and can name it that way. This is a **larger** channel
150
+ than the ordering one and it is untouched here: it is inherent in publishing a named list
151
+ at all, and closing it needs a different mechanism (a delay, a decoy, or accepting that a
152
+ project's *departure* from the public list is public). Filed separately rather than
153
+ silently implied to be fixed.
154
+ 3. **Position is stable within one epoch.** Two snapshots taken the same day can still tell
155
+ black holes apart, though not name them. The rotation bounds this to the epoch.
156
+
157
+ ## 7. Consequences
158
+
159
+ - The map's identified rows now sort by **listing date**, not last-touched. The feed stops
160
+ churning on `updated_at` bumps that meant nothing to a reader anyway.
161
+ - `<redacted>` is renamed `PUBLIC_FEED_MAX_PAGE_SIZE`, which is what it now is:
162
+ a per-request fan-out ceiling that doubles as the default page size.
163
+ - `listPublicProjects({ pool, limit, offset, now })` clamps its own window as well as the
164
+ route clamping it, because it is a module-public function and the ceiling is a fan-out
165
+ bound — it may not depend on every future caller remembering to pass one.
166
+ - Proof lives in `tests/visible_projects_feed.mjs`, which now pins the guards structurally:
167
+ the first sort term partitions the blocks, every identified key is guarded, there is no
168
+ fifth unguarded term, `updated_at` appears nowhere in the query, and the salt is bound as
169
+ a parameter rather than interpolated.
@@ -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.