@bongos/core 1.19.476 → 1.19.478

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.476",
6
- "core_contract": "1.19.476",
7
- "source_commit": "631886be90fa74af3353170d0f5e351a5cf100c7",
5
+ "core_version": "1.19.478",
6
+ "core_contract": "1.19.478",
7
+ "source_commit": "68f2bd02a4d1e4136b9469fb80d16e6b97896aac",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-02T16:34:36.724Z",
9
+ "built_at": "2026-09-02T17:55:45.973Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 413,
@@ -17,7 +17,7 @@
17
17
  "gate": "passed"
18
18
  },
19
19
  "file_count": 2275,
20
- "tree_sha256": "06f67b13e31a022107f61818ab5b0dbc693fcd5c8fd30d8f58e27e16e4b7a113",
20
+ "tree_sha256": "616c87783ecd91c37264dc29f95012b4fc50fc2746b7df6340ea87522e180eea",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -252,7 +252,7 @@
252
252
  {
253
253
  "path": ".claude/skills/session-handoff/SKILL.md",
254
254
  "mode": "0000644",
255
- "sha256": "ceca875c997081dda2692a1319f78dded07383b9b1645914a68dac09020e2035"
255
+ "sha256": "ddce51865985491a71bf89f734de3589cf2bb4f1ee76ac828b0c3905844db75f"
256
256
  },
