@bongos/core 1.19.778 → 1.19.779

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,11 +2,11 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.778",
6
- "core_contract": "1.19.778",
7
- "source_commit": "789aac3a01b8b02183c2dd2b3ebfee12bb25a703",
5
+ "core_version": "1.19.779",
6
+ "core_contract": "1.19.779",
7
+ "source_commit": "1b37b29ce2a7832804f9b44a6982ea25b57643e3",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-16T19:16:36.821Z",
9
+ "built_at": "2026-09-16T20:08:25.581Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 482,
@@ -17,7 +17,7 @@
17
17
  "gate": "passed"
18
18
  },
19
19
  "file_count": 2660,
20
- "tree_sha256": "0b65b14c44cb8fe069f46bc63b1572cb3471d535843beda4b24ebade6173c4b1",
20
+ "tree_sha256": "dbb45ccb2f44e4771d7a73e06e5ac46faea7115961c48cac9da3877e62f2e61c",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -1327,7 +1327,7 @@
1327
1327
  {
1328
1328
  "path": "docs/adr/0171-profile-rollup-join-and-consent.md",
1329
1329
  "mode": "0000644",
1330
- "sha256": "e61c6a4a5c03092934ba452d0566f160488ae1c5ebc510ed08a3caeab1bba0a8"
1330
+ "sha256": "943103bc2fcb66b89a06b13a49593d230b90f33bd3dec35aec82078cdd08f54f"
1331
1331
  },
