@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 +16 -16
- package/.claude/skills/session-handoff/SKILL.md +3 -3
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/api/openapi.json +4 -2
- package/docs/module-api-changelog.md +4 -0
- package/modules/lifecycle/closeness.js +87 -37
- package/modules/lifecycle/routes/closeness.js +17 -8
- package/modules/platform-identity/platform-identity.js +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/closeness.mjs +151 -31
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.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
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-
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
6695
|
+
"sha256": "6f770aa63ab58c0ce96f86e9b905b68dfba4c562b0c67d9233dfbbc4c0992ed5"
|
|
6696
6696
|
},
|
|
6697
6697
|
{
|
|
6698
6698
|
"path": "package.json",
|
|
6699
6699
|
"mode": "0000644",
|
|
6700
|
-
"sha256": "
|
|
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": "
|
|
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": "
|
|
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** —
|
|
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 "
|
|
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 }
|
package/docs/api/openapi.json
CHANGED
|
@@ -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
|
|
28
|
-
// enforced in the READ path (task
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
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
|
-
//
|
|
250
|
-
//
|
|
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
|
|
259
|
-
async function
|
|
260
|
-
|
|
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(
|
|
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
|
|
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
|
|
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. `
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
//
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
|
291
|
-
let entries = [...ids].map((otherId) => {
|
|
338
|
+
const entries = [...signals.ids].map((id) => {
|
|
292
339
|
const counts = {
|
|
293
|
-
co_claim:
|
|
294
|
-
co_build_session:
|
|
295
|
-
shipped_together_goal:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
19
|
-
//
|
|
20
|
-
// the
|
|
21
|
-
//
|
|
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:
|
|
40
|
-
// the
|
|
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
|
|
92
|
+
const { entries, total } = await closeness.closenessFor(req.builder.id, {
|
|
89
93
|
hiddenIdsFor: consentReader(api.pool),
|
|
90
94
|
});
|
|
91
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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');
|
package/tests/closeness.mjs
CHANGED
|
@@ -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
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
172
|
-
|
|
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
|
|
178
|
-
|
|
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
|
|
186
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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('
|
|
198
|
-
//
|
|
199
|
-
// not
|
|
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
|
-
|
|
203
|
-
|
|
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', () => {
|