257
257
  {
258
258
  "path": ".claude/skills/status/SKILL.md",
@@ -382,7 +382,7 @@
382
382
  {
383
383
  "path": "clients/bongos-client/index.d.ts",
384
384
  "mode": "0000644",
385
- "sha256": "6e1ae5c410c8796df7efcdcd9cbd0ccebe1075da8bc18d503c50196d0e7db683"
385
+ "sha256": "df45c050cb2b49cf1862ffa595122041600eab26808e74f0ca1b6cdabc99c5eb"
386
386
  },
387
387
  {
388
388
  "path": "clients/bongos-client/index.mjs",
@@ -1667,7 +1667,7 @@
1667
1667
  {
1668
1668
  "path": "docs/api/openapi.json",
1669
1669
  "mode": "0000644",
1670
- "sha256": "fa675542b54c8bbd96530ecaac630deaf9873cc9091714eb9fde660e5af2ad4e"
1670
+ "sha256": "e0c405c9d3de164ae6cfc5796eb96068a36613130bfc4ea6459d2b2311a60107"
1671
1671
  },
1672
1672
  {
1673
1673
  "path": "docs/architecture.md",
@@ -2147,7 +2147,7 @@
2147
2147
  {
2148
2148
  "path": "docs/module-api-changelog.md",
2149
2149
  "mode": "0000644",
2150
- "sha256": "e8957fb9ba93280d840032cdfd55ac8e8aaaf8fc7bc7a80df4e1e78a3cc9bcd1"
2150
+ "sha256": "048ba70f31e863597acf8c21aecd13ea1a2966a58c14064266e0af68a8208d45"
2151
2151
  },
2152
2152
  {
2153
2153
  "path": "docs/modules-contract.md",
@@ -4752,7 +4752,7 @@
4752
4752
  {
4753
4753
  "path": "modules/lifecycle/closeness.js",
4754
4754
  "mode": "0000644",
4755
- "sha256": "f5668020c115a6d356a74603dc4a7229ddfdb8d5015a22f871a35386a869fe58"
4755
+ "sha256": "4ea4fe1c93ffe6d6f42af31d96dfe402d7dbccb4a5825b71c571024548a87c25"
4756
4756
  },
4757
4757
  {
4758
4758
  "path": "modules/lifecycle/conflict-resolve.js",
@@ -4962,7 +4962,7 @@
4962
4962
  {
4963
4963
  "path": "modules/lifecycle/routes/closeness.js",
4964
4964
  "mode": "0000644",
4965
- "sha256": "1950ba9d20f925fa1ecbe7fb37d35a5b80045c556c6f76caa90b82e213b7db88"
4965
+ "sha256": "1ad8e353ead5c3fe39fadc1d48a2fc65725762cc298e206464ef242f7fdd292d"
4966
4966
  },
4967
4967
  {
4968
4968
  "path": "modules/lifecycle/routes/dependencies.js",
@@ -5312,7 +5312,7 @@
5312
5312
  {
5313
5313
  "path": "modules/platform-identity/platform-identity.js",
5314
5314
  "mode": "0000644",
5315
- "sha256": "1542edccc61a1cdb77540f5b6521a91477234af03cdde9178a1ed5516363e1fd"
5315
+ "sha256": "c43bda216cc9c6ec1788dd5d6ef4978594c1ab6db61ad6af33ed8ce451594899"
5316
5316
  },
5317
5317
  {
5318
5318
  "path": "modules/platform-identity/pollers/code-sweep.js",
@@ -6692,12 +6692,12 @@
6692
6692
  {
6693
6693
  "path": "package-lock.json",
6694
6694
  "mode": "0000644",
6695
- "sha256": "66d0470b8bbef92b19fb646d6f7d03a07dbccdc43159a4a10cbfa7ef9edf234e"
6695
+ "sha256": "6f770aa63ab58c0ce96f86e9b905b68dfba4c562b0c67d9233dfbbc4c0992ed5"
6696
6696
  },
6697
6697
  {
6698
6698
  "path": "package.json",
6699
6699
  "mode": "0000644",
6700
- "sha256": "eeae6c47ef2d23504ee84eaf0681717f9f78aee6e87ebd02af794e235b9127d7"
6700
+ "sha256": "ab2302189b57d998f1004827eb9fc2b35e25e4bffb829b410d3c02ec024eeb47"
6701
6701
  },
6702
6702
  {
6703
6703
  "path": "public-docs/index.html",
@@ -8352,7 +8352,7 @@
8352
8352
  {
8353
8353
  "path": "src/module-api.js",
8354
8354
  "mode": "0000644",
8355
- "sha256": "e679812470446860395dcbc799ef21b0459c86f89e954ed9d50b4428f26be6ff"
8355
+ "sha256": "5234d5effd57b6b2f8a0a4b41fa0357e6e081d57ead831943f08e634077decbb"
8356
8356
  },
8357
8357
  {
8358
8358
  "path": "src/module-loader/catalog.js",
@@ -8917,7 +8917,7 @@
8917
8917
  {
8918
8918
  "path": "tests/closeness.mjs",
8919
8919
  "mode": "0000644",
8920
- "sha256": "b5c4791d84aa3126714c7b3188320dc4df984e282a086196ded2394f825295d1"
8920
+ "sha256": "be10e63b6a424914ba71537dbfa653dcb247b28b3e6a9a8caf4696268e4500cf"
8921
8921
  },
8922
8922
  {
8923
8923
  "path": "tests/cloud_bongos_pack.mjs",
@@ -16,10 +16,10 @@ Use it at the end of a working session, or mid-session when the context is getti
16
16
 
17
17
  Emit **exactly one fenced code block** (so it's trivially copy-pasteable) containing the prompt below. Write it for a reader — and a future Claude session — that has **none** of this conversation's context: be specific, name files by path, and spell out task ids as hall links, never a bare `#NNN`.
18
18
 
19
- Keep it **short and curated** — a prompt, not a transcript. Include only what the next session needs to act. Use these sections, dropping any that are genuinely empty:
19
+ Keep it **short and curated** — a prompt, not a transcript. Include only what the next session needs to act. Target **under ~60 lines** inside the block: every line is context the fresh session pays for before it does anything. If it runs long, cut history first, then pointers — never the next steps or the first action. Use these sections, dropping any that are genuinely empty:
20
20
 
21
21
  1. **Goal / where things stand** — one or two lines: the overall objective and the current state.
22
- 2. **Done this session** — a few bullets of what actually landed, each tagged `verified-prod` | `verified-smoke` | `implemented-not-verified` so the next session trusts the right things.
22
+ 2. **Done this session** — optional, and capped: **at most three one-line bullets**, each tagged `verified-prod` | `verified-smoke` | `implemented-not-verified`, and only for a fact the next session must TRUST before it builds — a behaviour it will rely on, a state it must not redo. The record of what landed already exists twice (the dated file under `docs/session-logs/` and the ship notes in the ledger), so point at that file by path rather than repeating it here. Drop the section when nothing needs trusting.
23
23
  3. **Next steps** — the ordered, concrete actions to take next. Name the exact files, functions, and task ids (as hall links). Lead with the single most important one.
24
24
  4. **Manual checks to run first** — anything the next session should verify before building on this work (a test to run, a page to load, a status to confirm).
25
25
  5. **Gotchas / watch-outs** — traps you hit or foresee (a stale-tooling merge, a flaky step, a gated path, a decision still open).
@@ -28,7 +28,7 @@ Keep it **short and curated** — a prompt, not a transcript. Include only what
28
28
 
29
29
  ## How to compose it
30
30
 
31
- - **Ground it in this session.** Pull the "Done" + "Next steps" from what actually happened here — the claims you worked, the files you touched, the decisions you made. If you hold an active claim, name it and its status.
31
+ - **Ground it in this session.** Pull the "Next steps" (and the few "Done" facts worth trusting) from what actually happened here — the claims you worked, the files you touched, the decisions you made. If you hold an active claim, name it and its status. History belongs in the session log, not in the prompt.
32
32
  - **Mirror `docs/handoff-template.md`** for the section shape, but condense: a starter prompt is a fraction of a full session log.
33
33
  - **Self-contained.** Assume the fresh session starts cold. Every reference (file, task, ADR, URL) must be resolvable without this chat.
34
34
  - **Improvable.** If the builder edits the draft, keep their changes — this is their prompt to reuse and refine, not a fixed artifact.
@@ -37,7 +37,7 @@ export interface GetBuildersIdOnboardingResponse { builder_id: unknown; onboardi
37
37
  export interface GetBuildersIdProfileResponse { builder: unknown; shipped_count: unknown; recent_ships: unknown; achievements: unknown; cross_project?: unknown; handle?: unknown }
38
38
  export interface GetBuildersMePendingRankChangeResponse { change: unknown; greeting?: unknown }
39
39
  export interface GetBuildersRosterResponse { builders: unknown; stale_hours: unknown; stale_hours_source: unknown }
40
- export interface GetClosenessForMeResponse { closeness: unknown; count: unknown }
40
+ export interface GetClosenessForMeResponse { closeness: unknown; count: unknown; returned: unknown }
41
41
  export interface GetClosenessWeightsResponse { weights: unknown; window_days: unknown; note: unknown }
42
42
  export interface GetClosenessWithBuilderIdResponse { closeness: unknown; why: unknown }
43
43
  export interface GetConnectionsDegreeHandleResponse { handle: unknown; degree: unknown; mutuals: unknown; mutual_count: unknown; truncated: unknown }
@@ -15912,11 +15912,13 @@
15912
15912
  "type": "object",
15913
15913
  "properties": {
15914
15914
  "closeness": {},
15915
- "count": {}
15915
+ "count": {},
15916
+ "returned": {}
15916
15917
  },
15917
15918
  "required": [
15918
15919
  "closeness",
15919
- "count"
15920
+ "count",
15921
+ "returned"
15920
15922
  ]
15921
15923
  },
15922
15924
  "GetClosenessWeightsResponse": {
@@ -1389,5 +1389,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1389
1389
  landed since 1.19.474 with no explicit bump. run 33653632532. (task 1002620)
1390
1390
  1.19.476 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1391
1391
  landed since 1.19.475 with no explicit bump. run 33655706909. (task 1002620)
1392
+ 1.19.477 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1393
+ landed since 1.19.476 with no explicit bump. run 33662059275. (task 1002620)
1394
+ 1.19.478 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1395
+ landed since 1.19.477 with no explicit bump. run 33664027105. (task 1002620)
1392
1396
  ---------------------------------------------------------------------------
1393
1397
  ```
@@ -24,13 +24,18 @@
24
24
  // dropped and the remaining weights are renormalised, and the breakdown names what
25
25
  // was excluded and why.
26
26
  //
27
- // CONSENT. ADR 0171's posture is fail-closed account state with the hide flag
28
- // enforced in the READ path (task 1002277) — a builder who hid their profile must
29
- // not reappear as a node in someone else's graph. This file routes EVERY graph
30
- // read through one gate, `applyConsent`, rather than filtering at each call site,
31
- // so C05 (task 1003175, "consent is the read path, not a filter bolted on after")
32
- // has a single structural place to make it so. The gate FAILS CLOSED on error:
33
- // a consent read that throws drops the graph to empty rather than serving it.
27
+ // CONSENT (task 1003175 / C05). ADR 0171's posture is INHERITED, not reinvented:
28
+ // fail-closed account state, the hide flag enforced in the READ path (task
29
+ // 1002277) — a builder who hid their profile must not reappear as a node in
30
+ // someone else's graph. What C05 adds is WHERE the gate sits. `consentedIds` gates
31
+ // candidate IDS, and `readConsentedSignals` applies it to the signal maps
32
+ // themselves, so the graph is ASSEMBLED FROM CONSENTED DATA rather than assembled
33
+ // and then filtered. Nothing downstream — score, rank, total, page, hydrate — can
34
+ // see a hidden builder, because they were never in the set those are derived from.
35
+ // That is the difference the criterion asks for: an assembled-then-filtered graph
36
+ // still leaks through counts, ranks and totals even when the row itself is gone.
37
+ // The gate FAILS CLOSED: a consent read that throws or returns null yields the
38
+ // empty set, and so the empty graph.
34
39
  //
35
40
  // A KNOWN PERFORMANCE GAP, measured rather than assumed. The signal joins key on
36
41
  // tasks.goal_id (NO index), claims.builder_id and claims.task_id (indexed only
@@ -243,11 +248,21 @@ function toCountMap(rows) {
243
248
  }
244
249
 
245
250
  // ---------------------------------------------------------------------------
246
- // Consent — ONE gate, fail-closed
251
+ // Consent — ONE gate, fail-closed, and UPSTREAM of everything derived
247
252
  // ---------------------------------------------------------------------------
248
253
  //
249
- // Every graph read passes through here, so C05 has a single structural place to
250
- // make consent the read path rather than a filter bolted on at each call site.
254
+ // C05 (task 1003175) is "consent is the READ PATH, not a filter bolted on after",
255
+ // and the distinction is structural rather than stylistic. The gate takes a set of
256
+ // candidate builder IDS and returns the ones that may be seen — it never takes
257
+ // assembled rows. Consent is therefore resolved before a score, a rank, a total or
258
+ // a page exists, so there is no derived quantity that could have been computed
259
+ // while a hidden builder was still in the set.
260
+ //
261
+ // A gate that filtered assembled entries leaves every present and future aggregate
262
+ // one forgetful line away from leaking: "you have 4 close collaborators" over a
263
+ // list of 3 is a leak by subtraction even though the node itself is absent, and so
264
+ // is a page that comes back one short, and so is a rank with a gap in it. Removing
265
+ // the row is the easy half; never having counted it is the guarantee.
251
266
  //
252
267
  // `hiddenIdsFor` is injected: the platform-identity hub owns the hide flag
253
268
  // (ADR 0171 D2/D4) and lifecycle must not reach across a module boundary to read
@@ -255,19 +270,49 @@ function toCountMap(rows) {
255
270
  // is enabled there is no hub-level hide flag to honour, and NOTHING is suppressed
256
271
  // by this mechanism — which is correct, not a hole: on such an instance nobody has
257
272
  // been offered the switch. What is NOT tolerated is an ERROR being read as
258
- // "nobody is hidden", so a throwing reader empties the graph.
259
- async function applyConsent(entries, hiddenIdsFor) {
260
- if (typeof hiddenIdsFor !== 'function') return entries;
273
+ // "nobody is hidden", so a throwing reader yields the empty set.
274
+ async function consentedIds(candidateIds, hiddenIdsFor) {
275
+ const ids = [...candidateIds].map(String);
276
+ if (typeof hiddenIdsFor !== 'function') return new Set(ids);
277
+ if (!ids.length) return new Set();
261
278
  let hidden;
262
279
  try {
263
- hidden = await hiddenIdsFor(entries.map((e) => e.builder_id));
280
+ hidden = await hiddenIdsFor(ids);
264
281
  } catch (_) {
265
282
  // FAIL CLOSED. An unreadable consent state is not permission.
266
- return [];
283
+ return new Set();
267
284
  }
268
- if (!hidden) return []; // ditto: null is not "nobody"
285
+ if (!hidden) return new Set(); // ditto: null is not "nobody"
269
286
  const set = hidden instanceof Set ? hidden : new Set((hidden || []).map(String));
270
- return entries.filter((e) => !set.has(String(e.builder_id)));
287
+ return new Set(ids.filter((id) => !set.has(id)));
288
+ }
289
+
290
+ // THE READ PATH. Reads the three signals and hands back count maps that have
291
+ // ALREADY passed the consent gate — a hidden builder is not a key in any of them.
292
+ // Every graph read goes through this one function, so consent is not a step a
293
+ // caller performs but a property of the data a caller is given.
294
+ async function readConsentedSignals(builderId, { otherId = null, hiddenIdsFor } = {}, deps = {}) {
295
+ const activePool = deps.pool || pool;
296
+ const [coClaim, coSession, shipped] = await Promise.all([
297
+ coClaimCounts(builderId, { otherId }, { pool: activePool }),
298
+ coSessionCounts(builderId, { otherId }, { pool: activePool }),
299
+ shippedTogetherCounts(builderId, { otherId }, { pool: activePool }),
300
+ ]);
301
+
302
+ const candidates = new Set([...coClaim.keys(), ...coSession.keys(), ...shipped.keys()]);
303
+ const allowed = await consentedIds(candidates, hiddenIdsFor);
304
+ return {
305
+ ids: allowed,
306
+ co_claim: keepOnly(coClaim, allowed),
307
+ co_build_session: keepOnly(coSession, allowed),
308
+ shipped_together_goal: keepOnly(shipped, allowed),
309
+ };
310
+ }
311
+
312
+ function keepOnly(counts, allowed) {
313
+ const m = new Map();
314
+ for (const id of allowed) if (counts.has(id)) m.set(id, counts.get(id));
315
+ return m;
271
316
  }
272
317
 
273
318
  // ---------------------------------------------------------------------------
@@ -275,28 +320,30 @@ async function applyConsent(entries, hiddenIdsFor) {
275
320
  // ---------------------------------------------------------------------------
276
321
 
277
322
  // Everyone this builder has any measurable closeness to, scored and explained,
278
- // strongest first. `hiddenIdsFor` is the consent gate above.
279
- // `otherId` narrows every signal query to one pair at the DATABASE, so the pair
280
- // read does not fetch a hundred rows to keep one. It is threaded through rather
281
- // than filtered afterwards — see the note above the signal reads.
323
+ // strongest first. `otherId` narrows every signal query to one pair at the
324
+ // DATABASE, so the pair read does not fetch a hundred rows to keep one.
325
+ //
326
+ // Returns `{ entries, total }`. `total` is the size of the CONSENTED graph before
327
+ // paging — the honest answer to "how many people am I close to", counted here,
328
+ // downstream of the gate. It is returned rather than left to the caller because
329
+ // both ways a caller could work it out are wrong: counting pre-gate rows is the
330
+ // "you have 4 close collaborators" leak itself, and counting the page answers 100
331
+ // for a builder with 300.
282
332
  async function closenessFor(builderId, { limit = MAX_ROWS, hiddenIdsFor, otherId = null } = {}, deps = {}) {
283
333
  const activePool = deps.pool || pool;
284
- const [coClaim, coSession, shipped] = await Promise.all([
285
- coClaimCounts(builderId, { otherId }, { pool: activePool }),
286
- coSessionCounts(builderId, { otherId }, { pool: activePool }),
287
- shippedTogetherCounts(builderId, { otherId }, { pool: activePool }),
288
- ]);
334
+ // Nothing below this line can see a builder who hid: they are not a key in any
335
+ // of these maps, so no score, rank, total or page is ever computed with them in.
336
+ const signals = await readConsentedSignals(builderId, { otherId, hiddenIdsFor }, { pool: activePool });
289
337
 
290
- const ids = new Set([...coClaim.keys(), ...coSession.keys(), ...shipped.keys()]);
291
- let entries = [...ids].map((otherId) => {
338
+ const entries = [...signals.ids].map((id) => {
292
339
  const counts = {
293
- co_claim: coClaim.get(otherId) || 0,
294
- co_build_session: coSession.get(otherId) || 0,
295
- shipped_together_goal: shipped.get(otherId) || 0,
340
+ co_claim: signals.co_claim.get(id) || 0,
341
+ co_build_session: signals.co_build_session.get(id) || 0,
342
+ shipped_together_goal: signals.shipped_together_goal.get(id) || 0,
296
343
  };
297
344
  const scored = scoreCloseness(counts);
298
345
  return {
299
- builder_id: otherId,
346
+ builder_id: id,
300
347
  score: scored.score,
301
348
  // The explanation travels WITH the score, on every surface, rather than
302
349
  // behind a second call a caller may not make. That is the decision.
@@ -304,11 +351,14 @@ async function closenessFor(builderId, { limit = MAX_ROWS, hiddenIdsFor, otherId
304
351
  };
305
352
  }).filter((e) => e.score > 0);
306
353
 
307
- entries = await applyConsent(entries, hiddenIdsFor);
354
+ // Ranking and paging run over the consented set, so positions are dense — a
355
+ // hidden builder leaves no gap at the top of someone's graph — and the page is
356
+ // filled from the rest rather than coming back short. A short page announces a
357
+ // removal as loudly as the row would have.
308
358
  entries.sort((a, b) => b.score - a.score || String(a.builder_id).localeCompare(String(b.builder_id)));
309
- entries = entries.slice(0, capRows(limit));
359
+ const total = entries.length;
310
360
 
311
- return hydrate(entries, activePool);
361
+ return { entries: await hydrate(entries.slice(0, capRows(limit)), activePool), total };
312
362
  }
313
363
 
314
364
  // One pair, with the same breakdown the graph carries. Separate from closenessFor
@@ -323,7 +373,7 @@ async function closenessBetween(builderId, otherId, { hiddenIdsFor } = {}, deps
323
373
  // into the same queries, so there is still exactly one definition of each
324
374
  // signal and the pair read costs a pair.
325
375
  const graph = await closenessFor(builderId, { limit: 1, hiddenIdsFor, otherId }, { pool: activePool });
326
- const found = graph.find((e) => String(e.builder_id) === String(otherId));
376
+ const found = graph.entries.find((e) => String(e.builder_id) === String(otherId));
327
377
  if (found) return found;
328
378
 
329
379
  // Not in the graph. That is either "no measurable closeness" or "consent says
@@ -359,5 +409,5 @@ module.exports = {
359
409
  weightsModel, availableSignals, unavailableSignals,
360
410
  scoreCloseness, explainInWords,
361
411
  coClaimCounts, coSessionCounts, shippedTogetherCounts,
362
- applyConsent, closenessFor, closenessBetween,
412
+ consentedIds, readConsentedSignals, closenessFor, closenessBetween,
363
413
  };
@@ -15,10 +15,13 @@
15
15
  // a caller may simply not make is how a score ends up on a page with nothing to
16
16
  // point at. So `why` rides in the same payload as the number, always.
17
17
  //
18
- // CONSENT is resolved here and passed INTO the read as a gate (see
19
- // closeness.js applyConsent), never applied to the output afterwards. The hub owns
20
- // the hide flag (ADR 0171 D2/D4) and lifecycle must not read another module's
21
- // table (ADR 0083), so the port is resolved optionally and a reader is handed down.
18
+ // CONSENT is resolved here and passed INTO the read as a gate (closeness.js
19
+ // `consentedIds`, applied by `readConsentedSignals` to the signal maps), never to
20
+ // the output afterwards — these handlers receive a graph that was assembled from
21
+ // consented data, and so have no filtering left to forget. That is C05 (task
22
+ // 1003175). The hub owns the hide flag (ADR 0171 D2/D4) and lifecycle must not
23
+ // read another module's table (ADR 0083), so the port is resolved optionally and a
24
+ // reader is handed down.
22
25
  //
23
26
  // Reaches core only through the doorway (ADR 0083).
24
27
 
@@ -36,8 +39,9 @@ const { asyncHandler, parseId } = api;
36
39
  // hub-level hide flag to honour, because nobody on that instance has been offered
37
40
  // the switch — suppressing everyone would not be consent, it would be an outage.
38
41
  // What must never happen is an ERROR being read as "nobody is hidden", and that is
39
- // why the reader THROWS rather than swallowing: applyConsent catches and empties
40
- // the graph, which is the fail-closed direction ADR 0171 asks for.
42
+ // why the reader THROWS rather than swallowing: consentedIds catches and yields
43
+ // the empty set, so the graph is empty — the fail-closed direction ADR 0171 asks
44
+ // for.
41
45
  function consentReader(pool) {
42
46
  const hub = api.resolveOptional('platform-identity.hub');
43
47
  if (!hub || typeof hub.hiddenGithubIds !== 'function') return null;
@@ -85,10 +89,15 @@ module.exports = function buildClosenessRouter() {
85
89
  // Reading another builder's graph would expose who THEY are close to, which is
86
90
  // a fact about third parties they did not consent to share.
87
91
  router.get('/closeness/for-me', api.requireBuilder, asyncHandler('GET /closeness/for-me', async (req, res) => {
88
- const rows = await closeness.closenessFor(req.builder.id, {
92
+ const { entries, total } = await closeness.closenessFor(req.builder.id, {
89
93
  hiddenIdsFor: consentReader(api.pool),
90
94
  });
91
- return res.json({ closeness: rows, count: rows.length });
95
+ // `count` is the CONSENTED total the read counted for us, never `entries.length`.
96
+ // Counting the page here would be wrong twice over: it is the surface that says
97
+ // "you have N close collaborators", so counting rows that were assembled before
98
+ // the gate is the leak this criterion is about, and counting the page after it
99
+ // answers 100 for a builder with 300.
100
+ return res.json({ closeness: entries, count: total, returned: entries.length });
92
101
  }, { errorCode: 'closeness_failed' }));
93
102
 
94
103
  // One pair — "why am I close to THEM", asked directly rather than by paging the
@@ -886,7 +886,7 @@ async function getPublicProfileExtras(githubId, { pool = defaultPool } = {}) {
886
886
  // FAIL-CLOSED is the CALLER's job here, and deliberately so: this function
887
887
  // THROWS on a bad read rather than returning an empty set, because an empty set
888
888
  // means "nobody is hidden" and a database error must never be able to say that.
889
- // modules/lifecycle/closeness.js applyConsent catches and empties the graph.
889
+ // modules/lifecycle/closeness.js consentedIds catches and empties the graph.
890
890
  async function hiddenGithubIds(githubIds, { pool = defaultPool } = {}) {
891
891
  const ids = (Array.isArray(githubIds) ? githubIds : []).filter(isNumericGithubId).map(String);
892
892
  if (!ids.length) return new Set();
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.476",
3
+ "version": "1.19.478",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.476",
9
+ "version": "1.19.478",
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.476",
3
+ "version": "1.19.478",
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
@@ -34,7 +34,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
34
34
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
35
35
  // the entry to that file. Look for a version's history there, not here.
36
36
  // ---------------------------------------------------------------------------
37
- const CORE_VERSION = '1.19.476'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
37
+ const CORE_VERSION = '1.19.478'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
38
38
 
39
39
  // A namespaced logger so a module's log lines are attributable + consistent.
40
40
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -9,9 +9,10 @@
9
9
  // 3. a hidden builder appears in no graph.
10
10
  //
11
11
  // The scorer is pure, so 1 and 2 are driven with real inputs rather than asserted
12
- // over source. 3 is a consent property of the read, and is driven through the
13
- // real applyConsent gate with stub readers — including the failure modes, which
14
- // are the ones that actually matter.
12
+ // over source. 3 is a consent property of the read, and since task 1003175 (C05)
13
+ // it is driven through the REAL graph read against a stub pool — the leak tests
14
+ // need a graph that was actually assembled, not a gate called in isolation —
15
+ // including the failure modes, which are the ones that actually matter.
15
16
  import { strict as assert } from 'node:assert';
16
17
  import { test } from 'node:test';
17
18
  import { createRequire } from 'node:module';
@@ -152,55 +153,174 @@ test('effective weights always sum to 1 over the available subset', () => {
152
153
  assert.ok(Math.abs(summed - 1) < 1e-3, `effective weights sum to ${summed}`);
153
154
  });
154
155
 
155
- // ── 3. CONSENT: a hidden builder appears in NO graph ────────────────────────
156
-
157
- const entries = () => [
158
- { builder_id: '10', score: 0.9 },
159
- { builder_id: '20', score: 0.5 },
160
- { builder_id: '30', score: 0.1 },
161
- ];
162
-
163
- test('THE CONSENT RULE: a hidden builder is removed from the graph', () => {
164
- return closeness.applyConsent(entries(), async () => new Set(['20']))
165
- .then((out) => assert.deepEqual(out.map((e) => e.builder_id), ['10', '30']));
156
+ // ── 3. CONSENT IS THE READ PATH (task 1003175 / C05) ────────────────────────
157
+ //
158
+ // C04 gated the graph by filtering assembled rows. C05's criterion says that is
159
+ // not enough: "the hide cannot be a post-filter over an already-assembled graph —
160
+ // an assembled-then-filtered graph leaks through counts, ranks and 'you have 4
161
+ // close collaborators' totals even when the node itself is hidden. Test the leak,
162
+ // not just the absence of the row."
163
+ //
164
+ // So these drive the REAL closenessFor against a stub pool and assert on the four
165
+ // things a post-filter gets wrong — the total, the identity read, the ranking and
166
+ // the page — rather than only on the missing row. Every one of them fails if the
167
+ // gate is moved back downstream of assembly.
168
+
169
+ // A pool that answers the three signal queries and the hydrate, keyed off the SQL
170
+ // each read actually sends. It also RECORDS which ids were hydrated, which is how
171
+ // "a hidden builder's identity is never read" becomes an observation rather than a
172
+ // claim about source order.
173
+ function fakePool({ coClaim = {}, coSession = {}, shipped = {} } = {}) {
174
+ const seen = { hydrated: null };
175
+ const toRows = (obj) => Object.entries(obj).map(([other_id, n]) => ({ other_id, n }));
176
+ return {
177
+ seen,
178
+ async query(sql, params) {
179
+ if (/FROM claims mine/.test(sql)) return { rows: toRows(coClaim) };
180
+ if (/FROM session_logs mine/.test(sql)) return { rows: toRows(coSession) };
181
+ if (/FROM tasks t_mine/.test(sql)) return { rows: toRows(shipped) };
182
+ if (/FROM builders WHERE id/.test(sql)) {
183
+ seen.hydrated = (params[0] || []).map(String);
184
+ return {
185
+ rows: seen.hydrated.map((id) => ({
186
+ id, github_login: `u${id}`, display_name: `B${id}`, avatar_url: null,
187
+ })),
188
+ };
189
+ }
190
+ throw new Error(`unexpected query: ${sql.slice(0, 70)}`);
191
+ },
192
+ };
193
+ }
194
+
195
+ // Four collaborators of clearly separated strength. '40' is the STRONGEST, so
196
+ // hiding them is the case that moves the top of the ranking.
197
+ const GRAPH = { coClaim: { 10: 1, 20: 2, 30: 3, 40: 9 } };
198
+ const hides = (...ids) => async () => new Set(ids.map(String));
199
+
200
+ test('THE CONSENT RULE: a hidden builder is no node in anyone else\'s graph', async () => {
201
+ const pool = fakePool(GRAPH);
202
+ const { entries } = await closeness.closenessFor(1, { hiddenIdsFor: hides(40) }, { pool });
203
+ assert.deepEqual(entries.map((e) => e.builder_id).sort(), ['10', '20', '30']);
204
+ });
205
+
206
+ test('THE COUNTS LEAK: the total counts the CONSENTED graph, not the assembled one', async () => {
207
+ // This is the criterion's own example. Four candidates, one hidden — a surface
208
+ // that says "you have 4 close collaborators" over a list of 3 has leaked the
209
+ // hidden builder by subtraction, without ever showing their row.
210
+ const pool = fakePool(GRAPH);
211
+ const { entries, total } = await closeness.closenessFor(1, { hiddenIdsFor: hides(40) }, { pool });
212
+ assert.equal(total, 3, 'the total is counted after the gate, not before it');
213
+ assert.equal(entries.length, 3);
214
+ });
215
+
216
+ test('THE IDENTITY LEAK: a hidden builder\'s name is never even fetched', async () => {
217
+ // Not fetched and then dropped — never asked for. A post-filter that ran after
218
+ // hydrate would still have read the row.
219
+ const pool = fakePool(GRAPH);
220
+ await closeness.closenessFor(1, { hiddenIdsFor: hides(40) }, { pool });
221
+ assert.ok(pool.seen.hydrated, 'the graph was hydrated');
222
+ assert.ok(!pool.seen.hydrated.includes('40'), 'the hidden builder was never looked up');
223
+ });
224
+
225
+ test('THE RANK LEAK: hiding the top scorer leaves no gap at the top', async () => {
226
+ // Ranking runs over the consented set, so the #1 slot is the strongest VISIBLE
227
+ // collaborator. Ranked before the gate, the top slot belongs to the hidden
228
+ // builder and is then removed — so "who am I closest to" comes back EMPTY for a
229
+ // builder who has three collaborators, which tells them precisely that someone
230
+ // was taken out from above them. Asked for one, you get the real one.
231
+ const pool = fakePool(GRAPH);
232
+ const { entries, total } = await closeness.closenessFor(1, { limit: 1, hiddenIdsFor: hides(40) }, { pool });
233
+ assert.equal(entries.length, 1, 'the #1 slot is filled, not vacated by the hidden builder');
234
+ assert.equal(entries[0].builder_id, '30', 'and it is the top VISIBLE builder');
235
+ assert.equal(total, 3);
236
+
237
+ // Ordering over the whole consented graph is dense and monotonic — no rank a
238
+ // reader could subtract from.
239
+ const all = await closeness.closenessFor(1, { hiddenIdsFor: hides(40) }, { pool });
240
+ const scores = all.entries.map((e) => e.score);
241
+ assert.deepEqual(scores, [...scores].sort((a, b) => b - a));
242
+ });
243
+
244
+ test('THE PAGE LEAK: a page is refilled from the rest, never returned short', async () => {
245
+ // Gating after the slice would hand back one row for a limit of two, and a page
246
+ // that comes back short announces a removal as loudly as the row would have.
247
+ const pool = fakePool(GRAPH);
248
+ const { entries, total } = await closeness.closenessFor(1, { limit: 2, hiddenIdsFor: hides(40) }, { pool });
249
+ assert.equal(entries.length, 2, 'the page is full');
250
+ assert.deepEqual(entries.map((e) => e.builder_id), ['30', '20']);
251
+ assert.equal(total, 3, 'and the total still describes the whole consented graph, not the page');
166
252
  });
167
253
 
168
254
  test('FAIL CLOSED: a consent read that THROWS empties the graph', async () => {
169
255
  // An unreadable consent state is not permission. This is the direction that
170
256
  // matters: the opposite default would serve a hidden builder on any hiccup.
171
- const out = await closeness.applyConsent(entries(), async () => { throw new Error('hub down'); });
172
- assert.deepEqual(out, []);
257
+ const pool = fakePool(GRAPH);
258
+ const { entries, total } = await closeness.closenessFor(1, {
259
+ hiddenIdsFor: async () => { throw new Error('hub down'); },
260
+ }, { pool });
261
+ assert.deepEqual(entries, []);
262
+ assert.equal(total, 0);
263
+ assert.equal(pool.seen.hydrated, null, 'and nobody was looked up on the way out');
173
264
  });
174
265
 
175
266
  test('FAIL CLOSED: a consent read returning null empties the graph too', async () => {
176
267
  // null is not "nobody is hidden".
177
- const out = await closeness.applyConsent(entries(), async () => null);
178
- assert.deepEqual(out, []);
268
+ const pool = fakePool(GRAPH);
269
+ const { entries, total } = await closeness.closenessFor(1, { hiddenIdsFor: async () => null }, { pool });
270
+ assert.deepEqual(entries, []);
271
+ assert.equal(total, 0);
179
272
  });
180
273
 
181
274
  test('no consent reader at all leaves the graph intact — that is not a hole', async () => {
182
275
  // Where the hub module is off there is no hub-level hide flag to honour,
183
276
  // because nobody on that instance has been offered the switch. Suppressing
184
277
  // everyone would not be consent, it would be an outage.
185
- const out = await closeness.applyConsent(entries(), null);
186
- assert.equal(out.length, 3);
278
+ const pool = fakePool(GRAPH);
279
+ const { entries, total } = await closeness.closenessFor(1, {}, { pool });
280
+ assert.equal(entries.length, 4);
281
+ assert.equal(total, 4);
187
282
  });
188
283
 
189
- test('consent is ONE gate the reads pass through, not a filter at each call site', () => {
190
- // C05 (task 1003175) is "consent is the read path, not a filter bolted on
191
- // after" — it needs a single structural place, and this is it.
192
- assert.equal((SRC.match(/await applyConsent\(/g) || []).length, 1,
193
- 'exactly one call site applies consent');
194
- assert.match(SRC, /entries = await applyConsent\(entries, hiddenIdsFor\);/);
284
+ // ── the gate's own shape ────────────────────────────────────────────────────
285
+
286
+ test('THE GATE TAKES IDS, NEVER ASSEMBLED ROWS — that is what makes it the read path', () => {
287
+ // The whole of C05 is this signature. A gate over ids can only be applied before
288
+ // there is anything to derive; a gate over rows invites being applied after.
289
+ const fn = SRC.slice(SRC.indexOf('async function consentedIds'));
290
+ const body = fn.slice(0, fn.indexOf('\n}'));
291
+ assert.ok(!/\.score|\.builder_id|entries/.test(body),
292
+ 'the gate never touches a scored row');
195
293
  });
196
294
 
197
- test('a suppressed builder\'s identity is never even read', () => {
198
- // hydrate() runs AFTER applyConsent, so a hidden builder\'s name and avatar are
199
- // not fetched at all — not fetched and then dropped.
295
+ test('consent is applied to the SIGNALS, so the graph is built from consented data', async () => {
296
+ // readConsentedSignals is the single structural place. The maps it returns do
297
+ // not contain a hidden builder, so nothing assembled from them can.
298
+ const pool = fakePool(GRAPH);
299
+ const signals = await closeness.readConsentedSignals(1, { hiddenIdsFor: hides(40) }, { pool });
300
+ assert.deepEqual([...signals.ids].sort(), ['10', '20', '30']);
301
+ assert.ok(!signals.co_claim.has('40'), 'the hidden builder is not a key in the signal map');
302
+ });
303
+
304
+ test('consent is ONE gate, and the graph read never re-implements it', () => {
305
+ assert.equal((SRC.match(/await consentedIds\(/g) || []).length, 1,
306
+ 'exactly one call site applies the gate');
307
+ const fn = SRC.slice(SRC.indexOf('async function closenessFor'));
308
+ const body = fn.slice(0, fn.indexOf('\n}'));
309
+ assert.ok(!/consentedIds|\.filter\([^)]*hidden/i.test(body),
310
+ 'closenessFor does not filter on consent itself — it reads already-consented data');
311
+ assert.match(body, /await readConsentedSignals\(/);
312
+ });
313
+
314
+ test('everything derived comes AFTER the gate', () => {
315
+ // Source order is the backstop for the behavioural tests above: the read, then
316
+ // the scoring, then the total. A future aggregate added below this line inherits
317
+ // the guarantee; one added above it would not.
200
318
  const fn = SRC.slice(SRC.indexOf('async function closenessFor'));
201
319
  const body = fn.slice(0, fn.indexOf('\n}'));
202
- assert.ok(body.indexOf('applyConsent') < body.indexOf('return hydrate'),
203
- 'consent is applied before hydration');
320
+ const gate = body.indexOf('readConsentedSignals');
321
+ for (const derived of ['scoreCloseness(', 'entries.sort(', 'const total', 'hydrate(']) {
322
+ assert.ok(gate < body.indexOf(derived), `${derived} is computed after the gate`);
323
+ }
204
324
  });
205
325
 
206
326
  test('the pair read cannot be used to detect that someone hid themselves', () => {