1332
1332
  {
1333
1333
  "path": "docs/adr/0172-editable-rank-roles-substrate-axis.md",
@@ -1387,7 +1387,7 @@
1387
1387
  {
1388
1388
  "path": "docs/adr/0182-project-visibility-two-axes.md",
1389
1389
  "mode": "0000644",
1390
- "sha256": "b5774a35062d556a9af643b0726408268c8d91f3a6c9273f4bb610510bbb7342"
1390
+ "sha256": "15387fac9c92e30d305444616865b6aab0b0c391ddc91f08656c74b9e71385ad"
1391
1391
  },
1392
1392
  {
1393
1393
  "path": "docs/adr/0183-criteria-close-themselves.md",
@@ -2507,7 +2507,7 @@
2507
2507
  {
2508
2508
  "path": "docs/module-api-changelog.md",
2509
2509
  "mode": "0000644",
2510
- "sha256": "4c8bb6807afb74e21e64f0b4d673b335eae7873f90563eb2077e49a210dfd85e"
2510
+ "sha256": "b5409eae5a7466352e67fee988116c208a7c748e5dd88052f8a2b45a8e58f907"
2511
2511
  },
2512
2512
  {
2513
2513
  "path": "docs/modules-contract.md",
@@ -6082,7 +6082,7 @@
6082
6082
  {
6083
6083
  "path": "modules/platform-identity/platform-identity.js",
6084
6084
  "mode": "0000644",
6085
- "sha256": "75034fda91db58e923e6838e8eb6aba92277643fe3ed930fce1e4b3b9a4bdc96"
6085
+ "sha256": "c59bc528fb1bb451b1f1b751b2903b763b1d3e892a8a5bbc5144ee97db670cf3"
6086
6086
  },
6087
6087
  {
6088
6088
  "path": "modules/platform-identity/pollers/code-sweep.js",
@@ -6107,7 +6107,7 @@
6107
6107
  {
6108
6108
  "path": "modules/platform-identity/projects-feed.js",
6109
6109
  "mode": "0000644",
6110
- "sha256": "9a8a22d21089770ec80f709d27f798eab6101cfaead18942c439f7c8689ef041"
6110
+ "sha256": "14166982e6306ff8dd63f476af582970634eb9c210e9afa76166668e351244c9"
6111
6111
  },
6112
6112
  {
6113
6113
  "path": "modules/platform-identity/publish-gate.js",
@@ -6167,7 +6167,7 @@
6167
6167
  {
6168
6168
  "path": "modules/platform-identity/tests/platform-identity.mjs",
6169
6169
  "mode": "0000644",
6170
- "sha256": "32e6d5edd5e058687a641265e78e319ab4e905a85298e76623826c384d0c6356"
6170
+ "sha256": "da06ab89ee01bf3a704f677e7e50041220916f40cac23fa063c212dc9af2f56d"
6171
6171
  },
6172
6172
  {
6173
6173
  "path": "modules/platform-identity/ui/.gitkeep",
@@ -7612,12 +7612,12 @@
7612
7612
  {
7613
7613
  "path": "package-lock.json",
7614
7614
  "mode": "0000644",
7615
- "sha256": "6352bb5bd1cf9b2b944dd8ae2baef9fa9a719036dacd4acf5c0104d65fa56dc0"
7615
+ "sha256": "73c88d2a70b9f77171344eb76f4c5c9f2ecbbc4d1a1b6b3008d636e974b01f9b"
7616
7616
  },
7617
7617
  {
7618
7618
  "path": "package.json",
7619
7619
  "mode": "0000644",
7620
- "sha256": "019205fa726bfcf5d7175ff8e5bdc7589e117bff7757b35b4d64168f1dc6c713"
7620
+ "sha256": "4c30d22b31621113b656c60c5a7d71d70fc1544326be0d506ca16a242783defd"
7621
7621
  },
7622
7622
  {
7623
7623
  "path": "public-docs/index.html",
@@ -9477,7 +9477,7 @@
9477
9477
  {
9478
9478
  "path": "src/module-api.js",
9479
9479
  "mode": "0000644",
9480
- "sha256": "cc914f78fd5bd1fcb6eb795a98519e8a45551fd5e404b22eb3e118369cce35ea"
9480
+ "sha256": "3a9d2a54bc76ea41d67b42be5f2c07da60fcdeec3bbed8f23111163dfa17f1ff"
9481
9481
  },
9482
9482
  {
9483
9483
  "path": "src/module-loader/catalog.js",
@@ -11292,7 +11292,7 @@
11292
11292
  {
11293
11293
  "path": "tests/hub_recent_ships.mjs",
11294
11294
  "mode": "0000644",
11295
- "sha256": "53c7c8ad2de098d29c9010ed37e43779e8ceda4790018527c7edf45d54e56e80"
11295
+ "sha256": "0d8f07c64f4d17c8c894b2d6395281882efd4e186c5714fe9aab61e2808d4314"
11296
11296
  },
11297
11297
  {
11298
11298
  "path": "tests/idea_capture_grades.mjs",
@@ -12032,12 +12032,12 @@
12032
12032
  {
12033
12033
  "path": "tests/profile_rollup_consent.mjs",
12034
12034
  "mode": "0000644",
12035
- "sha256": "130672f5f844f2552f98bdc3e7b30fa9d49dd460e81c02a60c5684db6617f804"
12035
+ "sha256": "d63e22b8ddbcbe7e21a59ee907e5a4261bebc067c52de111c32a88f97a172406"
12036
12036
  },
12037
12037
  {
12038
12038
  "path": "tests/profile_route.mjs",
12039
12039
  "mode": "0000644",
12040
- "sha256": "ef2a58d60f561da1e025cf0958bdcb66072545158a68f3e41b4bc683afbed9d6"
12040
+ "sha256": "d70924dd2ddfc4e05c4b52d9763d76b0a68cf0ed16db6ffd1b07a1834bef0890"
12041
12041
  },
12042
12042
  {
12043
12043
  "path": "tests/profile_ui_cross_project.mjs",
@@ -13237,7 +13237,7 @@
13237
13237
  {
13238
13238
  "path": "tests/visible_projects_feed.mjs",
13239
13239
  "mode": "0000644",
13240
- "sha256": "a15bfe5f3b664a9daa4f45b80eb7a5bee2b2c630198439352c170fc44c4b2025"
13240
+ "sha256": "8a8df7e2a025029ffe7e18b7c7b18c6bd92a256822609931f0cd88628e446041"
13241
13241
  },
13242
13242
  {
13243
13243
  "path": "tests/wandering_prefs.mjs",
@@ -25,6 +25,8 @@ Five facts found during this spike shape the decisions:
25
25
 
26
26
  **2. One safe read path, enforced next to the data.** The port exposes `getPublicProfileExtras(githubId)`: `null` unless the account exists, is publicly addressable (Decision 3), and `hide_stats` is false — otherwise `{ totals: { projects, credits, works_shipped, karma }, disciplines, last_active, projects: [{ name, origin, rank_held }...], more_projects }`, whitelist-shaped **inside the module** (`client_id` and raw rows never cross the port; non-numeric `github_id` values like the `system:*` principals return `null` instead of hitting a bigint cast error). Every surface that shows cross-project numbers reads through the module's filtered functions at render time, never from a stored copy — and that includes the two that already exist: **`listScoutingRollup` itself gains the `profile_state = 'active' AND NOT hide_stats` predicate and a whitelisted projection**, so `/people` and `/scouting` (live today at Metic floor) honor hide by construction, not by memory. The hub hall profile, the `/u/<handle>` page, recruiting (R14/R15) and applications (R09) all sit on the same functions, which is what makes C2's "honored live everywhere, including pending applications" structural. Two deliberate side doors, both narrow: `getOwnProfileExtras(githubId)` serves the builder their own numbers regardless of flags (Settings), and spike 1002785's recruiter flow is expected to add a second **authorization-scoped** read for its opt-in discoverability sliver — `hide_stats` is the invariant every reader honors; public addressability is not.
27
27
 
28
+ > **D2 amendment — which projects a profile may NAME (owner, 2026-09-16, task 1003558).** Until then a project was named only when `featured AND status = 'active'`, a predicate the public map dropped in R06 (ADR 0182 D3). It is now **on the map AND Public**: `onMapSql('p') AND p.visibility = 'public'`, where `onMapSql` is the feed's own fragment, so a profile and the map cannot disagree about who is on the map. `featured` gates no name. **Private is deliberately NOT named**, though its name is on the map: naming it on a profile publishes who works there, at what rank and on which works, which the map never says. Stealth stays unnamed (ADR 0182 D2a). Everything unnamed is counted in `more_projects`, and `recent_ships` follow the name.
29
+
28
30
  **3. Consent is a fail-closed `profile_state` on `platform_identity_accounts`, with one discriminator.** Migration `platform_identity_007_profile_consent.sql` (lands with R02) adds `profile_state text NOT NULL DEFAULT 'provisional' CHECK (profile_state IN ('provisional','active'))` and `hide_stats boolean NOT NULL DEFAULT false`. The single rule: **an account is `active` iff it was touched by a hub-session `/sso/authorize`** — the one path where a human is demonstrably at the hub (and where T&C acceptance can later be captured, task 1002913). Device-poll and check-in create `provisional`; the rollup endpoint creates nothing (orphan activity rows are never publicly addressable). Backfill: **all existing accounts start `provisional`** and lazily upgrade on their next hub-session authorize — no join to core `builders` (that join is type-unsafe against `system:*` text ids and would put a core-table read inside a module migration; the price is that existing hub builders' profiles publish on their next sign-in rather than immediately). Public surfaces read only `active` accounts. **R02 ships this fail-closed default without waiting on P03**, because the asymmetry only runs one way: P03 can then only ever *widen* exposure, never claw back something already published. This deliberately narrows the Phase-4 interview's "no extra consent gate" to the accounts whose consent premise actually holds; P03 is the owner either ratifying that boundary (recommended) or reverting to the interview decision verbatim — and the revert branch has a named precondition: check-in/device-created identities have **no self-serve hide path** (no hub builder session exists for them; hub admission is invite-gated), so rejecting P03 first requires an assertion-authenticated hide path or open hub enrollment. Task 1002799 carries this full decision payload.
29
31
 
30
32
  > **P03 outcome — RATIFIED (owner, 2026-08-16, task 1002799).** The fail-closed boundary stands: check-in/device-created accounts stay `provisional` (not publicly listed, not at `/u/<handle>`, not in the sitemap or hub search) until their human signs in at the hub at least once. The owner's rationale: in the intended product flow a builder passes through cloudbongos.com at least once anyway — to find or create the projects they join — so the activation gate is satisfied naturally and costs nothing; the cloudbongos builders hall predating the hub flow is a one-time bootstrap exception, and those accounts lazily activate on their next hub sign-in exactly as the backfill rule above already provides (no backfill activation). Tasks 1002913 (T&C artifact) and 1002914 (criterion text amendments) unblock on this ship.
@@ -169,7 +169,7 @@ The last sentence of this decision is therefore now spent — `stealth` unqualif
169
169
  - Anything reading `featured = true AND status = 'active'` as "is this public" is now wrong and must be found. `modules/platform-identity/routes/projects.js` is the known site.
170
170
  - **R16 makes D3's bound real, and D4's gate reach a second module.** Shipping R16 (task 1002335) surfaced one thing this ADR left implicit. D3 says `featured` may never put an unpublishable project on the map, so `PATCH /projects/:id/featured` now refuses a dark-matter row (409 `not_publishable`, `missing` in the details); un-featuring stays unconditional, since an already-featured husk must remain removable. And D4's "name what is missing" is owed to the owner *before* their project is ever catalogued on the hub — which is the `provisioning` plane, a different module that ADR 0083 forbids from importing `platform-identity`. Rather than let a two-field rule exist twice, the gate is published as a kernel seam port, `project.publishGate` (`modules/platform-identity/publish-gate.js`). `provisioning` resolves it **optionally** and declares no `consumes`: `platform-identity` is hub-only, the public map is a hub concept, and a platform without one must answer "no verdict" rather than invent a local rule — which is the same drift-avoidance argument that produced `publishableSql` in the first place.
171
171
 
172
- - **R06 landed the predicate swap and the redaction, and settled three things this ADR left to the implementer** (task 1002325, `modules/platform-identity/projects-feed.js`). (a) **A black hole carries no stable per-project key.** D2a lists `name`/`tagline`/`art_url`/`origin`, and an id looks harmless beside them — but any identifier that survives a state change lets an observer who kept yesterday's public feed diff it against today's and *name* the project that went stealth. So stealth rows are byte-identical to one another and the feed grew no public id at all; the orbs page keys by position. (b) **The redaction is an ALLOW-list, not a deny-list** — every key nulled except `state` and `orb` — so a column added to the feed later is anonymous without anyone remembering to come back to it, and it NULLS rather than deletes, keeping the feed's "never drop a key" contract. (c) **An unrecognised `visibility` draws as a black hole, not a planet**: this mapping is a privacy boundary, so a fourth enum value added without mapping it must fail closed. Two smaller notes: the route PATH keeps the word `featured` (it is rank:public and CORS-open, already fetched by a static site and other origins — renaming it would break them for nothing, and `ORDER BY featured DESC` is where the flag's D3 ranking job now lives); and `getRollupForBuilder`'s `is_public` was deliberately left on the OLD `featured AND active` predicate rather than widened with the feed, because a stealth project is on the map *anonymously* and naming it in a builder's profile would be the exact leak D2a forbids — how much of a cross-project record may be NAMED is ADR 0171 D2's question, and under-naming is the safe direction meanwhile.
172
+ - **R06 landed the predicate swap and the redaction, and settled three things this ADR left to the implementer** (task 1002325, `modules/platform-identity/projects-feed.js`). (a) **A black hole carries no stable per-project key.** D2a lists `name`/`tagline`/`art_url`/`origin`, and an id looks harmless beside them — but any identifier that survives a state change lets an observer who kept yesterday's public feed diff it against today's and *name* the project that went stealth. So stealth rows are byte-identical to one another and the feed grew no public id at all; the orbs page keys by position. (b) **The redaction is an ALLOW-list, not a deny-list** — every key nulled except `state` and `orb` — so a column added to the feed later is anonymous without anyone remembering to come back to it, and it NULLS rather than deletes, keeping the feed's "never drop a key" contract. (c) **An unrecognised `visibility` draws as a black hole, not a planet**: this mapping is a privacy boundary, so a fourth enum value added without mapping it must fail closed. Two smaller notes: the route PATH keeps the word `featured` (it is rank:public and CORS-open, already fetched by a static site and other origins — renaming it would break them for nothing, and `ORDER BY featured DESC` is where the flag's D3 ranking job now lives); and `getRollupForBuilder`'s `is_public` was deliberately left on the OLD `featured AND active` predicate rather than widened with the feed, because a stealth project is on the map *anonymously* and naming it in a builder's profile would be the exact leak D2a forbids — how much of a cross-project record may be NAMED is ADR 0171 D2's question, and under-naming is the safe direction meanwhile. (Widened by task 1003558 to on-the-map AND Public; see the ADR 0171 D2 amendment.)
173
173
 
174
174
  ## Alternatives rejected
175
175
 
@@ -2030,5 +2030,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2030
2030
  landed since 1.19.776 with no explicit bump. run 35137334438. (task 1002620)
2031
2031
  1.19.778 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2032
2032
  landed since 1.19.777 with no explicit bump. run 35139523999. (task 1002620)
2033
+ 1.19.779 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2034
+ landed since 1.19.778 with no explicit bump. run 35144678111. (task 1002620)
2033
2035
  ---------------------------------------------------------------------------
2034
2036
  ```
@@ -322,7 +322,7 @@ const {
322
322
  // D1/D2a/D3; R06, task 1002325). Its own file for the publish-gate.js reason: this
323
323
  // file is at the size ratchet. Re-exported below, so the surface is unchanged.
324
324
  const {
325
- ORB_BY_STATE, orbForState, isStealthProject, feedShapeFor, listPublicProjects, PUBLIC_FEED_MAX_PAGE_SIZE,
325
+ ORB_BY_STATE, orbForState, isStealthProject, feedShapeFor, listPublicProjects, PUBLIC_FEED_MAX_PAGE_SIZE, onMapSql,
326
326
  } = require('./projects-feed');
327
327
 
328
328
  // Upsert a catalog row (keyed by origin). Called when a client registers (so every
@@ -753,17 +753,14 @@ function isNumericGithubId(githubId) {
753
753
  // listScoutingRollup's corrected shape: scalars over the UN-FANNED activity
754
754
  // table, the disciplines union computed separately (never a pre-GROUP-BY unnest
755
755
  // fan-out — bug task 1002911). Per-project rows join the module-internal
756
- // client_id → sso_clients.origin → projects.origin two-hop; a project is NAMED
757
- // only when its catalog row is publicly listed today (featured + active).
758
- // Everything else is counted in more_projects, never named ("the aggregate is
759
- // not the leak, the name is").
756
+ // client_id → sso_clients.origin → projects.origin two-hop. Everything not
757
+ // named is counted in more_projects ("the aggregate is not the leak, the name is").
760
758
  //
761
- // Deliberately NARROWER than the public feed since R06 (task 1002325), and left so
762
- // rather than widened here: a STEALTH project is on the map ANONYMOUSLY (ADR 0182
763
- // D2a), so naming it in a builder's profile would be the leak the feed exists to
764
- // prevent, and how much of a record may be NAMED is ADR 0171 D2's question, not
765
- // R06's. Narrower under-names, which is safe. The widening is its own task.
766
- // client_id never appears in the returned shape.
759
+ // NAMED = on the map by the feed's own predicate (onMapSql) AND Public — the
760
+ // owner's call on task 1003558 (ADR 0171 D2 amendment). A Private project is
761
+ // named on the map but not here: who works there, their rank and their work
762
+ // titles stay counted. Stealth is on the map anonymously (ADR 0182 D2a).
763
+ // `featured` only ranks (D3), so it gates no name. client_id never crosses.
767
764
  async function getRollupForBuilder(githubId, { pool = defaultPool } = {}) {
768
765
  if (!isNumericGithubId(githubId)) return null;
769
766
  const { rows: aggRows } = await pool.query(
@@ -790,7 +787,7 @@ async function getRollupForBuilder(githubId, { pool = defaultPool } = {}) {
790
787
  const agg = aggRows[0];
791
788
  const { rows: projRows } = await pool.query(
792
789
  `SELECT p.name, p.origin, a.rank_held, a.recent_ships,
793
- (p.id IS NOT NULL AND p.featured AND p.status = 'active') AS is_public
790
+ (p.id IS NOT NULL AND ${onMapSql('p')} AND p.visibility = 'public') AS is_public
794
791
  FROM platform_identity_builder_project_activity a
795
792
  LEFT JOIN platform_identity_sso_clients c ON c.client_id = a.client_id
796
793
  LEFT JOIN platform_identity_projects p ON p.origin = c.origin
@@ -207,7 +207,20 @@ const PUBLIC_FEED_MAX_PAGE_SIZE = 500;
207
207
  // The same predicate written without the `visibility_source` half — or shipped without
208
208
  // migration 020's backfill, which stamps every pre-existing row 'control-plane' — would
209
209
  // empty the public map on deploy. Both halves are load-bearing.
210
- const DOOR_KNOWN_SQL = "(visibility_source = 'control-plane' OR visibility_reported_at IS NOT NULL)";
210
+ function doorKnownSql(prefix = '') {
211
+ return `(${prefix}visibility_source = 'control-plane' OR ${prefix}visibility_reported_at IS NOT NULL)`;
212
+ }
213
+ const DOOR_KNOWN_SQL = doorKnownSql();
214
+
215
+ // ON THE MAP, as one SQL fragment: active, publishable and door-known. The feed's WHERE
216
+ // and a builder profile's naming rule (getRollupForBuilder, task 1003558) both read it,
217
+ // so a profile can never name a project by a different idea of "on the map" than the map's.
218
+ // publishableSql() refuses a non-identifier alias before this interpolates it too.
219
+ function onMapSql(alias) {
220
+ const publishable = publishableSql(alias);
221
+ const prefix = alias ? `${alias}.` : '';
222
+ return `(${prefix}status = 'active' AND ${publishable} AND ${doorKnownSql(prefix)})`;
223
+ }
211
224
 
212
225
  // Every project ON THE MAP, in the public projection (no owner_github_id, no status) — ONE
213
226
  // PAGE of it (task 1003561, which absorbed task 1003560). `visibility` is projected because
@@ -235,7 +248,7 @@ async function listPublicProjects({ pool = defaultPool, limit = PUBLIC_FEED_MAX_
235
248
  const { rows } = await pool.query(
236
249
  `SELECT origin, name, tagline, art_url, type, template, created_at, visibility
237
250
  FROM platform_identity_projects
238
- WHERE status = 'active' AND ${publishableSql()} AND ${DOOR_KNOWN_SQL}
251
+ WHERE ${onMapSql()}
239
252
  ORDER BY ${IDENTIFIED_SQL} DESC,
240
253
  (${IDENTIFIED_SQL} AND featured) DESC,
241
254
  (CASE WHEN ${IDENTIFIED_SQL} THEN created_at END) DESC,
@@ -260,5 +273,6 @@ module.exports = {
260
273
  feedShapeFor,
261
274
  PUBLIC_FEED_MAX_PAGE_SIZE,
262
275
  DOOR_KNOWN_SQL,
276
+ onMapSql,
263
277
  listPublicProjects,
264
278
  };
@@ -18,6 +18,7 @@ import { readFileSync } from 'node:fs';
18
18
 
19
19
  const require = createRequire(import.meta.url);
20
20
  const P = require('../platform-identity.js');
21
+ const { onMapSql } = require('../projects-feed.js');
21
22
  const terms = require('../terms-acceptance.js');
22
23
 
23
24
  let passed = 0, failed = 0;
@@ -287,7 +288,7 @@ await ta('listPublicProjects returns a PUBLIC projection (on-the-map only, no ow
287
288
  // R06 (task 1002325) replaced the `featured = true` gate with the derived publish
288
289
  // gate: a project is on the map iff it is active AND publishable (ADR 0182 D3).
289
290
  // The full predicate + the stealth redaction are pinned by tests/visible_projects_feed.mjs.
290
- assert.match(c.text, /WHERE status = 'active' AND /);
291
+ assert.match(c.text, /WHERE \(status = 'active' AND /);
291
292
  assert.ok(c.text.includes(P.publishableSql()), 'the map predicate is the shared gate');
292
293
  // Pin the SELECT LIST itself, not the whole query — an ORDER BY mention must
293
294
  // not satisfy a projection guard, and SELECT * must never sneak owner/status
@@ -681,6 +682,11 @@ await ta('getRollupForBuilder aggregates un-fanned + names only publicly-catalog
681
682
  const aggCte = aggSql.slice(aggSql.indexOf('WITH agg AS'), aggSql.indexOf('disc AS'));
682
683
  assert.doesNotMatch(aggCte, /unnest/i, 'scalar CTE must not fan rows out');
683
684
  assert.match(aggSql.slice(aggSql.indexOf('disc AS')), /array_agg\(DISTINCT x\.d\)/);
685
+ // WHO is named (task 1003558): on the map by the feed's own predicate AND Public.
686
+ const isPublic = (pool.calls[1].text.match(/\(p\.id IS NOT NULL[\s\S]*?\) AS is_public/) || [''])[0];
687
+ assert.ok(isPublic.includes(onMapSql('p')), 'names read the feed\'s on-the-map predicate, not a restatement');
688
+ assert.match(isPublic, /AND p\.visibility = 'public'\) AS is_public$/, 'Private and Stealth are counted, never named');
689
+ assert.doesNotMatch(isPublic, /featured/, 'featured only ranks the feed (ADR 0182 D3); it gates no name');
684
690
  });
685
691
 
686
692
  await ta('getPublicProfileExtras is fail-closed: null for missing / provisional / hidden accounts', async () => {
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.778",
3
+ "version": "1.19.779",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.778",
9
+ "version": "1.19.779",
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.778",
3
+ "version": "1.19.779",
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
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.778'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.779'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -237,6 +237,19 @@ await t('getRollupForBuilder attaches recent_ships to publicly-named projects on
237
237
  assert.ok(!serialized.includes('SECRET-PROJECT'), 'nor does its name (the pre-existing rule this one follows)');
238
238
  });
239
239
 
240
+ await t('works follow the NAME: the one statement that reads recent_ships names Public projects only (task 1003558)', async () => {
241
+ // A second, wider read of the works would let a Private project's titles out while
242
+ // its name stayed counted. So the works and the naming verdict must share a row.
243
+ const pool = mockPool((sql) => (/WITH agg AS/.test(sql)
244
+ ? { rows: [{ projects: 0, credits: 0, works_shipped: 0, karma: 0, last_active: null, disciplines: null }] }
245
+ : { rows: [] }));
246
+ await pi.getRollupForBuilder(42, { pool });
247
+ const reads = pool.calls.filter((c) => /recent_ships/.test(c.text));
248
+ assert.equal(reads.length, 1, 'exactly one statement reads the works');
249
+ assert.match(reads[0].text, /AND p\.visibility = 'public'\) AS is_public/, 'and it decides the name on the same row');
250
+ assert.doesNotMatch(reads[0].text, /featured/, 'featured gates no name, so it gates no work either');
251
+ });
252
+
240
253
  await t('a project that reported no works reads as an empty list, not null', async () => {
241
254
  const pool = mockPool((sql) => {
242
255
  if (/WITH agg AS/.test(sql)) return { rows: [{ projects: 1, credits: 0, works_shipped: 0, karma: 0, last_active: null, disciplines: null }] };
@@ -8,9 +8,10 @@
8
8
  // /scouting by construction. Consent is TWO columns since ADR 0213
9
9
  // (profile_state='active' AND terms_accepted_at IS NOT NULL), and the
10
10
  // LEGACY fixture is the pre-terms row that proves the second one.
11
- // 2. getRollupForBuilder aggregates un-fanned and names ONLY projects whose
12
- // catalog row is publicly listed (featured + active); everything else is
13
- // counted in more_projects, never named. client_id never crosses.
11
+ // 2. getRollupForBuilder aggregates un-fanned and names ONLY projects that are
12
+ // on the map AND Public (task 1003558) — featured or not; Private, Stealth
13
+ // and door-unknown rows are counted in more_projects, never named.
14
+ // client_id never crosses.
14
15
  // 3. getPublicProfileExtras is fail-closed (missing/provisional/hidden/
15
16
  // non-numeric → null); getOwnProfileExtras serves the self view anyway.
16
17
  // 4. upsertAccount's consent upgrade is one-way (activate flips provisional →
@@ -53,15 +54,18 @@ try {
53
54
  const ACTIVE = 90002278, HIDDEN = 90002279, PROV = 90002280, LAZY = 90002281, LEGACY = 90002283;
54
55
  const ALL_IDS = [ACTIVE, HIDDEN, PROV, LAZY, LEGACY];
55
56
  const PUB_CLIENT = 't1002278-pub', PRIV_CLIENT = 't1002278-priv', LOST_CLIENT = 't1002278-lost';
56
- const ALL_CLIENTS = [PUB_CLIENT, PRIV_CLIENT, LOST_CLIENT];
57
+ const STEALTH_CLIENT = 't1003558-stealth', UNREAD_CLIENT = 't1003558-unread';
58
+ const ALL_CLIENTS = [PUB_CLIENT, PRIV_CLIENT, LOST_CLIENT, STEALTH_CLIENT, UNREAD_CLIENT];
57
59
  const PUB_ORIGIN = 'https://t1002278-pub.example', PRIV_ORIGIN = 'https://t1002278-priv.example';
60
+ const STEALTH_ORIGIN = 'https://t1003558-stealth.example', UNREAD_ORIGIN = 'https://t1003558-unread.example';
61
+ const ALL_ORIGINS = [PUB_ORIGIN, PRIV_ORIGIN, STEALTH_ORIGIN, UNREAD_ORIGIN];
58
62
  const q = (sql, params) => pool.query(sql, params);
59
63
 
60
64
  async function cleanup() {
61
65
  await q(`DELETE FROM platform_identity_builder_project_activity WHERE github_id = ANY($1::bigint[])`, [ALL_IDS]).catch(() => {});
62
66
  await q(`DELETE FROM platform_identity_project_memberships WHERE github_id = ANY($1::bigint[])`, [ALL_IDS]).catch(() => {});
63
67
  await q(`DELETE FROM platform_identity_accounts WHERE github_id = ANY($1::bigint[])`, [ALL_IDS]).catch(() => {});
64
- await q(`DELETE FROM platform_identity_projects WHERE origin = ANY($1::text[])`, [[PUB_ORIGIN, PRIV_ORIGIN]]).catch(() => {});
68
+ await q(`DELETE FROM platform_identity_projects WHERE origin = ANY($1::text[])`, [ALL_ORIGINS]).catch(() => {});
65
69
  await q(`DELETE FROM platform_identity_sso_clients WHERE client_id = ANY($1::text[])`, [ALL_CLIENTS]).catch(() => {});
66
70
  }
67
71
 
@@ -75,12 +79,20 @@ try {
75
79
  // (012), and the consent floor tests terms_accepted_at (019). Without them a
76
80
  // FRESH database answers 42703 "column does not exist" — an infrastructure
77
81
  // error dressed up as a boundary failure — instead of a clean assertion.
82
+ // 011 and 020 carry the columns the naming rule reads (description, visibility,
83
+ // the door-known pair); 012_recent_ships the column the rollup SELECTs; 008 and
84
+ // 009 the handle and display_name_source columns upsertAccount writes.
78
85
  await q(migrationSql('platform_identity_001_tables.sql'));
79
86
  await q(migrationSql('platform_identity_004_projects.sql'));
80
87
  await q(migrationSql('platform_identity_005_builder_activity.sql'));
81
88
  await q(migrationSql('platform_identity_007_profile_consent.sql'));
89
+ await q(migrationSql('platform_identity_008_handle.sql'));
90
+ await q(migrationSql('platform_identity_009_profile_fields.sql'));
91
+ await q(migrationSql('platform_identity_011_project_visibility.sql'));
82
92
  await q(migrationSql('platform_identity_012_account_privacy.sql'));
93
+ await q(migrationSql('platform_identity_012_recent_ships.sql'));
83
94
  await q(migrationSql('platform_identity_019_terms_acceptance.sql'));
95
+ await q(migrationSql('platform_identity_020_visibility_pull.sql'));
84
96
  await cleanup();
85
97
 
86
98
  // CONSENT IS TWO COLUMNS (ADR 0213, task 1002913): profile_state='active' AND
@@ -96,27 +108,39 @@ try {
96
108
  ($4, 'legacy-b', 'Legacy', 'active', false, NULL)`,
97
109
  [ACTIVE, HIDDEN, PROV, LEGACY],
98
110
  );
99
- // Three reporting projects: one publicly catalogued (featured+active), one
100
- // catalogued but NOT featured (→ never named), one with no catalog row at all.
111
+ // Five reporting projects (task 1003558). All but Lost are active and described;
112
+ // only Pub is on the map AND Public — and it is the one that is NOT featured, so
113
+ // the old `featured` gate would name exactly the wrong rows:
114
+ // Pub public, unfeatured, door assigned → named
115
+ // Priv private, featured, door assigned → counted
116
+ // Stealth stealth, featured, door assigned → counted
117
+ // Unread public, featured, door never read → counted (dark matter)
118
+ // Lost no catalog row at all → counted
101
119
  await q(
102
120
  `INSERT INTO platform_identity_sso_clients (client_id, secret_hash, name, origin)
103
- VALUES ($1, 'x', 'Pub Proj', $4), ($2, 'x', 'Priv Proj', $5), ($3, 'x', 'Lost Proj', NULL)`,
104
- [...ALL_CLIENTS, PUB_ORIGIN, PRIV_ORIGIN],
121
+ VALUES ($1, 'x', 'Pub Proj', $6), ($2, 'x', 'Priv Proj', $7), ($3, 'x', 'Lost Proj', NULL),
122
+ ($4, 'x', 'Stealth Proj', $8), ($5, 'x', 'Unread Proj', $9)`,
123
+ [...ALL_CLIENTS, ...ALL_ORIGINS],
105
124
  );
106
125
  await q(
107
- `INSERT INTO platform_identity_projects (origin, name, featured, status)
108
- VALUES ($1, 'Pub Proj', true, 'active'), ($2, 'Priv Proj', false, 'active')`,
109
- [PUB_ORIGIN, PRIV_ORIGIN],
126
+ `INSERT INTO platform_identity_projects (origin, name, description, featured, status, visibility, visibility_source)
127
+ VALUES ($1, 'Pub Proj', 'described', false, 'active', 'public', 'control-plane'),
128
+ ($2, 'Priv Proj', 'described', true, 'active', 'private', 'control-plane'),
129
+ ($3, 'Stealth Proj', 'described', true, 'active', 'stealth', 'control-plane'),
130
+ ($4, 'Unread Proj', 'described', true, 'active', 'public', NULL)`,
131
+ ALL_ORIGINS,
110
132
  );
111
133
  await q(
112
134
  `INSERT INTO platform_identity_builder_project_activity
113
135
  (client_id, github_id, credits, tasks_shipped, karma, rank_held, disciplines, last_active)
114
- VALUES ($1, $4, 100, 5, 3, 'metic', '{engineer,ui}', '2026-01-01T00:00:00Z'),
115
- ($2, $4, 50, 2, 1, 'archon', '{artist}', '2026-02-01T00:00:00Z'),
116
- ($3, $4, 25, 1, 0, NULL, '{}', '2026-03-01T00:00:00Z'),
117
- ($1, $5, 60, 4, 2, 'metic', '{engineer}', '2026-01-01T00:00:00Z'),
118
- ($1, $6, 10, 1, 0, 'xenos', '{engineer}', '2026-01-01T00:00:00Z'),
119
- ($1, $7, 90, 3, 1, 'metic', '{engineer}', '2026-01-01T00:00:00Z')`,
136
+ VALUES ($1, $6, 100, 5, 3, 'metic', '{engineer,ui}', '2026-01-01T00:00:00Z'),
137
+ ($2, $6, 50, 2, 1, 'archon', '{artist}', '2026-02-01T00:00:00Z'),
138
+ ($3, $6, 25, 1, 0, NULL, '{}', '2026-03-01T00:00:00Z'),
139
+ ($4, $6, 0, 0, 0, 'thetes', '{}', '2025-12-01T00:00:00Z'),
140
+ ($5, $6, 0, 0, 0, 'thetes', '{}', '2025-12-01T00:00:00Z'),
141
+ ($1, $7, 60, 4, 2, 'metic', '{engineer}', '2026-01-01T00:00:00Z'),
142
+ ($1, $8, 10, 1, 0, 'xenos', '{engineer}', '2026-01-01T00:00:00Z'),
143
+ ($1, $9, 90, 3, 1, 'metic', '{engineer}', '2026-01-01T00:00:00Z')`,
120
144
  [...ALL_CLIENTS, ACTIVE, HIDDEN, PROV, LEGACY],
121
145
  );
122
146
 
@@ -133,18 +157,20 @@ try {
133
157
 
134
158
  // 2 — getRollupForBuilder semantics.
135
159
  const r = await pi.getRollupForBuilder(ACTIVE, { pool });
136
- assert.deepEqual(r.totals, { projects: 3, credits: 175, works_shipped: 8, karma: 4 });
160
+ assert.deepEqual(r.totals, { projects: 5, credits: 175, works_shipped: 8, karma: 4 });
137
161
  pass++; console.log(' ok - totals aggregate un-fanned across the builder\'s projects');
138
162
  assert.deepEqual([...r.disciplines].sort(), ['artist', 'engineer', 'ui']);
139
163
  pass++; console.log(' ok - disciplines are the deduped union');
140
164
  ok(new Date(r.last_active).toISOString() === '2026-03-01T00:00:00.000Z', 'last_active is the max');
141
- ok(r.projects.length === 1 && r.projects[0].name === 'Pub Proj', 'only the publicly-catalogued project is named');
142
- assert.deepEqual(Object.keys(r.projects[0]).sort(), ['name', 'origin', 'rank_held']);
143
- pass++; console.log(' ok - named rows carry exactly name/origin/rank_held');
165
+ ok(r.projects.length === 1 && r.projects[0].name === 'Pub Proj', 'only the on-the-map Public project is named — unfeatured, and named anyway');
166
+ assert.deepEqual(Object.keys(r.projects[0]).sort(), ['name', 'origin', 'rank_held', 'recent_ships']);
167
+ pass++; console.log(' ok - named rows carry exactly name/origin/rank_held/recent_ships');
144
168
  ok(r.projects[0].rank_held === 'metic', 'rank_held is that project\'s reported standing');
145
- ok(r.more_projects === 2, 'unfeatured + uncatalogued projects are counted, never named');
169
+ ok(r.more_projects === 4, 'Private, Stealth, door-unknown and uncatalogued projects are counted, never named');
146
170
  ok(!JSON.stringify(r).includes('client_id'), 'client_id never crosses the shape');
147
- ok(!JSON.stringify(r).includes('Priv Proj'), 'a non-public project name never crosses');
171
+ for (const unnamed of ['Priv Proj', 'Stealth Proj', 'Unread Proj', PRIV_ORIGIN, STEALTH_ORIGIN, UNREAD_ORIGIN]) {
172
+ ok(!JSON.stringify(r).includes(unnamed), `"${unnamed}" never crosses`);
173
+ }
148
174
 
149
175
  // 3 — the port reads.
150
176
  ok(await pi.getPublicProfileExtras(HIDDEN, { pool }) === null, 'public read → null for a hidden builder');
@@ -345,16 +345,37 @@ const HIDDEN_LOGIN = 'hidden-DO-NOT-LEAK-r06';
345
345
  const PROV_LOGIN = 'provisional-DO-NOT-LEAK-r06';
346
346
  const PRIVATE_LOGIN = 'private-DO-NOT-LEAK-r06';
347
347
 
348
- // TWO projects — one publicly catalogued, one not. Distinctive digits so the
349
- // "no partial numbers" scan below cannot pass by coincidence.
348
+ // TWO projects, both on the map — one Public and UNFEATURED, one Private and
349
+ // FEATURED — so neither the old `featured` gate nor a door-blind one passes
350
+ // (task 1003558). Distinctive digits so the "no partial numbers" scan below
351
+ // cannot pass by coincidence.
352
+ const ON_MAP = { status: 'active', description: 'described', visibility_source: 'control-plane', visibility_reported_at: null };
350
353
  const PROOF_PROJECTS = [
351
354
  { name: 'R06 Public Proj', origin: 'https://r06-pub.example', rank_held: 'metic',
352
- credits: 60271, tasks_shipped: 4103, karma: 9021, is_public: true },
355
+ credits: 60271, tasks_shipped: 4103, karma: 9021, catalog: { ...ON_MAP, visibility: 'public', featured: false } },
353
356
  { name: SECRET_PROJECT_NAME, origin: 'https://r06-priv.example', rank_held: 'archon',
354
- credits: 40129, tasks_shipped: 2011, karma: 3007, is_public: false },
357
+ credits: 40129, tasks_shipped: 2011, karma: 3007, catalog: { ...ON_MAP, visibility: 'private', featured: true } },
355
358
  ];
356
359
  const sumOf = (k) => PROOF_PROJECTS.reduce((n, p) => n + p[k], 0);
357
360
 
361
+ // getRollupForBuilder's `is_public`, evaluated the scouting engine's way: a catalog
362
+ // row is named unless a predicate the SQL actually DECLARES refuses it. Drop
363
+ // `p.visibility = 'public'` from the module and the Private project gets named here.
364
+ function namedByDeclaredSql(sql, project) {
365
+ const expr = (sql.match(/\(p\.id IS NOT NULL[\s\S]*?\) AS is_public/) || [''])[0];
366
+ const c = { name: project.name, ...project.catalog };
367
+ const declared = [
368
+ [/p\.featured/, c.featured === true],
369
+ [/p\.status = 'active'/, c.status === 'active'],
370
+ [/coalesce\(p\.name, ''\)\) <> ''/, String(c.name ?? '').trim() !== ''],
371
+ [/coalesce\(p\.description, ''\)\) <> ''/, String(c.description ?? '').trim() !== ''],
372
+ [/p\.visibility_source = 'control-plane' OR p\.visibility_reported_at IS NOT NULL/,
373
+ c.visibility_source === 'control-plane' || c.visibility_reported_at != null],
374
+ [/p\.visibility = 'public'/, c.visibility === 'public'],
375
+ ];
376
+ return expr !== '' && declared.every(([re, holds]) => !re.test(expr) || holds);
377
+ }
378
+
358
379
  // The one account row the reads consult. setAccountHideStats mutates it.
359
380
  // ADR 0213 (task 1002913): consent is profile_state AND an acceptance of the
360
381
  // platform terms, both stamped by the same upsertAccount branch.
@@ -415,7 +436,8 @@ const engine = {
415
436
  }
416
437
  // getRollupForBuilder's per-project rows (the client_id → origin two-hop).
417
438
  if (/platform_identity_sso_clients/.test(sql)) {
418
- return { rows: PROOF_PROJECTS.map((p) => ({ name: p.name, origin: p.origin, rank_held: p.rank_held, is_public: p.is_public, recent_ships: [] })) };
439
+ return { rows: PROOF_PROJECTS.map((p) => ({ name: p.name, origin: p.origin, rank_held: p.rank_held,
440
+ is_public: namedByDeclaredSql(sql, p), recent_ships: [] })) };
419
441
  }
420
442
  // getRollupForBuilder's un-fanned aggregate — summed ACROSS both projects.
421
443
  if (/AS works_shipped/.test(sql)) {
@@ -460,9 +482,38 @@ await t('R06.1 a two-project builder\'s COMBINED record shows to another account
460
482
  assert.equal(cp.totals.works_shipped, sumOf('tasks_shipped'), 'ships are summed across both');
461
483
  assert.equal(cp.totals.karma, sumOf('karma'), 'karma is summed across both');
462
484
  assert.deepEqual(cp.disciplines, ['engineer', 'ui'], 'the disciplines union spans both projects');
463
- assert.equal(cp.projects.length, 1, 'only the publicly-catalogued project is NAMED');
485
+ assert.equal(cp.projects.length, 1, 'only the Public project is NAMED — unfeatured, and named anyway');
464
486
  assert.equal(cp.projects[0].name, PROOF_PROJECTS[0].name);
465
- assert.equal(cp.more_projects, 1, 'the other is counted, never named');
487
+ assert.equal(cp.more_projects, 1, 'the Private one is counted, never named — featured, and unnamed anyway');
488
+ });
489
+
490
+ await t('R06.1 a name needs the map AND the Public door — every other shape is counted, never named (task 1003558)', async () => {
491
+ const [pub, other] = PROOF_PROJECTS;
492
+ const original = other.catalog;
493
+ const unnamed = {
494
+ private: { ...original, visibility: 'private' },
495
+ stealth: { ...original, visibility: 'stealth' },
496
+ 'public, door never read': { ...original, visibility: 'public', visibility_source: null, visibility_reported_at: null },
497
+ 'public, no description (dark matter)': { ...original, visibility: 'public', description: ' ' },
498
+ 'public, hidden from the catalog': { ...original, visibility: 'public', status: 'hidden' },
499
+ };
500
+ try {
501
+ for (const [label, catalog] of Object.entries(unnamed)) {
502
+ other.catalog = catalog;
503
+ const r = await get(port, '/api/gds/builders/95/profile');
504
+ const cp = r.json.cross_project;
505
+ assert.deepEqual(cp.projects.map((p) => p.name), [pub.name], `${label}: only the Public project is named`);
506
+ assert.equal(cp.more_projects, 1, `${label}: counted`);
507
+ assert.ok(!r.text.includes(SECRET_PROJECT_NAME) && !r.text.includes(other.origin), `${label}: neither its name nor its origin crosses`);
508
+ }
509
+ // The positive control: the same row IS named once Public and on the map, so the
510
+ // refusals above are the door, not a fixture nothing could ever name.
511
+ other.catalog = { ...original, visibility: 'public' };
512
+ const r = await get(port, '/api/gds/builders/95/profile');
513
+ assert.deepEqual(r.json.cross_project.projects.map((p) => p.name), [pub.name, SECRET_PROJECT_NAME]);
514
+ } finally {
515
+ other.catalog = original;
516
+ }
466
517
  });
467
518
 
468
519
  await t('R06.1 the record is viewer-INDEPENDENT — another account sees the same body', async () => {
@@ -463,7 +463,9 @@ test('dark matter never reaches the route at all — the exclusion is in SQL', a
463
463
  const src = require('node:fs').readFileSync(
464
464
  new URL('../modules/platform-identity/projects-feed.js', import.meta.url), 'utf8');
465
465
  const fn = (src.match(/async function listPublicProjects[\s\S]*?\n\}/) || [''])[0];
466
- assert.ok(fn.includes('publishableSql()'), 'the gate is applied in the query, not after it');
466
+ assert.ok(fn.includes('onMapSql()'), 'the gate is applied in the query, not after it');
467
+ const feed = require('../modules/platform-identity/projects-feed.js');
468
+ assert.ok(feed.onMapSql().includes(pi.publishableSql()), 'and on-the-map carries the publish gate');
467
469
  });
468
470
 
469
471
  // ── the route's half of the page window (task 1003560 → 1003561) ─────────────