@bongos/core 1.19.577 → 1.19.579

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.577",
6
- "core_contract": "1.19.577",
7
- "source_commit": "762e093b407c02f640fd7c4f28f35b4aba92b905",
5
+ "core_version": "1.19.579",
6
+ "core_contract": "1.19.579",
7
+ "source_commit": "8b206e9f473df316fc1981854a479d1ba509d76e",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-07T16:20:42.564Z",
9
+ "built_at": "2026-09-07T17:52:17.407Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 451,
12
+ "docs_redacted": 452,
13
13
  "agent_docs_stubbed": 24,
14
- "functional_verbatim": 2046,
14
+ "functional_verbatim": 2047,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2521,
20
- "tree_sha256": "681bcb585fd2e75cf4e37463230354417f0f352734aa5057de24da8cfb8d8712",
19
+ "file_count": 2523,
20
+ "tree_sha256": "bd13bef2ae141fa766ed5c8a67894be21c62263079037e763bc1533ff500afdc",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -1792,7 +1792,7 @@
1792
1792
  {
1793
1793
  "path": "docs/adr/0255-a-public-lists-ordering-is-part-of-its-payload.md",
1794
1794
  "mode": "0000644",
1795
- "sha256": "2c82050a733a0a82dde5079bbc486fc3378b2be954817a811aab92b9e46c9c5b"
1795
+ "sha256": "9240caa363815ca9bbd49993bd73ecda85c6f91280ac152f344f69c77928bd22"
1796
1796
  },
1797
1797
  {
1798
1798
  "path": "docs/adr/0256-the-visibility-ceiling-is-the-sign-in-gate-too-and-the-reader-moves-to-the-composer.md",
@@ -1809,10 +1809,15 @@
1809
1809
  "mode": "0000644",
1810
1810
  "sha256": "28f3644d3d9dcd2985b7e3f42cf2135af86203eee35b6e509d1ff98f630f1228"
1811
1811
  },
1812
+ {
1813
+ "path": "docs/adr/0259-a-projects-departure-from-the-public-list-is-public.md",
1814
+ "mode": "0000644",
1815
+ "sha256": "772b9153d882f6bbd5000ee90b83e05d550ee4f04d4f5392db36e223d9a58c59"
1816
+ },
1812
1817
  {
1813
1818
  "path": "docs/adr/README.md",
1814
1819
  "mode": "0000644",
1815
- "sha256": "30bb35e65ebd2dc8c5eb0aad7e30d5875d0c764265f398547abd545b62ee7d7c"
1820
+ "sha256": "bf52fa642d9a576a88f961b46fbb68352e3fd8dd79004440f5e4182e549039ed"
1816
1821
  },
1817
1822
  {
1818
1823
  "path": "docs/api-reference.md",
@@ -1847,12 +1852,12 @@
1847
1852
  {
1848
1853
  "path": "docs/copy-inventory.md",
1849
1854
  "mode": "0000644",
1850
- "sha256": "8bdf39ea0b8ed9583f9ef68b0edae767eec9c2825f8ef355d678b3db3d08b5e1"
1855
+ "sha256": "da770863626202ff2566de7f0a152eebc086662c74d86423794d378fca2d83e6"
1851
1856
  },
1852
1857
  {
1853
1858
  "path": "docs/copy-registry.json",
1854
1859
  "mode": "0000644",
1855
- "sha256": "cd51497ccca735bf1f8178291b427f4166aff79870a080a4a9d036238f0ff90c"
1860
+ "sha256": "423614f0519d1e94e605e439b53a2bac7a63c2ae8005b0021c71e6c81f38edfc"
1856
1861
  },
1857
1862
  {
1858
1863
  "path": "docs/design/apex-pass-2-direction.md",
@@ -2697,7 +2702,7 @@
2697
2702
  {
2698
2703
  "path": "docs/module-api-changelog.md",
2699
2704
  "mode": "0000644",
2700
- "sha256": "39e12981a4c704b133c9afc6c10cdb225fbe2cc882e667916eed2785ce0ae5ef"
2705
+ "sha256": "cb7bb494b4b2ad905f1067b6f3c11e6331faa8beaad533c70d4a5009c456a807"
2701
2706
  },
2702
2707
  {
2703
2708
  "path": "docs/modules-contract.md",
@@ -6812,7 +6817,7 @@
6812
6817
  {
6813
6818
  "path": "modules/public-landing/public/projects.html",
6814
6819
  "mode": "0000644",
6815
- "sha256": "7aa74c059670bd351bf8a4295e408aef4a908e3095711aee93f75408ccd837d5"
6820
+ "sha256": "0154fab4db489db2eb1955bffdd9b0585ff7fe28607897e75c26d83c7edfc3e0"
6816
6821
  },
6817
6822
  {
6818
6823
  "path": "modules/public-landing/public/projects.probes.json",
@@ -7557,12 +7562,12 @@
7557
7562
  {
7558
7563
  "path": "package-lock.json",
7559
7564
  "mode": "0000644",
7560
- "sha256": "b2a595c7b297963949c8f0c4d662c56d6ce4334a8f9e92231f47f0cd9233d624"
7565
+ "sha256": "ce40726e213f4a94fd3983d81c5919e4cc435fda1d666e7cf598fc2f963b8741"
7561
7566
  },
7562
7567
  {
7563
7568
  "path": "package.json",
7564
7569
  "mode": "0000644",
7565
- "sha256": "a2009a9bab221342b0b8b9c3c75a21d2ae02fb2fc281235f6fa7e17513a99ff8"
7570
+ "sha256": "6ce1fcd5c0378c8001b8249d8359d04ecfa5730e07dc3e21cda4952fcf1deeae"
7566
7571
  },
7567
7572
  {
7568
7573
  "path": "public-docs/index.html",
@@ -9267,7 +9272,7 @@
9267
9272
  {
9268
9273
  "path": "src/module-api.js",
9269
9274
  "mode": "0000644",
9270
- "sha256": "a06af2e38ee9044c10f14f0ce1392ab85adb8058421222bf894f0f7ed6d77326"
9275
+ "sha256": "c1da85daf5425cfb3bbd7d8e29e6995e90ead6088565476973954ca366ef14f5"
9271
9276
  },
9272
9277
  {
9273
9278
  "path": "src/module-loader/catalog.js",
@@ -11179,6 +11184,11 @@
11179
11184
  "mode": "0000644",
11180
11185
  "sha256": "9a1813d6036886ea07d1563e71a466e66c28554b75b22605464c795fd9fa2ebb"
11181
11186
  },
11187
+ {
11188
+ "path": "tests/manage_manifest_shared_read.mjs",
11189
+ "mode": "0000644",
11190
+ "sha256": "419234978bb7688f83ec14bb2db7fb4be1eec99205595795c705befe5620b89c"
11191
+ },
11182
11192
  {
11183
11193
  "path": "tests/manage_settings_shared_read.mjs",
11184
11194
  "mode": "0000644",
@@ -11642,7 +11652,7 @@
11642
11652
  {
11643
11653
  "path": "tests/projects_hub.mjs",
11644
11654
  "mode": "0000644",
11645
- "sha256": "f93044429430c9fff4fe6860bae87ff8f67bc49a485391e659422e63f209e08c"
11655
+ "sha256": "d32fd31e368acdaaf1d4f31b775f62ef8e20ea4f5810a37fddb8ef1dba1487b6"
11646
11656
  },
11647
11657
  {
11648
11658
  "path": "tests/projects_hub_module_picker.mjs",
@@ -150,7 +150,10 @@ Stated plainly, because a privacy fix that oversells itself is worse than none:
150
150
  than the ordering one and it is untouched here: it is inherent in publishing a named list
151
151
  at all, and closing it needs a different mechanism (a delay, a decoy, or accepting that a
152
152
  project's *departure* from the public list is public). Filed separately rather than
153
- silently implied to be fixed.
153
+ silently implied to be fixed — and **now decided**: the owner chose to accept it and say
154
+ so in the copy ([ADR 0259](<redacted>.md),
155
+ [task 1003672](https://cloudbongos.com/builders#/task/1003672)), which also carries the
156
+ §6.3 residual below.
154
157
  3. **Position is stable within one epoch.** Two snapshots taken the same day can still tell
155
158
  black holes apart, though not name them. The rotation bounds this to the epoch.
156
159
 
@@ -0,0 +1,128 @@
1
+ # ADR 0259 — A project's departure from the public list is public, and the copy says so
2
+
3
+ - **Status:** accepted
4
+ - **Date:** 2026-09-07
5
+ - **Task:** [task 1003672](https://cloudbongos.com/builders#/task/1003672) (BONGOS-V1, goal 1000046 — *Project creation*)
6
+ - **Deciders:** the owner (choosing between accept / delay / decoys), Claude
7
+ - **Related:** [ADR 0255](<redacted>.md) §6.2 (which filed this leak rather than implying it fixed) · [ADR 0182](<redacted>.md) D2a (the stealth redaction and its `STEALTH_ANONYMOUS_KEEPS` allow-list) · [ADR 0253](<redacted>.md) (the `visibility_source` provenance this feed joins)
8
+
9
+ ---
10
+
11
+ ## 1. The channel
12
+
13
+ The public projects feed publishes a named list plus a count of anonymous rows. When a
14
+ project flips to stealth, its named row **leaves the identified block** and the anonymous
15
+ count **rises by one**. An observer who keeps yesterday's feed and diffs it against today's
16
+ reads that off directly:
17
+
18
+ > *Mercury is gone, and there is one more black hole.*
19
+
20
+ That names the project that went stealth, and the moment it happened, with no reference to
21
+ ordering at all.
22
+
23
+ **Everything ADR 0182 D2a does is defeated by it.** That ADR takes real trouble to keep any
24
+ stable per-project key off an anonymous row — `STEALTH_ANONYMOUS_KEEPS = ['state', 'orb']`
25
+ is an allow-list precisely so a column added next year is anonymous without anyone
26
+ remembering to come back to it. But the identity was never carried *on* the anonymous row.
27
+ It is carried by the **absence** of the named row that used to sit beside it.
28
+
29
+ **It is larger than the leak [ADR 0255](<redacted>.md)
30
+ closed.** The ordering channel needed repeated sampling and correlation to be useful. This
31
+ one is a single diff of two snapshots and it is exact.
32
+
33
+ ## 2. Why no ordering fix reaches it
34
+
35
+ It is not an ordering property, so no `ORDER BY` change touches it. It is inherent in
36
+ publishing a named list at all: any list that names its members leaks a member's departure
37
+ to anyone who read it twice. The three ways out are all product decisions with visible
38
+ costs, which is why 0255 filed this rather than fixing it:
39
+
40
+ | Option | Cost |
41
+ |---|---|
42
+ | **Accept it, and say so** | An observer archiving the list learns who went stealth, and when. |
43
+ | **Delay** — keep the named row for a window | A privacy control that takes days to take effect, which is the wrong direction for a privacy control. |
44
+ | **Decoys** — pad the anonymous block | The count stops being truthful. It is truthful today. |
45
+
46
+ ## 3. Decision
47
+
48
+ **Accept it, and make the copy say so.**
49
+
50
+ The promise stealth actually keeps is about the project's *contents*, not about the
51
+ *transition*. An anonymous row stays anonymous: no name, no tagline, no art, no origin, no
52
+ stable key, nothing that survives to the next snapshot. What is not private is the act of
53
+ stepping off the named list, because the named list is published and readers may keep
54
+ copies of it.
55
+
56
+ Two reasons this is the right end of the trade, and not merely the cheapest:
57
+
58
+ 1. **It is already the honest reading of the existing promise.** ADR 0182 D2a made existence
59
+ and count public *by design* — the map shows that hidden projects are there. A count that
60
+ is public and truthful is a count whose changes are public. Delay and decoys both work by
61
+ making a currently-honest number less honest.
62
+
63
+ 2. **The alternatives damage the control they defend.** A delay means an owner clicks the
64
+ privacy setting and is not private yet, which is a worse failure than the one it fixes —
65
+ the owner believes they are hidden while they are not. Decoys spend the count's
66
+ truthfulness on a guarantee that is still only probabilistic.
67
+
68
+ ### 3.1 What "say so" means concretely
69
+
70
+ The owner-facing copy for Stealth previously read:
71
+
72
+ > The map shows only that something is there. Its name, and everything else about it, stay
73
+ > yours — and nobody can ask to join.
74
+
75
+ Every clause is true and the paragraph as a whole still oversells, because a reader takes
76
+ "its name stays yours" to cover the switch itself. The copy now names the boundary in both
77
+ places an owner meets this setting — the choice's blurb and the explanatory fold — in the
78
+ plain register [ADR 0182](<redacted>.md)'s interview fixed for this
79
+ card (the space metaphor belongs to the drawn orb, never to the words).
80
+
81
+ **A privacy control that oversells is worse than one that admits a limit**, which is the
82
+ same standard 0255 §6 set for itself.
83
+
84
+ ## 4. What a future fix must not do
85
+
86
+ Recorded so a later attempt does not undo the two guarantees that *are* held:
87
+
88
+ - **No stable per-project key on an anonymous row** — the `STEALTH_ANONYMOUS_KEEPS`
89
+ allow-list ([ADR 0182](<redacted>.md) D2a). A decoy scheme that
90
+ needs to tell its own padding apart from real rows is the classic way this gets
91
+ reintroduced.
92
+ - **No anonymous ordering an observer can predict or correlate**
93
+ ([ADR 0255](<redacted>.md) §3) — the trailing
94
+ block's keyed, rotating permutation.
95
+
96
+ ## 5. The residual this does not address either
97
+
98
+ Within one 24h epoch the anonymous block's permutation is stable, so two snapshots taken the
99
+ same day can tell black holes apart — though not name them. Bounded by the rotation, and
100
+ carried here from [ADR 0255](<redacted>.md) §6.3 so
101
+ both residuals live in one place. Accepted on the same reasoning: the fix is a per-request
102
+ permutation, which breaks pagination exactly the way `ORDER BY random()` was rejected for in
103
+ 0255.
104
+
105
+ ## 6. Consequences
106
+
107
+ - No behaviour changes in `modules/platform-identity/projects-feed.js`. The feed, the
108
+ redaction allow-list and the permutation are all untouched — this ADR decides that they
109
+ are *finished*, not that they need more.
110
+ - The Stealth copy carries the boundary in both surfaces, and
111
+ `tests/projects_hub.mjs` pins that it does, so the sentence cannot quietly drop out of a
112
+ future copy edit and leave the page overselling again.
113
+ - [ADR 0255](<redacted>.md) §6.2's "filed
114
+ separately" is now resolved, and points here.
115
+
116
+ ## Alternatives rejected
117
+
118
+ - **A delay window before the named row is dropped.** Rejected: the setting would not take
119
+ effect when the owner sets it, and the owner would not know that. It converts an
120
+ information leak into a false belief, which is worse.
121
+ - **Decoy rows padding the anonymous block.** Rejected: it spends the count's honesty, and a
122
+ decoy scheme needs some way to distinguish padding from real rows — the exact stable key
123
+ D2a forbids.
124
+ - **Dropping the named list.** Rejected: the map's whole purpose is showing what is being
125
+ built here; a list nobody can read protects a privacy nobody asked for at the cost of the
126
+ feature.
127
+ - **Saying nothing and leaving the copy as it was.** Rejected: the copy would keep implying a
128
+ guarantee the system does not make. That is the failure mode this ADR exists to close.
@@ -350,3 +350,4 @@ This keeps the decision history honest and traceable.
350
350
  > ⚠️ **Numbering collisions are now machine-enforced, not narrated.** Twenty numbers are shared by two ADRs each — assigned in parallel sessions before anything checked. The files keep their filenames (renumbering would break every existing citation, and a number once assigned is never reused), and this table disambiguates each pair as `NNNN-a` / `NNNN-b`. The authoritative list is `LEGACY_DUPLICATE_ADRS` in [`scripts/gds/adr-namespace.js`](../../scripts/gds/adr-namespace.js), frozen by exact filename: a **new** collision — or a third file joining a legacy number, or a rename of either half — hard-fails the `unit` CI gate, as does an index row that points at no file or an ADR with no row. The list may only shrink. This paragraph used to narrate the collisions one by one and had fallen eight behind reality; see [ADR 0195](<redacted>.md) for why the check exists and why the pairs are grandfathered rather than renumbered.
351
351
  | 0257 | [**Auth resolves before the hall mounts anything, and a widget's boot read may never navigate** ([task 1003673](https://cloudbongos.com/builders#/task/1003673) · goal 1000063 — *The front door*). An invite-only instance could not admit its FIRST builder, and the symptom lied about where the fault was: an owner saw an empty Access-requests queue and no approve button, because **signing in does not file a request** — only the landing's *Request access* form does, and that form was unreachable. A signed-out visitor to `/builders` was bounced to GitHub, refused as a first-timer ("request access first"), and pointed back at `/builders` to be bounced again. Reproduced against `main`, not just an old pin. `builders.js` was already careful — `/me` is read `softAuth` and a 401 there renders the landing — but `DOMContentLoaded` called `mountHallWidgets()` FIRST, synchronously, and `goals.js`'s mount-time read of `/goals` goes through a `getJSON` with no `softAuth`, which answers 401 by NAVIGATING. The widget's read raced `renderLanding()` and won. **Decision: the front door settles which page this is before anything else runs** — `boot()` reads `/me` (soft), then either renders the landing and stops (nothing mounts, nothing else fetches) or mounts the hall, handing the resolved payload to `loadAll()` so the page still asks once. Widgets are member surfaces (`renderLanding` hid them all after the fact anyway), so not mounting them while signed out removes the CLASS rather than the one instance of it that was found; **a widget's own boot read may never navigate** is the belt beside it (`goals.js` reads soft and renders nothing without a session). A 401 during boot is not an instruction to go and sign in — only a user action is. `tests/hall_landing_boot.mjs` executes the real `builders.js` in a DOM stub and was confirmed to FAIL against the pre-fix file. Rejected: patching `goals.js` alone (it was merely first); making the 401 redirect soft everywhere (an expired session mid-visit SHOULD be sent to sign in — the line is boot vs user action); open enrollment as the cure (removes the gate [ADR 0050](<redacted>.md) chose deliberately instead of repairing the door).](<redacted>.md) | hall-ui / admission / front door |
352
352
  | 0258 | [**The public CLI is a generated client-only package, and its file list is proven by running it** ([task 1003679](https://cloudbongos.com/builders#/task/1003679) · goal 1000054 — *A newcomer can build without the UI*). The core ships private as `@bongos/core` ([ADR 0108](<redacted>.md)), so a newcomer with no credential can install NOTHING and the web hall is the only way in — the owner's words: "how am I supposed to easily take on tasks as a new builder? I should be able to do everything without the UI." Open task 1002025 proposed publishing the core, which ships the SERVER and bypasses [ADR 0099](<redacted>.md)'s redaction pipeline (dormant until ~2026-11). **Decision: generate a separate public `@cloudbongos/cli` from the core and do not publish the core.** Four load-bearing parts. (1) SCOPED NAME, because npm shares one namespace between org names and unscoped packages — the bare `cloudbongos` is unpublishable *precisely because the owner owns that org*, which npm reports as "invalid" and reads like the name is taken; and a package name cannot be created on the npm website at all (it exists on first publish, which is why "Add Existing Package" answered `Forbidden`). (2) THE FILE LIST IS DECLARED AND PROVEN BY BEHAVIOUR, never computed — a static require-closure CANNOT answer this, because `src/module-api.js` is the doorway a module may only import ([ADR 0083](<redacted>.md)) and it *names* every kernel capability, so a static walk sees 33 `src/` files from `start.js` alone where runtime resolves **one** (`api-prefix.js`, which imports nothing) — the same trap as task 1003677's unsatisfiable done-when. So the test packs the tarball, installs it into an empty dir with NO repo, and runs every verb: it may fail for want of a session, never for a missing file. That caught two real holes on its first strengthened run — `clients/bongos-client/index.mjs`, reached by a dynamic `import()` no `require()` walk can see, and a hand-listed `files` array that dropped `clients/` and produced a tarball which installed cleanly then died on first use; both are now build-time refusals, and the `files` array is derived from the manifest. (3) `claim`/`ship`/`dev`/`serve`/`module`/`upgrade`/`onboard`/`doctor`/`exec`/`package-core` are ABSENT and the CLI says WHY plus the next step (`bongos shell` → a cloud box with the full CLI) — a bare `unknown command` teaches nothing, which is the exact failure being fixed. The supported journey is `login` → `start` → `shell`. (4) A REDACTION GATE fails the build closed on non-loopback IPv4 (RFC 5737 doc ranges exempt), token/key shapes, and every domain + owner login readable from the instance's own `config/branding.json` — needles come from host config so the core carries no instance identity ([ADR 0062 §7](<redacted>.md)); `cloudbongos.com` is allowlisted as public by design. No `repository` field while the core repo is private (it would 404 for every user and publish the owner's login for nothing). 31 files, one dependency (`undici`), version independent of the core's ([ADR 0161](<redacted>.md)). `@bongos/client` is VENDORED, not depended on — a public package depending on a private one is uninstallable. **The owner runs `npm publish`; a builder must not.** Task 1002025 is superseded. Rejected: publishing the core; deriving from a static closure (structurally impossible past the doorway); waiting for the mirror (dormant, and this is a build product not a source release); an unscoped name; depending on `@bongos/client`; a degraded `claim`; shipping all of `src/` to make lazy getters safe.](<redacted>.md) | cli / distribution / public surface |
353
+ | 0259 | [**A project’s departure from the public list is public, and the copy says so** ([task 1003672](https://cloudbongos.com/builders#/task/1003672) · goal 1000046 — *Project creation*). Decides the leak [ADR 0255](<redacted>.md) §6.2 deliberately filed rather than implied fixed. The public projects feed publishes a named list plus a count of anonymous rows, so when a project flips to stealth its named row LEAVES the identified block while the count RISES BY ONE — *“Mercury is gone, and there is one more black hole”* names it, and the moment, from a single diff of two snapshots. **Everything [ADR 0182](<redacted>.md) D2a does is defeated by it**, because the identity was never carried ON the anonymous row (`STEALTH_ANONYMOUS_KEEPS` is an allow-list precisely so a column added next year is anonymous by default) — it is carried by the ABSENCE of the named row that used to sit beside it. **Larger than the channel 0255 closed**, which needed repeated sampling and correlation; this one is exact and needs two reads. No `ORDER BY` change touches it: it is inherent in publishing a named list at all, so every fix is a product decision with a visible cost and the owner’s call, not a builder’s. **Decision: accept it, and make the copy say so.** Two reasons it is the right end of the trade and not merely the cheapest — (1) it is ALREADY the honest reading of the existing promise, since D2a made existence and count public *by design*, and a count that is public and truthful is a count whose CHANGES are public; (2) both alternatives damage the control they defend. A **delay window** means the owner clicks the privacy setting and is not private yet *without knowing it*, converting an information leak into a false belief — strictly worse, and the wrong direction for a privacy control. **Decoys** spend the count’s truthfulness (it is truthful today) on a guarantee that is still only probabilistic, and a decoy scheme needs some way to tell padding from real rows — the exact stable per-project key D2a forbids. The promise stealth actually keeps is about the project’s CONTENTS, not the TRANSITION: an anonymous row stays anonymous (no name, tagline, art, origin, or key surviving to the next snapshot); what is not private is stepping off a published list readers may keep copies of. So the copy — which read *“its name, and everything else about it, stay yours”*, every clause true and the paragraph as a whole overselling, because a reader takes it to cover the switch — now names the boundary in BOTH places an owner meets the setting (the choice’s blurb and the what-changes fold), in the plain register 0182’s interview fixed for this card. **A privacy control that oversells is worse than one that admits a limit**, the same standard 0255 §6 set for itself. **No behaviour changes** in `projects-feed.js` — the feed, the redaction allow-list and the keyed rotating permutation are decided FINISHED, not insufficient. `tests/projects_hub.mjs` pins the sentence in both surfaces (the copy IS the fix, so a future edit tightening the blurb must not quietly drop it) and re-applies the no-space-metaphor ban to it. Carries 0255 §6.3’s smaller residual so both live in one place: within one 24h epoch the permutation is stable, so same-day snapshots tell black holes apart (not name them) — accepted on the same reasoning, since the fix is a per-request permutation, which breaks pagination exactly as `ORDER BY random()` was rejected for. Guards recorded for any future attempt: no stable per-project key on an anonymous row, and no anonymous ordering an observer can predict or correlate. Rejected: the delay; decoys; dropping the named list (protects a privacy nobody asked for at the cost of the feature); and saying nothing, which is the failure mode this closes.](<redacted>.md) | platform-identity / privacy boundary / copy honesty |
@@ -4,7 +4,7 @@
4
4
  > Deterministic on a given tree — regenerate and commit rather than hand-editing.
5
5
  > Machine-readable twin: [`copy-registry.json`](copy-registry.json).
6
6
 
7
- **1707 strings** across 3 surfaces · 1476 distinct pieces of copy · 1707 certain, 0 heuristic · 162 shown in more than one place.
7
+ **1706 strings** across 3 surfaces · 1475 distinct pieces of copy · 1706 certain, 0 heuristic · 162 shown in more than one place.
8
8
 
9
9
  ## Surfaces
10
10
 
@@ -12,7 +12,7 @@
12
12
  |---|---|---|---|
13
13
  | `builders-hall` | `modules/hall-ui/public` | 78 | 1234 |
14
14
  | `status-dashboard` | `modules/status-ui/public` | 4 | 100 |
15
- | `landing` | `modules/public-landing/public` | 5 | 373 |
15
+ | `landing` | `modules/public-landing/public` | 5 | 372 |
16
16
 
17
17
  ## Confidence, and why it is on every row
18
18
 
@@ -1422,7 +1422,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
1422
1422
  | `7667eb552781` | certain | {…}: {…}% ({…}/{…} checks) | `modules/status-ui/public/status.js:468` |
1423
1423
  | `6d0d64f10bd9` | certain | ‹ earlier page | `modules/status-ui/public/index.html:321` |
1424
1424
 
1425
- ## `landing` — 373 strings
1425
+ ## `landing` — 372 strings
1426
1426
 
1427
1427
  | id | conf | text | where |
1428
1428
  |---|---|---|---|
@@ -1788,7 +1788,6 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
1788
1788
  | `5becfcad07c3` | certain | — people ask from the project’s hall, you approve each one from the hall’s Watch page, and then they sign in. | `modules/public-landing/public/projects.html:809` |
1789
1789
  | `9ac56aa4e540` | certain | — projects hosted by the platform run on servers rented from DigitalOcean, with their web address and traffic | `modules/public-landing/public/privacy.html:65` |
1790
1790
  | `39719e6e1d56` | certain | — shipped as | `modules/public-landing/public/projects.html:778` |
1791
- | `78a1684630fa` | certain | — the map shows only that something is there; its name, and everything else about it, stay yours, and nobody c | `modules/public-landing/public/projects.html:828` |
1792
1791
  | `b5bac008ba11` | certain | — the project is on the map under its name, and anyone who finds it can join. | `modules/public-landing/public/projects.html:828` |
1793
1792
  | `728661ec4c4a` | certain | — when a shipped task is graded, the change it made (the code diff) is sent to the model the grader runs on, A | `modules/public-landing/public/privacy.html:64` |
1794
1793
  | `ff1318762145` | certain | — whoever signs in with GitHub becomes a member at once, at the newcomer rank. | `modules/public-landing/public/projects.html:809` |
@@ -3,9 +3,9 @@
3
3
  "generator": "scripts/gds/copy-inventory.js",
4
4
  "goal": 1000074,
5
5
  "counts": {
6
- "strings": 1707,
7
- "unique_ids": 1476,
8
- "certain": 1707,
6
+ "strings": 1706,
7
+ "unique_ids": 1475,
8
+ "certain": 1706,
9
9
  "heuristic": 0,
10
10
  "duplicate_ids": 162
11
11
  },
@@ -23,7 +23,7 @@
23
23
  "landing": {
24
24
  "at": "modules/public-landing/public",
25
25
  "files": 5,
26
- "strings": 373
26
+ "strings": 372
27
27
  }
28
28
  },
29
29
  "blind_spots": [
@@ -16093,15 +16093,6 @@
16093
16093
  "origin": "html-text",
16094
16094
  "confidence": "certain"
16095
16095
  },
16096
- {
16097
- "id": "78a1684630fa",
16098
- "surface": "landing",
16099
- "text": "— the map shows only that something is there; its name, and everything else about it, stay yours, and nobody can ask to join. Whichever you pick, a project reaches the map only once it has a name and a description. This is separate from “Who can see it” above — that one is the door on the project’s own pages. Changing it restarts the project to pick it up.",
16100
- "file": "modules/public-landing/public/projects.html",
16101
- "line": 828,
16102
- "origin": "html-text",
16103
- "confidence": "certain"
16104
- },
16105
16096
  {
16106
16097
  "id": "b5bac008ba11",
16107
16098
  "surface": "landing",
@@ -1603,5 +1603,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1603
1603
  landed since 1.19.575 with no explicit bump. run 34132732488. (task 1002620)
1604
1604
  1.19.577 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1605
1605
  landed since 1.19.576 with no explicit bump. run 34142767912. (task 1002620)
1606
+ 1.19.578 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1607
+ landed since 1.19.577 with no explicit bump. run 34146835487. (task 1002620)
1608
+ 1.19.579 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1609
+ landed since 1.19.578 with no explicit bump. run 34149317186. (task 1002620)
1606
1610
  ---------------------------------------------------------------------------
1607
1611
  ```
@@ -825,7 +825,7 @@ summary{min-height:24px;padding:3px 0;}
825
825
  <div id="mDoor"><p class="finePrint">Reading the project’s settings…</p></div>
826
826
  <details class="finePrint" id="mDoorWhat">
827
827
  <summary>What each choice changes</summary>
828
- <p><b>Public</b> — the project is on the map under its name, and anyone who finds it can join. <b>Private</b> — it is on the map under its name, but nobody joins on their own: people ask, and you decide. <b>Stealth</b> — the map shows only that something is there; its name, and everything else about it, stay yours, and nobody can ask to join. Whichever you pick, a project reaches the map only once it has a name and a description. This is separate from “Who can see it” above — that one is the door on the project’s own pages. Changing it restarts the project to pick it up.</p>
828
+ <p><b>Public</b> — the project is on the map under its name, and anyone who finds it can join. <b>Private</b> — it is on the map under its name, but nobody joins on their own: people ask, and you decide. <b>Stealth</b> — the map shows only that something is there; its name, and everything else about it, stay yours, and nobody can ask to join. Switching to Stealth is itself visible: someone comparing the map before and after can tell which project went quiet. Whichever you pick, a project reaches the map only once it has a name and a description. This is separate from “Who can see it” above — that one is the door on the project’s own pages. Changing it restarts the project to pick it up.</p>
829
829
  </details>
830
830
  </div>
831
831
 
@@ -5590,6 +5590,7 @@ summary{min-height:24px;padding:3px 0;}
5590
5590
  three cards below share ONE read of the row (task 1003582), so
5591
5591
  dropping the memo here is what keeps each entry a fresh read */
5592
5592
  if (!keepRepo) forgetSettings();
5593
+ if (!keepRepo) forgetManifest();
5593
5594
  if (!keepRepo) loadVisibility(inst);
5594
5595
  if (!keepRepo) loadJoinability(inst);
5595
5596
  if (!keepRepo) loadDoor();
@@ -5892,6 +5893,8 @@ summary{min-height:24px;padding:3px 0;}
5892
5893
  if (!inst || !$('mVisRuns')) return;
5893
5894
  if (visPushOpen(inst)) {
5894
5895
  mVisPushSeen = true;
5896
+ forgetManifest(); /* stale from the moment a push is queued — and by the
5897
+ time it settles both cards share one fresh read */
5895
5898
  visRuns(inst.open_intent.state === 'running'
5896
5899
  ? 'Restarting the project to apply a settings change…'
5897
5900
  : 'A settings change is queued — the project restarts to pick it up. Until then it keeps its current setting.');
@@ -5900,43 +5903,81 @@ summary{min-height:24px;padding:3px 0;}
5900
5903
  if (mVisPushSeen) { mVisPushSeen = false; loadVisRuns(inst); }
5901
5904
  }
5902
5905
 
5906
+ /* ── the project's own manifest, read ONCE per manage entry (task 1003554) ─
5907
+ Two cards render off the SAME cross-origin GET of the project's manifest —
5908
+ how the project is reached (brand.project.platformVisibility) and how
5909
+ people join (brand.project.joinability) — and each used to ask for itself:
5910
+ two identical round trips to somebody else's server on every entry.
5911
+
5912
+ Memoised like ensureSettings() and keyed on the project the same way, so a
5913
+ switch is never served the last one's answer. The difference that matters
5914
+ is WHEN it is dropped. The settings row is read once per entry and a memo
5915
+ that lives for that entry is right; this answer instead goes stale the
5916
+ moment a settings push is queued, and the re-read after that push settles
5917
+ is the ONLY thing that can say the new value took. So forgetManifest() is
5918
+ called from the push-open branch of renderVisState/renderJoinState, which
5919
+ runs on every repaint while the push is open and reads nothing itself: by
5920
+ the time it settles the memo is already empty, and the two cards' re-reads
5921
+ then share one read rather than racing two. A lifetime memo here would
5922
+ freeze both lines at their pre-restart value — an efficiency cleanup
5923
+ turned into a wrong answer on screen.
5924
+
5925
+ It RESOLVES with every outcome rather than throwing, so one unreachable
5926
+ project fans out to both lines and neither goes silent: status 0 means it
5927
+ could not be reached at all, any other status is the project's own
5928
+ refusal. The 8s budget and its abort belong to the shared read now — one
5929
+ timer for the pair, not one each. */
5930
+ var manifestPromise = null;
5931
+ var manifestFor = null; /* which project the memo holds — a switch must not reuse it */
5932
+
5933
+ function ensureManifest(domain) {
5934
+ if (manifestPromise && manifestFor === manageId) return manifestPromise;
5935
+ manifestFor = manageId;
5936
+ var ctl = typeof AbortController === 'function' ? new AbortController() : null;
5937
+ var timer = ctl ? setTimeout(function () { ctl.abort(); }, 8000) : null;
5938
+ manifestPromise = fetch('https://' + domain + '/api/gds/instance', { mode: 'cors', signal: ctl ? ctl.signal : undefined })
5939
+ .then(function (res) {
5940
+ /* a project that ANSWERED but refused (its own pre-launch gate, a proxy
5941
+ error page) is not an older core — carry the status, don't guess */
5942
+ if (!res.ok) return { ok: false, status: res.status, data: null };
5943
+ return res.json().then(function (m) { return { ok: true, status: res.status, data: m }; });
5944
+ })
5945
+ .catch(function () { return { ok: false, status: 0, data: null }; })
5946
+ .then(function (ans) { if (timer) clearTimeout(timer); return ans; });
5947
+ return manifestPromise;
5948
+ }
5949
+
5950
+ /* Drop the memo so the next ensureManifest() is a real read — at each manage
5951
+ entry, and for the whole time a settings push is open. */
5952
+ function forgetManifest() { manifestPromise = null; manifestFor = null; }
5953
+
5903
5954
  /* what the project RUNS: its own public manifest, read cross-origin (the
5904
5955
  same GET /instance the platform's runner reads after a restart) */
5905
5956
  function loadVisRuns(inst) {
5906
5957
  if (!inst || inst.status !== 'active' || !inst.domain) { visRuns(''); return; }
5907
5958
  var asked = manageId;
5908
5959
  visRuns('Checking what the project is running…');
5909
- var ctl = typeof AbortController === 'function' ? new AbortController() : null;
5910
- var timer = ctl ? setTimeout(function () { ctl.abort(); }, 8000) : null;
5911
- fetch('https://' + inst.domain + '/api/gds/instance', { mode: 'cors', signal: ctl ? ctl.signal : undefined })
5912
- .then(function (res) {
5913
- /* a project that ANSWERED but refused (its own pre-launch gate, a proxy
5914
- error page) is not an older core — say what happened, not a guess */
5915
- if (!res.ok) throw new Error('http:' + res.status);
5916
- return res.json();
5917
- })
5918
- .then(function (m) {
5919
- if (asked !== manageId) return;
5920
- var p = m && m.brand && m.brand.project;
5921
- var v = p && p.platformVisibility;
5922
- if (!p || typeof p !== 'object') {
5923
- visRuns('This project’s server doesn’t report this setting yet — it runs an older platform version, so the setting can’t take effect until the project is updated.');
5924
- return;
5925
- }
5926
- var o = VIS_OPTIONS[v];
5927
- if (!o) { visRuns('This project’s server reports a setting this page doesn’t recognise (' + esc(String(v)) + ').'); return; }
5928
- var want = mVisSettings && mVisSettings.value;
5929
- visRuns('Right now the project runs as <b>' + esc(o.label) + '</b>' +
5930
- (want && want !== v && VIS_OPTIONS[want] ? ' — the saved choice (' + esc(VIS_OPTIONS[want].label) + ') hasn’t taken effect there yet.' : '.'));
5931
- })
5932
- .catch(function (err) {
5933
- if (asked !== manageId) return;
5934
- var http = /^http:(\d+)$/.exec(String(err && err.message));
5935
- visRuns(http
5936
- ? 'The project answered but didn’t share what it’s running (it replied ' + esc(http[1]) + ').'
5960
+ ensureManifest(inst.domain).then(function (ans) {
5961
+ if (asked !== manageId) return;
5962
+ if (!ans.ok) {
5963
+ visRuns(ans.status
5964
+ ? 'The project answered but didn’t share what it’s running (it replied ' + esc(String(ans.status)) + ').'
5937
5965
  : 'Couldn’t reach the project to check what it’s running right now.');
5938
- })
5939
- .then(function () { if (timer) clearTimeout(timer); });
5966
+ return;
5967
+ }
5968
+ var m = ans.data;
5969
+ var p = m && m.brand && m.brand.project;
5970
+ var v = p && p.platformVisibility;
5971
+ if (!p || typeof p !== 'object') {
5972
+ visRuns('This project’s server doesn’t report this setting yet — it runs an older platform version, so the setting can’t take effect until the project is updated.');
5973
+ return;
5974
+ }
5975
+ var o = VIS_OPTIONS[v];
5976
+ if (!o) { visRuns('This project’s server reports a setting this page doesn’t recognise (' + esc(String(v)) + ').'); return; }
5977
+ var want = mVisSettings && mVisSettings.value;
5978
+ visRuns('Right now the project runs as <b>' + esc(o.label) + '</b>' +
5979
+ (want && want !== v && VIS_OPTIONS[want] ? ' — the saved choice (' + esc(VIS_OPTIONS[want].label) + ') hasn’t taken effect there yet.' : '.'));
5980
+ });
5940
5981
  }
5941
5982
 
5942
5983
  /* ── the settings row, read ONCE per manage entry (task 1003582) ─────────
@@ -6305,6 +6346,7 @@ summary{min-height:24px;padding:3px 0;}
6305
6346
  if (!inst || !$('mJoinRuns')) return;
6306
6347
  if (visPushOpen(inst)) {
6307
6348
  mJoinPushSeen = true;
6349
+ forgetManifest(); /* the same one settle, the same one drop */
6308
6350
  joinRuns(inst.open_intent.state === 'running'
6309
6351
  ? 'Restarting the project to apply a settings change…'
6310
6352
  : 'A settings change is queued — the project restarts to pick it up. Until then it keeps its current setting.');
@@ -6313,40 +6355,33 @@ summary{min-height:24px;padding:3px 0;}
6313
6355
  if (mJoinPushSeen) { mJoinPushSeen = false; loadJoinRuns(inst); }
6314
6356
  }
6315
6357
 
6316
- /* what the project RUNS: its own public manifest, read cross-origin */
6358
+ /* what the project RUNS: the SAME shared manifest read the reach card uses
6359
+ (task 1003554) — a different key out of one answer, not a second GET */
6317
6360
  function loadJoinRuns(inst) {
6318
6361
  if (!inst || inst.status !== 'active' || !inst.domain) { joinRuns(''); return; }
6319
6362
  var asked = manageId;
6320
6363
  joinRuns('Checking what the project is running…');
6321
- var ctl = typeof AbortController === 'function' ? new AbortController() : null;
6322
- var timer = ctl ? setTimeout(function () { ctl.abort(); }, 8000) : null;
6323
- fetch('https://' + inst.domain + '/api/gds/instance', { mode: 'cors', signal: ctl ? ctl.signal : undefined })
6324
- .then(function (res) {
6325
- if (!res.ok) throw new Error('http:' + res.status);
6326
- return res.json();
6327
- })
6328
- .then(function (m) {
6329
- if (asked !== manageId) return;
6330
- var p = m && m.brand && m.brand.project;
6331
- var v = p && p.joinability;
6332
- if (!p || typeof p !== 'object') {
6333
- joinRuns('This project’s server doesn’t report this setting yet — it runs an older platform version, so the setting can’t take effect until the project is updated.');
6334
- return;
6335
- }
6336
- var o = JOIN_OPTIONS[v];
6337
- if (!o) { joinRuns('This project’s server reports a setting this page doesn’t recognise (' + esc(String(v)) + ').'); return; }
6338
- var want = mJoinSettings && mJoinSettings.value;
6339
- joinRuns('Right now the project runs as <b>' + esc(o.label) + '</b>' +
6340
- (want && want !== v && JOIN_OPTIONS[want] ? ' — the saved choice (' + esc(JOIN_OPTIONS[want].label) + ') hasn’t taken effect there yet.' : '.'));
6341
- })
6342
- .catch(function (err) {
6343
- if (asked !== manageId) return;
6344
- var http = /^http:(\d+)$/.exec(String(err && err.message));
6345
- joinRuns(http
6346
- ? 'The project answered but didn’t share what it’s running (it replied ' + esc(http[1]) + ').'
6364
+ ensureManifest(inst.domain).then(function (ans) {
6365
+ if (asked !== manageId) return;
6366
+ if (!ans.ok) {
6367
+ joinRuns(ans.status
6368
+ ? 'The project answered but didn’t share what it’s running (it replied ' + esc(String(ans.status)) + ').'
6347
6369
  : 'Couldn’t reach the project to check what it’s running right now.');
6348
- })
6349
- .then(function () { if (timer) clearTimeout(timer); });
6370
+ return;
6371
+ }
6372
+ var m = ans.data;
6373
+ var p = m && m.brand && m.brand.project;
6374
+ var v = p && p.joinability;
6375
+ if (!p || typeof p !== 'object') {
6376
+ joinRuns('This project’s server doesn’t report this setting yet — it runs an older platform version, so the setting can’t take effect until the project is updated.');
6377
+ return;
6378
+ }
6379
+ var o = JOIN_OPTIONS[v];
6380
+ if (!o) { joinRuns('This project’s server reports a setting this page doesn’t recognise (' + esc(String(v)) + ').'); return; }
6381
+ var want = mJoinSettings && mJoinSettings.value;
6382
+ joinRuns('Right now the project runs as <b>' + esc(o.label) + '</b>' +
6383
+ (want && want !== v && JOIN_OPTIONS[want] ? ' — the saved choice (' + esc(JOIN_OPTIONS[want].label) + ') hasn’t taken effect there yet.' : '.'));
6384
+ });
6350
6385
  }
6351
6386
 
6352
6387
  function loadJoinability(inst) {
@@ -6554,7 +6589,7 @@ summary{min-height:24px;padding:3px 0;}
6554
6589
  var DOOR_OPTIONS = {
6555
6590
  public: { label: 'Public', blurb: 'The project is on the map under its name, and anyone who finds it can join.' },
6556
6591
  private: { label: 'Private', blurb: 'On the map under its name, but nobody joins on their own — people ask, and you decide.' },
6557
- stealth: { label: 'Stealth', blurb: 'The map shows only that something is there. Its name, and everything else about it, stay yours — and nobody can ask to join.' }
6592
+ stealth: { label: 'Stealth', blurb: 'The map shows only that something is there. Its name, and everything else about it, stay yours — and nobody can ask to join. Switching to Stealth is itself visible: someone comparing the map before and after can tell which project went quiet.' }
6558
6593
  };
6559
6594
  var mDoorSettings = null; /* the platform row's last answer: { value, options } */
6560
6595
 
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.577",
3
+ "version": "1.19.579",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.577",
9
+ "version": "1.19.579",
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.577",
3
+ "version": "1.19.579",
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",
package/src/module-api.js CHANGED
@@ -55,7 +55,7 @@ const { buildInfo } = require('./build-info');
55
55
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
56
56
  // the entry to that file. Look for a version's history there, not here.
57
57
  // ---------------------------------------------------------------------------
58
- const CORE_VERSION = '1.19.577'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
58
+ const CORE_VERSION = '1.19.579'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
59
59
 
60
60
  // A namespaced logger so a module's log lines are attributable + consistent.
61
61
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,205 @@
1
+ // tests/manage_manifest_shared_read.mjs — one read of the project's OWN
2
+ // manifest per manage entry, EXECUTED (task 1003554).
3
+ //
4
+ // The defect this pins shut: entering /projects?view=manage fetched
5
+ // https://<project>/api/gds/instance twice, identically and cross-origin,
6
+ // because the two cards that render a "what it runs right now" line — how the
7
+ // project is reached (brand.project.platformVisibility) and how people join
8
+ // (brand.project.joinability) — each asked the project for themselves. Two
9
+ // round trips to somebody else's server, each with its own 8s budget, for one
10
+ // answer.
11
+ //
12
+ // It is the sibling of tests/manage_settings_shared_read.mjs, and the reason it
13
+ // is a separate reader rather than the same memo: the settings row is read once
14
+ // per entry and never again, so a memo that lives for the entry is right. This
15
+ // answer goes stale the moment a settings push is queued, and the re-read once
16
+ // that push settles is the ONLY thing that can say the new value took. So the
17
+ // memo is dropped for as long as the push is open. A lifetime memo here would
18
+ // freeze both lines at their pre-restart value — an efficiency cleanup turned
19
+ // into a wrong answer on screen.
20
+ //
21
+ // tests/projects_hub.mjs pins that both cards go through the shared reader;
22
+ // this file lifts the reader out of modules/public-landing/public/projects.html,
23
+ // runs it in a vm under a scripted fetch, and counts the round trips. The
24
+ // vm-harness precedent is tests/project_door_ui.mjs.
25
+ //
26
+ // What it proves, and why each matters:
27
+ // • both cards of one entry cost ONE round trip — the defect itself
28
+ // • forgetManifest() makes the next read real, so entry and tab return each
29
+ // ask the project as it is now, never as it was last visit
30
+ // • the push-open drop is what makes the settle re-read real, and the two
31
+ // cards re-reading after one settle still cost one round trip
32
+ // • switching projects is never served the previous project's manifest
33
+ // • a refusal RESOLVES carrying its status and an unreachable project
34
+ // resolves with status 0 — so ONE failure reaches BOTH lines and neither
35
+ // card goes silent waiting on a rejection nobody handed it
36
+ // • the 8s abort budget is the shared read's, and it is cleared once
37
+ //
38
+ // Run: node --test --test-reporter=tap tests/manage_manifest_shared_read.mjs
39
+
40
+ import assert from 'node:assert/strict';
41
+ import { test } from 'node:test';
42
+ import fs from 'node:fs';
43
+ import path from 'node:path';
44
+ import vm from 'node:vm';
45
+ import { fileURLToPath } from 'node:url';
46
+
47
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
48
+ const HUB = fs.readFileSync(path.join(ROOT, 'modules', 'public-landing', 'public', 'projects.html'), 'utf8');
49
+ const SCRIPT = (HUB.match(/<script>([\s\S]*?)<\/script>\s*<\/body>/) || [, ''])[1];
50
+
51
+ // The reader, sliced from the page verbatim. Both boundaries are asserted so a
52
+ // rename cannot leave this file executing an empty string and passing.
53
+ const START = SCRIPT.indexOf(' var manifestPromise = null;');
54
+ const END = SCRIPT.indexOf(' function loadVisRuns(');
55
+ assert.ok(START > 0, 'the shared reader must start at its memo declaration');
56
+ assert.ok(END > START, 'the shared reader must sit above its first consumer');
57
+ const REGION = SCRIPT.slice(START, END);
58
+ for (const fn of ['function ensureManifest(', 'function forgetManifest(']) {
59
+ assert.ok(REGION.includes(fn), `the region must carry ${fn}`);
60
+ }
61
+
62
+ // ── the harness ──────────────────────────────────────────────────────────────
63
+ // Narrow on purpose: the region touches fetch, manageId and the abort timer,
64
+ // and nothing else — so the stub stays honest about what it can prove.
65
+
66
+ function boot({ ok = true, status = 200, body = { brand: { project: { platformVisibility: 'public', joinability: 'open' } } }, reject = false } = {}) {
67
+ const requests = [];
68
+ const timers = { set: 0, cleared: 0 };
69
+ const sandbox = {
70
+ Promise, JSON, String, Object, Error, AbortController,
71
+ manageId: '18',
72
+ setTimeout: (fn, ms) => { timers.set++; timers.ms = ms; return { fn }; },
73
+ clearTimeout: () => { timers.cleared++; },
74
+ fetch: (url, init = {}) => {
75
+ requests.push({ url, mode: init.mode });
76
+ return reject
77
+ ? Promise.reject(new Error('network'))
78
+ : Promise.resolve({ ok, status, json: () => Promise.resolve(body) });
79
+ },
80
+ };
81
+ sandbox.globalThis = sandbox;
82
+ vm.createContext(sandbox);
83
+ vm.runInContext(REGION, sandbox, { filename: 'projects.html#shared-manifest-read' });
84
+
85
+ return { requests, timers, sandbox };
86
+ }
87
+
88
+ // ── the tests ────────────────────────────────────────────────────────────────
89
+
90
+ test('the two cards of one manage entry cost ONE round trip, not two', async () => {
91
+ // Arrange — the page's own entry pass: the reach card, then the join card
92
+ const ui = boot();
93
+
94
+ // Act
95
+ const answers = await Promise.all([
96
+ ui.sandbox.ensureManifest('demo.example.com'),
97
+ ui.sandbox.ensureManifest('demo.example.com'),
98
+ ]);
99
+
100
+ // Assert
101
+ assert.equal(ui.requests.length, 1, 'one cross-origin GET served both lines');
102
+ assert.equal(ui.requests[0].url, 'https://demo.example.com/api/gds/instance');
103
+ assert.equal(ui.requests[0].mode, 'cors', 'the project is somebody else’s origin');
104
+ for (const a of answers) {
105
+ assert.equal(a.ok, true);
106
+ assert.equal(a.data.brand.project.joinability, 'open', 'both callers got the same manifest');
107
+ }
108
+ });
109
+
110
+ test('forgetManifest makes the next read real — entry and tab return never reuse the last visit’s answer', async () => {
111
+ // Arrange
112
+ const ui = boot();
113
+ await ui.sandbox.ensureManifest('demo.example.com');
114
+
115
+ // Act — what loadManage does at the top of every entry + tab return pass
116
+ ui.sandbox.forgetManifest();
117
+ await ui.sandbox.ensureManifest('demo.example.com');
118
+
119
+ // Assert
120
+ assert.equal(ui.requests.length, 2, 'the second entry asked the project again');
121
+ });
122
+
123
+ test('a settled push re-reads for real, and the two cards still share ONE round trip', async () => {
124
+ // Arrange — the reason this memo is not ensureSettings()'s: an answer read
125
+ // before a push cannot say whether the restart took
126
+ const ui = boot();
127
+ await ui.sandbox.ensureManifest('demo.example.com');
128
+
129
+ // Act — the push opens (renderVisState/renderJoinState each drop the memo on
130
+ // every repaint while it is open), then it settles and both cards re-read
131
+ ui.sandbox.forgetManifest();
132
+ ui.sandbox.forgetManifest();
133
+ const after = await Promise.all([
134
+ ui.sandbox.ensureManifest('demo.example.com'),
135
+ ui.sandbox.ensureManifest('demo.example.com'),
136
+ ]);
137
+
138
+ // Assert
139
+ assert.equal(ui.requests.length, 2, 'one re-read after the settle — not one per card, and not zero');
140
+ assert.equal(after[0].ok, true);
141
+ });
142
+
143
+ test('switching projects is never served the previous project’s manifest', async () => {
144
+ // Arrange — the memo is warm for project 18
145
+ const ui = boot();
146
+ await ui.sandbox.ensureManifest('one.example.com');
147
+
148
+ // Act — the owner opens a different project; loadManage has not forgotten yet
149
+ ui.sandbox.manageId = '19';
150
+ await ui.sandbox.ensureManifest('two.example.com');
151
+
152
+ // Assert
153
+ assert.equal(ui.requests.length, 2, 'the memo is keyed on the project, not merely present');
154
+ assert.equal(ui.requests[1].url, 'https://two.example.com/api/gds/instance');
155
+ });
156
+
157
+ test('a project that answered-and-refused RESOLVES carrying its status — both lines can say so', async () => {
158
+ // Arrange — a pre-launch gate or a proxy error page, which is NOT an older core
159
+ const ui = boot({ ok: false, status: 403 });
160
+
161
+ // Act
162
+ const [a, b] = await Promise.all([
163
+ ui.sandbox.ensureManifest('demo.example.com'),
164
+ ui.sandbox.ensureManifest('demo.example.com'),
165
+ ]);
166
+
167
+ // Assert
168
+ assert.equal(a.ok, false);
169
+ assert.equal(a.status, 403, 'the status reaches the card, which prints it verbatim');
170
+ assert.equal(a.data, null, 'no body is claimed for a refusal');
171
+ assert.deepEqual(b, a, 'the second card is handed the same refusal, not left waiting');
172
+ });
173
+
174
+ test('an unreachable project RESOLVES with status 0, so one failure fans out to both lines', async () => {
175
+ // Arrange — the failure that used to reject into two separate catches
176
+ const ui = boot({ reject: true });
177
+
178
+ // Act
179
+ const [a, b] = await Promise.all([
180
+ ui.sandbox.ensureManifest('demo.example.com'),
181
+ ui.sandbox.ensureManifest('demo.example.com'),
182
+ ]);
183
+
184
+ // Assert
185
+ assert.equal(a.ok, false);
186
+ assert.equal(a.status, 0, 'status 0 is "could not reach it at all" — a different sentence from a refusal');
187
+ assert.deepEqual(b, a, 'neither line goes silent on a shared rejection');
188
+ assert.equal(ui.requests.length, 1, 'one failed attempt, not one per card');
189
+ });
190
+
191
+ test('the 8s abort budget belongs to the shared read, and is cleared once', async () => {
192
+ // Arrange
193
+ const ui = boot();
194
+
195
+ // Act
196
+ await Promise.all([
197
+ ui.sandbox.ensureManifest('demo.example.com'),
198
+ ui.sandbox.ensureManifest('demo.example.com'),
199
+ ]);
200
+
201
+ // Assert
202
+ assert.equal(ui.timers.set, 1, 'one timer for the pair, not one each');
203
+ assert.equal(ui.timers.ms, 8000, 'the budget the two cards used to keep separately');
204
+ assert.equal(ui.timers.cleared, 1, 'and it is cleared when the answer lands');
205
+ });
@@ -1418,14 +1418,17 @@ test('the save sends the choice and shows the platform’s receipt verbatim —
1418
1418
 
1419
1419
  test('what the project RUNS comes only from its own manifest, and every answer is said', () => {
1420
1420
  const runs = fnHub('loadVisRuns');
1421
+ const manifest = fnHub('ensureManifest');
1421
1422
  assert.match(runs, /inst\.status !== 'active' \|\| !inst\.domain/, 'only a live project with an address can be asked');
1422
- assert.match(runs, /'https:\/\/' \+ inst\.domain \+ '\/api\/gds\/instance'/,
1423
+ assert.match(manifest, /'https:\/\/' \+ domain \+ '\/api\/gds\/instance'/,
1423
1424
  'the same public GET /instance the platform’s runner reads after a restart');
1425
+ assert.doesNotMatch(runs, /fetch\(/, 'the card fires no GET of its own — one manifest read serves the pair (task 1003554)');
1424
1426
  assert.match(runs, /m\.brand && m\.brand\.project/);
1425
1427
  assert.match(runs, /doesn’t report this setting yet/, 'an older core is named, not shown as applied');
1426
1428
  assert.match(runs, /hasn’t taken effect there yet/, 'a saved-but-not-applied value is said');
1427
1429
  assert.match(runs, /Couldn’t reach the project/, 'an unreachable project is said');
1428
- assert.match(runs, /if \(!res\.ok\) throw new Error\('http:' \+ res\.status\)/, 'a refusal is not an older core');
1430
+ assert.match(manifest, /if \(!res\.ok\) return \{ ok: false, status: res\.status, data: null \};/,
1431
+ 'a refusal is not an older core — its status is carried out to both cards, not thrown away');
1429
1432
  assert.match(runs, /answered but didn’t share what it’s running/, 'a project that answered-and-refused is said as such');
1430
1433
  assert.doesNotMatch(runs, /mVisSettings\.value\)\s*\+\s*'<\/b>'|runs as <b>' \+ esc\(VIS_OPTIONS\[want\]/,
1431
1434
  'the platform’s stored choice is never painted as what the project runs');
@@ -1540,6 +1543,30 @@ test('the join-door labels are PLAIN — the space metaphor is the orb’s, neve
1540
1543
  assert.doesNotMatch(options, /unlisted/i, 'dark matter is DERIVED — there is deliberately no way to choose it');
1541
1544
  });
1542
1545
 
1546
+ /* ADR 0259: the one thing Stealth does NOT hide is the switch itself. A project
1547
+ leaving the named list while the anonymous count rises names it to anyone
1548
+ holding yesterday's feed — inherent in publishing a named list, and the owner
1549
+ chose to accept it and say so rather than pay for a delay or decoys. The
1550
+ copy is the whole fix, so the copy is what gets pinned: without this, a
1551
+ future edit tightening the blurb drops the caveat and the page goes back to
1552
+ implying a guarantee the feed does not make. */
1553
+ test('the Stealth copy names its own limit — switching is visible, even though the project is not (ADR 0259)', () => {
1554
+ const options = (SCRIPT.match(/var DOOR_OPTIONS = \{[\s\S]*?\n \};/) || [''])[0];
1555
+ const fold = (HUB.match(/<details class="finePrint" id="mDoorWhat">[\s\S]*?<\/details>/) || [''])[0];
1556
+ assert.ok(options && fold, 'both the option table and the fold must be extractable');
1557
+ for (const [where, text] of [['the choice’s blurb', options], ['the what-changes fold', fold]]) {
1558
+ assert.match(text, /Switching to Stealth is itself visible/,
1559
+ `${where} states the boundary — a privacy control that oversells is worse than one that admits a limit`);
1560
+ assert.match(text, /comparing the map before and after can tell which project went quiet/,
1561
+ `${where} says HOW it is visible, so the owner can judge it rather than take our word`);
1562
+ }
1563
+ /* the plain-register rule this card is held to still binds the new sentence
1564
+ (the space metaphor is the drawn orb's, never the copy's) */
1565
+ for (const word of ['planet', 'star', 'black hole', 'dark matter', 'feed', 'snapshot']) {
1566
+ assert.doesNotMatch(options.toLowerCase(), new RegExp(`\\b${word}\\b`), `"${word}" must not reach a choice’s blurb`);
1567
+ }
1568
+ });
1569
+
1543
1570
  test('the join-door pick is a real radio group built from the server’s own options list', () => {
1544
1571
  const render = fnHub('renderDoor');
1545
1572
  assert.match(render, /\(s\.options \|\| \[\]\)\.filter\(function \(v\) \{ return !!DOOR_OPTIONS\[v\]; \}\)/,
@@ -1598,15 +1625,39 @@ test('the joinability save sends the one key and shows the platform’s receipt
1598
1625
  test('what the project RUNS for joinability comes only from its own manifest, and every answer is said', () => {
1599
1626
  const runs = fnHub('loadJoinRuns');
1600
1627
  assert.match(runs, /inst\.status !== 'active' \|\| !inst\.domain/, 'only a live project with an address can be asked');
1601
- assert.match(runs, /'https:\/\/' \+ inst\.domain \+ '\/api\/gds\/instance'/, 'the same public GET /instance the runner reads');
1628
+ assert.match(runs, /ensureManifest\(inst\.domain\)/, 'the same public GET /instance the runner reads, through the shared reader');
1629
+ assert.doesNotMatch(runs, /fetch\(/, 'the second card of the pair adds no round trip of its own (task 1003554)');
1602
1630
  assert.match(runs, /p && p\.joinability/, 'the manifest key is brand.project.joinability');
1603
1631
  assert.match(runs, /doesn’t report this setting yet/, 'an older core is named, not shown as applied');
1604
1632
  assert.match(runs, /hasn’t taken effect there yet/, 'a saved-but-not-applied value is said');
1605
1633
  assert.match(runs, /Couldn’t reach the project/, 'an unreachable project is said');
1606
- assert.match(runs, /if \(!res\.ok\) throw new Error\('http:' \+ res\.status\)/, 'a refusal is not an older core');
1607
1634
  assert.match(runs, /answered but didn’t share what it’s running/, 'a project that answered-and-refused is said as such');
1608
1635
  });
1609
1636
 
1637
+ /* One read of the project's own manifest per manage entry (task 1003554), the
1638
+ sibling of the settings-row share above. Two cards read two keys out of the
1639
+ SAME cross-origin GET and each used to fetch it for itself. The memo differs
1640
+ from ensureSettings() in one load-bearing way — it is dropped for as long as
1641
+ a push is open, because the re-read after it settles is the only thing that
1642
+ can say the new value took. tests/manage_manifest_shared_read.mjs runs the
1643
+ reader; this pins that both cards go through it and that the drop is there. */
1644
+ test('the two “what it runs” lines share ONE read of the project’s manifest', () => {
1645
+ const ensure = fnHub('ensureManifest');
1646
+ assert.match(ensure, /if \(manifestPromise && manifestFor === manageId\) return manifestPromise;/,
1647
+ 'memoised AND keyed on the project — a switch must never be served the last one’s manifest');
1648
+ assert.match(ensure, /setTimeout\(function \(\) \{ ctl\.abort\(\); \}, 8000\)/, 'one 8s budget for the pair, not one each');
1649
+ for (const fn of ['loadVisRuns', 'loadJoinRuns']) {
1650
+ assert.match(fnHub(fn), /ensureManifest\(inst\.domain\)/, `${fn} reads the manifest through the shared reader`);
1651
+ assert.doesNotMatch(fnHub(fn), /fetch\(/, `${fn} fires no GET of its own`);
1652
+ }
1653
+ assert.match(SCRIPT, /if \(!keepRepo\) forgetManifest\(\);/,
1654
+ 'the memo is dropped on entry + tab return, so neither is ever served the last visit’s answer');
1655
+ for (const fn of ['renderVisState', 'renderJoinState']) {
1656
+ assert.match(fnHub(fn), /forgetManifest\(\);/,
1657
+ `${fn} drops the memo while a push is open — a lifetime memo would freeze the line at its pre-restart value`);
1658
+ }
1659
+ });
1660
+
1610
1661
  test('the joinability in-flight line shares the visibility section’s push predicate and re-asks the manifest when it settles', () => {
1611
1662
  const st = fnHub('renderJoinState');
1612
1663
  assert.match(st, /if \(visPushOpen\(inst\)\)/, 'one settings push carries every key — one predicate, shared');