@bongos/core 1.19.418 → 1.19.420

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.
Files changed (48) hide show
  1. package/.bongos-core.json +95 -45
  2. package/.claude/skills/new-project/SKILL.md +22 -11
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +18 -0
  5. package/clients/bongos-client/index.cjs +18 -0
  6. package/clients/bongos-client/index.d.ts +28 -1
  7. package/clients/bongos-client/index.mjs +18 -0
  8. package/docs/adr/0207-connections-implementation-departures-and-honest-limits.md +90 -0
  9. package/docs/adr/0209-one-budget-for-the-account-existence-oracle.md +79 -0
  10. package/docs/adr/README.md +2 -0
  11. package/docs/api/openapi.json +518 -5
  12. package/docs/api-reference.md +16 -3
  13. package/docs/copy-inventory.md +186 -174
  14. package/docs/copy-registry.json +348 -238
  15. package/docs/file-map.md +1 -0
  16. package/docs/module-api-changelog.md +4 -0
  17. package/modules/dev-box/app/src/vendor/bongos-client.cjs +18 -0
  18. package/modules/onboarding/routes/access-requests.js +16 -3
  19. package/modules/platform-identity/account-visibility.js +58 -0
  20. package/modules/platform-identity/connections.js +490 -0
  21. package/modules/platform-identity/migrations/platform_identity_015_connections.sql +78 -0
  22. package/modules/platform-identity/module.json +1 -1
  23. package/modules/platform-identity/platform-identity.js +5 -5
  24. package/modules/platform-identity/routes/connections.js +236 -0
  25. package/modules/platform-identity/tests/platform-identity.mjs +20 -0
  26. package/modules/provisioning/migrations/provisioning_013_project_detail.sql +54 -0
  27. package/modules/provisioning/onboard-plan.js +13 -0
  28. package/modules/provisioning/provisioning.js +113 -5
  29. package/modules/provisioning/routes/provisioning.js +105 -0
  30. package/modules/provisioning/tests/provisioning.mjs +84 -0
  31. package/package-lock.json +2 -2
  32. package/package.json +1 -1
  33. package/public-landing/projects.html +161 -42
  34. package/scripts/gds/fitness.js +9 -0
  35. package/scripts/gds/onboard.js +41 -1
  36. package/scripts/gds/provision-net.js +7 -3
  37. package/src/bongos/middleware/rate-limit.js +68 -2
  38. package/src/bongos/routes/auth.js +19 -12
  39. package/src/module-api.js +13 -1
  40. package/tests/account_existence_rate_limit.mjs +375 -0
  41. package/tests/connections_api.mjs +568 -0
  42. package/tests/connections_ownership.mjs +313 -0
  43. package/tests/module_api.mjs +1 -0
  44. package/tests/onboard.mjs +56 -0
  45. package/tests/own_scope_guard.mjs +5 -0
  46. package/tests/projects_hub.mjs +69 -0
  47. package/tests/projects_hub_pre_uat.mjs +27 -5
  48. package/tests/provision.mjs +20 -0
@@ -6,6 +6,7 @@ export declare const DEFAULT_BASE_URL: string;
6
6
  // Request-body + shared types, generated from the OpenAPI components.schemas.
7
7
  export interface ApiErrorObject { code: string; message: string; details?: unknown }
8
8
  export interface DeleteBoxSshKeyIdResponse { ok: boolean; deleted: unknown }
9
+ export interface DeleteConnectionsIdResponse { outcome: unknown }
9
10
  export interface DeleteDependenciesResponse { ok: boolean }
10
11
  export interface DeleteGovernmentAssignmentsBuilderIdRankKeyResponse { ok: boolean; builder_id: unknown; rank_key: unknown; removed: unknown }
11
12
  export interface DeleteGovernmentRanksRankKeyPermissionsPermissionKeyResponse { ok: boolean; rank_key: unknown; permission_key: unknown; removed: unknown }
@@ -39,6 +40,9 @@ export interface GetBuildersRosterResponse { builders: unknown; stale_hours: unk
39
40
  export interface GetClosenessForMeResponse { closeness: unknown; count: unknown }
40
41
  export interface GetClosenessWeightsResponse { weights: unknown; window_days: unknown; note: unknown }
41
42
  export interface GetClosenessWithBuilderIdResponse { closeness: unknown; why: unknown }
43
+ export interface GetConnectionsDegreeHandleResponse { handle: unknown; degree: unknown; mutuals: unknown; mutual_count: unknown; truncated: unknown }
44
+ export interface GetConnectionsRequestsResponse { incoming: unknown; outgoing: unknown; incoming_count: unknown; outgoing_count: unknown }
45
+ export interface GetConnectionsResponse { connections: unknown; count: unknown }
42
46
  export interface GetDiscordChannelsPlanResponse { ok: boolean; guild_id: unknown; op_count: unknown; ops: unknown; warnings: unknown }
43
47
  export interface GetDoneWhenPendingReviewResponse { criteria: unknown; count: unknown }
44
48
  export interface GetGithubRepoResponse { ok: boolean; repo: unknown; private_deployable: boolean }
@@ -162,6 +166,8 @@ export interface PatchMeSkillPrefsResponse { prefs: unknown; effective: unknown;
162
166
  export interface PatchMeSoundPrefsResponse { prefs: unknown; effective: unknown; effective_map: unknown; volume: unknown; defaults: unknown; known_sounds: unknown }
163
167
  export interface PatchProjectsIdFeaturedRequest { featured: boolean }
164
168
  export interface PatchProjectsIdFeaturedResponse { project: unknown }
169
+ export interface PatchProvisioningInstancesIdDetailRequest { description?: string; detail?: Record<string, unknown> }
170
+ export interface PatchProvisioningInstancesIdDetailResponse { ok: boolean; description: unknown; detail: unknown; options: unknown }
165
171
  export interface PatchProvisioningInstancesIdSettingsRequest { platform_visibility?: string; joinability?: string }
166
172
  export interface PatchProvisioningInstancesIdSettingsResponse { ok: boolean; saved: unknown; push: unknown }
167
173
  export interface PatchTaskRecommendationsIdRequest { status: "declined" | "withdrawn" }
@@ -216,6 +222,9 @@ export interface PostClaimsIdReleaseOnBehalfRequest { reason: string }
216
222
  export interface PostClaimsIdResolveRequest { outcome: "shipped" | "abandoned" | "partial"; notes_md?: string; value_summary?: string; verification?: Record<string, unknown>; force?: boolean; committed_files?: string[]; sandbox_review?: Record<string, unknown> }
217
223
  export interface PostClaimsRequest { task_id: StringifiableId; worktree_name?: string; head_sha?: string; bind_session?: boolean; enforce_touches?: boolean }
218
224
  export interface PostClaimsResponse { claim: unknown; task: unknown; just_unlocked: unknown; overlap_warning: unknown; reward: unknown }
225
+ export interface PostConnectionsIdAcceptResponse { status: unknown }
226
+ export interface PostConnectionsRequest { handle: string; note?: string }
227
+ export interface PostConnectionsResponse { status: unknown }
219
228
  export interface PostCopyDeskFlagsIdCloseRequest { status: string; resolution_note: string; resolved_task_id?: number }
220
229
  export interface PostCopyDeskFlagsIdCloseResponse { ok: boolean; flag: unknown }
221
230
  export interface PostCopyDeskFlagsRequest { scope?: string; surface: string; string_id?: string; reason: string; kind?: string }
@@ -310,7 +319,7 @@ export interface PostProvisioningInstancesIdGithubAppRequest { account?: string
310
319
  export interface PostProvisioningInstancesIdGithubAppResponse { state: unknown; account: unknown; post_url: unknown; personal_url: unknown; org_url: unknown; org_login: unknown; manifest: unknown; redirect_url: unknown; expires_at: unknown }
311
320
  export interface PostProvisioningInstancesIdRestartResponse { ok: boolean; queued: unknown; action: unknown; status: unknown; message: unknown }
312
321
  export interface PostProvisioningInstancesIdTeardownResponse { ok: boolean; queued: unknown; action: unknown; status: unknown; superseded: unknown; message: unknown }
313
- export interface PostProvisioningInstancesRequest { slug: string; target_ref?: string; hosting_shape?: string; domain?: string; no_address?: boolean; tier?: string; onboard_mode?: string; type?: string }
322
+ export interface PostProvisioningInstancesRequest { slug: string; target_ref?: string; hosting_shape?: string; domain?: string; no_address?: boolean; tier?: string; onboard_mode?: string; type?: string; description?: string; detail?: Record<string, unknown> }
314
323
  export interface PostSearchMarkRequest { chunk_id?: StringifiableId; useful?: unknown }
315
324
  export interface PostSearchRequest { expand?: unknown; q?: string; source_kind?: string; limit?: unknown }
316
325
  export interface PostSearchResponse { expand?: unknown; chunk?: unknown; query?: unknown; count?: unknown; total_full_tokens?: unknown; results?: unknown; corpus?: unknown }
@@ -562,6 +571,22 @@ export interface BongosClient {
562
571
  /** GET /closeness/with/{builderId} — rank: any-builder */
563
572
  getClosenessWithBuilderId(args?: RequestArgs): Promise<GetClosenessWithBuilderIdResponse>;
564
573
  };
574
+ "connections": {
575
+ /** GET /connections — rank: any-builder */
576
+ getConnections(args?: RequestArgs): Promise<GetConnectionsResponse>;
577
+ /** POST /connections — rank: any-builder */
578
+ postConnections(args: RequestArgs & { body: PostConnectionsRequest }): Promise<PostConnectionsResponse>;
579
+ /** DELETE /connections/{id} — rank: any-builder */
580
+ deleteConnectionsId(args?: RequestArgs): Promise<DeleteConnectionsIdResponse>;
581
+ /** POST /connections/{id}/accept — rank: any-builder */
582
+ postConnectionsIdAccept(args?: RequestArgs): Promise<PostConnectionsIdAcceptResponse>;
583
+ /** POST /connections/{id}/decline — rank: any-builder */
584
+ postConnectionsIdDecline(args?: RequestArgs): Promise<ApiResponse>;
585
+ /** GET /connections/degree/{handle} — rank: any-builder */
586
+ getConnectionsDegreeHandle(args?: RequestArgs): Promise<GetConnectionsDegreeHandleResponse>;
587
+ /** GET /connections/requests — rank: any-builder */
588
+ getConnectionsRequests(args?: RequestArgs): Promise<GetConnectionsRequestsResponse>;
589
+ };
565
590
  "copyDesk": {
566
591
  /** POST /copy-desk/flags — rank: any-builder */
567
592
  postCopyDeskFlags(args: RequestArgs & { body: PostCopyDeskFlagsRequest }): Promise<PostCopyDeskFlagsResponse>;
@@ -923,6 +948,8 @@ export interface BongosClient {
923
948
  postProvisioningInstances(args: RequestArgs & { body: PostProvisioningInstancesRequest }): Promise<ApiResponse>;
924
949
  /** GET /provisioning/instances/{id} — rank: any-builder */
925
950
  getProvisioningInstancesId(args?: RequestArgs): Promise<GetProvisioningInstancesIdResponse>;
951
+ /** PATCH /provisioning/instances/{id}/detail — rank: any-builder */
952
+ patchProvisioningInstancesIdDetail(args?: RequestArgs & { body?: PatchProvisioningInstancesIdDetailRequest }): Promise<PatchProvisioningInstancesIdDetailResponse>;
926
953
  /** POST /provisioning/instances/{id}/domain — rank: any-builder */
927
954
  postProvisioningInstancesIdDomain(args: RequestArgs & { body: PostProvisioningInstancesIdDomainRequest }): Promise<PostProvisioningInstancesIdDomainResponse>;
928
955
  /** POST /provisioning/instances/{id}/force-teardown — rank: metic+archon */
@@ -260,6 +260,22 @@ export function createClient(opts = {}) {
260
260
  // GET /closeness/with/{builderId} — rank: any-builder — GET /closeness/with/:builderId
261
261
  getClosenessWithBuilderId: (args) => request("GET", "/closeness/with/{builderId}", { hasBody: false }, args),
262
262
  },
263
+ "connections": {
264
+ // GET /connections — rank: any-builder — GET /connections
265
+ getConnections: (args) => request("GET", "/connections", { hasBody: false }, args),
266
+ // POST /connections — rank: any-builder — POST /connections
267
+ postConnections: (args) => request("POST", "/connections", { hasBody: true }, args),
268
+ // DELETE /connections/{id} — rank: any-builder — DELETE /connections/:id
269
+ deleteConnectionsId: (args) => request("DELETE", "/connections/{id}", { hasBody: false }, args),
270
+ // POST /connections/{id}/accept — rank: any-builder — POST /connections/:id/accept
271
+ postConnectionsIdAccept: (args) => request("POST", "/connections/{id}/accept", { hasBody: true }, args),
272
+ // POST /connections/{id}/decline — rank: any-builder — POST /connections/:id/decline
273
+ postConnectionsIdDecline: (args) => request("POST", "/connections/{id}/decline", { hasBody: true }, args),
274
+ // GET /connections/degree/{handle} — rank: any-builder — GET /connections/degree/:handle
275
+ getConnectionsDegreeHandle: (args) => request("GET", "/connections/degree/{handle}", { hasBody: false }, args),
276
+ // GET /connections/requests — rank: any-builder — GET /connections/requests
277
+ getConnectionsRequests: (args) => request("GET", "/connections/requests", { hasBody: false }, args),
278
+ },
263
279
  "copyDesk": {
264
280
  // POST /copy-desk/flags — rank: any-builder — POST /copy-desk/flags
265
281
  postCopyDeskFlags: (args) => request("POST", "/copy-desk/flags", { hasBody: true }, args),
@@ -621,6 +637,8 @@ export function createClient(opts = {}) {
621
637
  postProvisioningInstances: (args) => request("POST", "/provisioning/instances", { hasBody: true }, args),
622
638
  // GET /provisioning/instances/{id} — rank: any-builder — GET /provisioning/instances/:id
623
639
  getProvisioningInstancesId: (args) => request("GET", "/provisioning/instances/{id}", { hasBody: false }, args),
640
+ // PATCH /provisioning/instances/{id}/detail — rank: any-builder — PATCH /provisioning/instances/:id/detail
641
+ patchProvisioningInstancesIdDetail: (args) => request("PATCH", "/provisioning/instances/{id}/detail", { hasBody: true }, args),
624
642
  // POST /provisioning/instances/{id}/domain — rank: any-builder — POST /provisioning/instances/:id/domain
625
643
  postProvisioningInstancesIdDomain: (args) => request("POST", "/provisioning/instances/{id}/domain", { hasBody: true }, args),
626
644
  // POST /provisioning/instances/{id}/force-teardown — rank: metic+archon — POST /provisioning/instances/:id/force-teardown
@@ -0,0 +1,90 @@
1
+ # ADR 0207 — Building the connection graph: three departures from ADR 0202, and the three limits it is honest about
2
+
3
+ - **Status:** accepted
4
+ - **Date:** 2026-08-29
5
+ - **Task:** [task 1002295](https://cloudbongos.com/builders#/task/1002295) (goal 1000045 — Public builder profiles, criterion C6)
6
+ - **Amends:** [ADR 0202](<redacted>.md) (the design of record — this ADR does not replace any decision there, it records where the build had to choose and what the build cannot promise)
7
+ - **Relates to:** ADR 0016 (a connection is never authority), ADR 0171 D4 (the leak-by-subtraction ban), ADR 0182 (a predicate written once as JS and as a SQL fragment), ADR 0083 §5 (module-owned, namespaced migrations), ADR 0206 (the own-scope guard the connection surface joins)
8
+
9
+ > ADR **0205** and **0206** were taken by sibling sessions in flight; this ADR takes 0207 rather than race them (the ADR 0199 / 0202 precedent).
10
+
11
+ ## Context
12
+
13
+ ADR 0202 decided the connection graph completely enough to build from, which is what a design ADR should do. Building it surfaced three places where the decision's *mechanism* could not be implemented as literally written without losing the *property* the decision was actually about, and three places where the shipped code cannot deliver as much as a casual reading of 0202 would suggest.
14
+
15
+ Both kinds are worth writing down. A departure nobody records reads later as a bug. A limit nobody records reads later as a guarantee — and this feature's limits are all privacy-shaped, which is exactly the category where a false guarantee is expensive.
16
+
17
+ None of this changes what 0202 decided. Every decision below is downstream of one of its four.
18
+
19
+ ## Decision
20
+
21
+ ### D1 — Level-wise CTEs, not `WITH RECURSIVE`
22
+
23
+ ADR 0202 D3 says "a recursive CTE seeded at the viewer, hard-capped at depth 3 with a per-level fan-out cap and a deterministic frontier order". **We implement the semantics and not the mechanism**, because a recursive term in PostgreSQL cannot carry either of the two properties D3 was actually deciding: you cannot apply a `LIMIT` to a single level of a recursive term, and you cannot order a level before truncating it. A recursive implementation would therefore have had a *depth* bound and no *fan-out* bound — the half of D3 that matters for a hub-heavy account.
24
+
25
+ `degreeBetween` instead runs level-wise CTEs, staged exactly as D3 describes the three shapes: one query answers 1st and 2nd (and yields the mutual list free from the intersection), and a second runs only when the first found nothing. Each level is `ORDER BY gid LIMIT $n` with the cap bound as a parameter, so a truncation is reproducible rather than a different answer each call — which was D3's stated reason for a deterministic frontier order.
26
+
27
+ **Consequence for readers:** `tests/connections_api.mjs` asserts `RECURSIVE` appears in neither query. That is not a style rule; it is the check that nobody "fixes" this back toward the ADR's wording and loses the per-level cap.
28
+
29
+ ### D2 — 1st degree is an unbounded pair-index probe, never a scan of the capped frontier
30
+
31
+ D3's own words: "1st = one index hit on the accepted-pair index." The first implementation read `is_first` off the already-materialised `v1` frontier instead, because it was there — which is wrong the moment a builder passes `FANOUT_1` connections: a target whose `github_id` sorts past the 500th smallest would be reported **2nd** while simultaneously appearing in that viewer's own connections list.
32
+
33
+ The fix is to keep 1st degree on `platform_identity_connections_pair_accepted`, unbounded, exactly as decided. **A cap may only ever make an answer *less* specific (`null`), never *wrong*.** That sentence is the rule this decision exists to state, and it is why the dedicated partial index earns its write cost.
34
+
35
+ ### D3 — The shared visibility predicate lands in its own file, and task 1002967 adopts it
36
+
37
+ ADR 0202 D4 requires connections to consume "the single effective-visibility reader task 1002967 establishes… published in both forms the ADR 0182 precedent requires". At build time 1002967 had not shipped and `platform-identity.js` was at the 1500-line fitness ratchet with zero headroom, so there was nowhere to put it inside the file the ADR imagined.
38
+
39
+ The definition therefore lands alone in **`modules/platform-identity/account-visibility.js`** — `isAccountVisible(account)`, `accountVisibleSql(alias)`, and `accountActiveSql(alias)` (the consent-only floor the own-scope reads use). **Task 1002967 must ADOPT this file rather than write a second predicate**; a second one is the precise drift D4 exists to prevent, and this ADR is the pointer that keeps that from happening by accident.
40
+
41
+ **Rejected.** *Inlining the predicate in `connections.js`* — it is the drift, one release early. *Growing `platform-identity.js`* — the ratchet is shrink-only and the file is a different concern anyway.
42
+
43
+ ### D4 — Node visibility during traversal is viewer-relative, and asymmetric on purpose
44
+
45
+ 0202 says two things that only reconcile one way. D4 §2: a private account's connections "appear in its own-scope list and in the counterparty's own-scope list". The ADR's title: every degree label is computed "over the graph the viewer is allowed to see". So:
46
+
47
+ - **The viewer's OWN 1-hop floors on `profile_state = 'active'` alone.** Your own connections are yours to see whatever their visibility — otherwise a friend who goes private silently disappears from your own list, which §2 forbids.
48
+ - **Every DEEPER hop clears the full public predicate** (`accountVisibleSql`). A bridge you may not see yields `null`, never "3rd".
49
+
50
+ The asymmetry is the model, not an inconsistency: the first hop is a relationship you are party to, and the rest is other people's.
51
+
52
+ ### D5 — Cancel is a `DELETE`, and an addressee may not use it
53
+
54
+ 0202 names `cancelled` in the lifecycle but gives it no verb, and the task title does not list one. `DELETE /connections/:id` does both jobs, keyed on the row's status: hard-delete an `accepted` row (either party may end it) or `cancelled` a `pending` one (**the requester only**). An addressee calling `DELETE` on a pending row gets 404 and must use `/decline` — otherwise the 90-day cooldown is dodged by calling a decline a delete, which would quietly undo D2's whole point.
55
+
56
+ ### D6 — The create response is `201 { status: 'pending' }` and nothing else
57
+
58
+ D2 §2 requires a swallowed re-ask to return "the same body it returns for a request that was genuinely created". The cheapest way to guarantee that for all time is for the body to contain nothing that a real create has and a swallowed one could not fabricate — no id, no timestamps. Clients read the new row from `GET /connections/requests`.
59
+
60
+ ## The three limits, stated rather than implied
61
+
62
+ ### L1 — A swallowed re-ask is silent in the response and observable in the outbox
63
+
64
+ Nothing is written, so the request never appears in the requester's own outbox, while a genuine one does within milliseconds. The **response** discloses nothing (D2 §2 holds); the **absence afterwards** is observable by anyone who looks.
65
+
66
+ We accept this, because ADR 0202's own Consequences already do — *"a builder can send the same request twice a year and never learn why nothing happens"* — and the alternative is worse: writing a decoy row would put a lie in the database to protect a lie in the API, and a decoy is data neither party consented to. **What changes here is only the claim**: the code comments and this ADR say "the response is silent", not "the system is silent".
67
+
68
+ ### L2 — The target's 1-hop is capped too, and that truncation is deliberately not reported
69
+
70
+ `truncated` is derived from the **viewer's** counts only. If the *target* has more than `FANOUT_1` connections, a genuine mutual can fall outside the intersection and downgrade a real 2nd degree to `null` while `truncated` still reads `false`.
71
+
72
+ Reporting it would leak one bit about a third party's degree ("this account has ≥500 connections") on a read that is otherwise entirely about the viewer. **An occasional `null` is a better trade than a reliable side channel**, and `null` already means "out of network", which is what the UI must render anyway. D2 above removes the case that actually mattered (a 1st-degree connection mislabelled); what remains degrades in the safe direction.
73
+
74
+ ### L3 — The query count is bounded; the frontier's work is not bounded by `FANOUT_2`
75
+
76
+ `degreeBetween` issues at most two queries whatever the graph does, and the *output* of each level is capped — both are asserted against the SQL that really ran. But `ORDER BY … LIMIT` must materialise and sort the frontier before it truncates, so the 2-hop expansion's **work** is O(Σ degree over the viewer's 1-hop), not O(`FANOUT_2`).
77
+
78
+ ADR 0202 D3's envelope ("the cap converts that from a slow query into a *missing* 3rd-degree label") is therefore true of the response and not of the cost. At the hub's present scale — order 10² accounts — the difference is unobservable. **The revisit trigger is a measured p95 on this endpoint, not a guess**; the cheap next move if it bites is a `statement_timeout` on the far query that returns `degree: null, truncated: true`, which is the degradation D3 already chose.
79
+
80
+ ## Consequences
81
+
82
+ - ADR 0202 remains the design of record. This ADR is where a reader finds out why the code does not contain `WITH RECURSIVE`, why `account-visibility.js` exists as its own file, and which of the feature's silences are real.
83
+ - Three rules are now enforced by tests rather than by memory: a cap may not make an answer wrong (D2), the own-scope list floors on consent and not on the public predicate (D4), and a decline may not be resurrected from an expired request (D5's sibling — the expiry guard `acceptConnection` always had and `declineConnection` initially lacked).
84
+ - The connection surface joins `tests/own_scope_guard.mjs`'s inventory (ADR 0206's guard), so a future edit that lets a route take its subject from the request fails in CI rather than in review.
85
+ - `neighbours(githubId)` and `mutualCount(viewer, target)` — named in ADR 0202's "what this unblocks" — are **not** exported yet. The repo's knip ratchet counts an export with no caller as dead code, so they land with their first consumer ([task 1002309](https://cloudbongos.com/builders#/task/1002309)); the one-hop definition they must reuse is `neighboursSql` in `connections.js`, and re-deriving a direction-aware `WHERE` instead is the drift that file's header forbids.
86
+
87
+ ## Deferred (named, not silently dropped)
88
+
89
+ - **A per-builder cap on outbound requests.** The 90-day cooldown stops repeat asks to the *same* person; nothing stops one builder asking a hundred different people, and only the global write limiter is in the way. A real number is an owner-signed value on the same privacy matrix ADR 0202 D2 came from, so it is named here rather than invented by a builder.
90
+ - **A real-Postgres pass on the write path.** CI has no Postgres, so the shipped proofs are DB-free by necessity. The statement most worth executing once against a live PG 15 is `requestConnection`'s expire-then-insert: it depends on the `guard` CTE forcing the modifying CTE to drain before the INSERT's unique check, and on the just-expired tuple reading as dead to that check. Both were reasoned through and reviewed; neither has been observed.
@@ -0,0 +1,79 @@
1
+ # 0209 — Two routes answering one question spend one budget; whether the answer itself collapses is the owner's call
2
+
3
+ - **Status:** Accepted (the ceiling). The response-collapse half is **Deferred — owner decision**, filed as a blocker against the task.
4
+ - **Date:** 2026-08-29
5
+ - **Deciders:** Claude, building the throttle half of the account-existence task. The task body named the collapse question as an owner/Archon decision and it is left as one.
6
+ - **Task:** task 1003339 (goal 1000045 — public builder profiles + account security, criterion `i-control-my-own-access-and-exposure`)
7
+ - **Interacts with:** [ADR 0016](<redacted>.md) (the trust boundary — these two routes are deliberately outside it), [ADR 0083](<redacted>.md) (the published doorway a module reaches core through), [ADR 0091](<redacted>.md) §1 (what belongs in the bounded kernel), [ADR 0116](<redacted>.md) (the 429's envelope), [ADR 0194](<redacted>.md) (the membership door these routes report on), [ADR 0206](<redacted>.md) (the sibling account-security decision in this goal)
8
+
9
+ ## Context
10
+
11
+ Two PUBLIC routes answer the same question — *does this GitHub login have an account here?*
12
+
13
+ - `GET /access-requests/status?github_login=X` (`modules/onboarding/routes/access-requests.js`) returns `member | invited | pending | dismissed | none` plus `admitted`.
14
+ - `GET /auth/web/admission-status?login=X` (`src/bongos/routes/auth.js`) returns a bare `{ admitted }`. Strictly weaker; strictly derivable from the first.
15
+
16
+ Neither was throttled, and the reason is structural rather than an oversight anyone could see locally: `rateLimitMiddleware` returns early for every non-write method, so the global limiter meters writes and nothing else. The one read limiter that exists (`publicReadRateLimitMiddleware`) is mounted only under `/public/*`. Neither of these routes is under that prefix, so both were reachable at line rate by anyone.
17
+
18
+ Scripted against a list of GitHub usernames, either route becomes a login→membership roster: who builds here, and confirmation that a specific named person is present. That is targeting information, and it is exactly what you would want before a phishing attempt aimed at a named builder. The `auth.js` comment had already stated the exposure plainly ("It is also NOT throttled") and deferred the remedy, correctly, on the grounds that hardening one route while its richer twin stayed open would accomplish nothing.
19
+
20
+ Two separable questions sit inside that, and conflating them is what kept the task open:
21
+
22
+ 1. **Is unauthenticated probing throttled?** A ceiling is right under every possible answer to (2) — an enumerator is unwelcome whether or not the reply is detailed.
23
+ 2. **Should the reply itself collapse**, so a caller who is not the subject cannot tell `pending` / `dismissed` / `none` apart from a bare "not admitted"? That changes what a real applicant sees during onboarding.
24
+
25
+ ## Decision
26
+
27
+ **Ship (1) now, as ONE budget. Do not decide (2) here — file it.**
28
+
29
+ ### The ceiling is one budget, not two identical ones
30
+
31
+ `accountExistenceReadRateLimit` joins `src/bongos/middleware/rate-limit.js` as limiter #4: 120 reads / 60s per IP over a single bucket map, keyed on `req.ip` (the same real-client-IP resolution the write limiter uses). It is exported as **one middleware instance, not a factory**, and the same instance is mounted on both routes.
32
+
33
+ That is the load-bearing part of this ADR. Two limiters configured `120/60s` each are indistinguishable from one shared limiter in a diff, in a code review, and in any test that only counts to the limit on each route separately — and they hand an enumerator 240/min, because alternating between the twins costs nothing. The routes are one oracle read two ways, so they get one budget. `tests/account_existence_rate_limit.mjs` proves the sharing behaviourally in both directions *and* by object identity, and the mutation that replaces the shared instance with per-route copies leaves the counting tests green while the sharing tests go red — which is precisely the regression worth a test.
34
+
35
+ 120/min is the `/public/*` posture, and it clears the honest callers with wide margin: `renderAccessPendingPage` polls the boolean probe every 3s (20/min) and `bongos login` polls the rich one every 5s (12/min).
36
+
37
+ ### The module reaches it through the doorway, and rate-limit.js becomes kernel
38
+
39
+ The onboarding module may require exactly one core file — the published doorway (ADR 0083). So `src/module-api.js` exports the shared instance, and `src/bongos/middleware/rate-limit.js` joins `KERNEL_FILES` in `fitness.js`. The argument is `logger.js`'s, verbatim: the abuse ceiling is a trust primitive in the same sense the authz gates are — **authz decides who may ask, this decides how often**, and an unauthenticated read route has nothing else holding it. The file requires nothing at all, so joining the roster costs no boundary and buys the import-direction guarantee on a file two surfaces now trust.
40
+
41
+ The alternative — the module vendoring its own copy, as `modules/platform-identity/routes/public-profile.js` had to before there was a doorway export — is the failure this export exists to prevent. It is also how you get two budgets by accident.
42
+
43
+ ### The response collapse is deferred, with the evidence gathered
44
+
45
+ Not deferred vaguely. The consumer sweep was run first, and it is short enough to state in full:
46
+
47
+ - **Route 1 has exactly one consumer in the repo**: `scripts/gds/login.js`, the `bongos login` CLI. Its `pollDecision()` maps `member`/`invited` → admitted, `dismissed` → a hard stop, and **everything else → wait**. So `pending` and `none` are *already* indistinguishable to the only thing that reads them.
48
+ - **Route 2's only consumer reads `admitted` and nothing else** — the inline poller in `renderAccessPendingPage`. The pending-vs-stranger distinction that page needs comes from `webAdmissionStatus`'s `expectedSoon`, computed server-side from a direct query, never from route 1.
49
+ - **The generated typed clients define wrappers for both routes with zero call sites.**
50
+ - Therefore collapsing costs **exactly one thing**: `login.js` is the only place in the entire codebase that tells a declined applicant they were declined. Collapse the answer and that branch is unreachable — a dismissed applicant's CLI prints dots for thirty minutes and then emits an actively wrong instruction ("re-run `bongos login` once an Archon has approved you") for an approval that is never coming.
51
+
52
+ That is a real, if narrow, product regression, and it lands on a person at their worst moment with this project. It is a judgement about what an applicant is owed, not an engineering detail, so it goes to the owner rather than being settled by whoever happened to hold the task. The blocker carries this evidence and a recommendation.
53
+
54
+ One thing the collapse would **not** buy, and the blocker says so: it does not close the oracle. `admitted: true` still means "member or invited" on both routes. Collapsing `pending`/`dismissed`/`none` narrows what leaks about *applicants*; only removing the routes or putting them behind an authenticated identity would close membership enumeration itself, and neither is possible while a stranger with no account has to poll them.
55
+
56
+ ### Rejected
57
+
58
+ - **A separate limiter per route, both 120/60s.** Looks identical in review, hands an enumerator double the rate, and is the exact regression the sharing tests exist to catch.
59
+ - **A `createReadRateLimit({ scope, limit, windowMs })` factory on the doorway.** More general and more wrong: two call sites with matching config get two buckets. The generality is what loses the property.
60
+ - **Vendoring the `public-profile.js` sliding window into the onboarding module.** A fourth copy, and copies drift. That file's own comment records that it vendored only because the core limiter was not on the doorway — this ADR removes that reason.
61
+ - **Hardening only `admission-status`** (the smaller diff). Accomplishes nothing while its richer twin stays open; the `auth.js` comment said so before this task existed.
62
+ - **Refactoring `publicReadRateLimitMiddleware` onto the new shared gate.** A live limiter that is not broken, on a surface this task does not own.
63
+ - **Collapsing the response on my own judgement because "nothing much" reads it.** "Nothing much" is not "nothing" — `dismissed` has one real consumer with real user-visible copy, and the task named this an owner decision.
64
+ - **Deciding it the other way — annotating both routes "acceptable for this community model" and closing.** Equally the owner's call, and equally not mine.
65
+
66
+ ## Consequences
67
+
68
+ - Both account-existence probes now refuse past 120/min from one address, with the ADR 0116 envelope, `Retry-After`, and `X-RateLimit-Scope: account-existence` — the scope name is deliberately the *question*, not the route, so a caller can see the two share a budget.
69
+ - The limiter runs ahead of the lookups, so a refused probe issues no query. The oracle is not also a free amplifier.
70
+ - The doorway gains one export (a MINOR addition; `tests/module_api.mjs` records it). `src/bongos/middleware/rate-limit.js` is now held to the kernel import rule — it currently imports nothing, and must keep importing nothing but the kernel.
71
+ - Behind a shared NAT, ~6 simultaneous sign-ins polling at 3s would reach the ceiling. The refusal is a 429 with `Retry-After` and both pollers already retry, so this degrades to a slower poll rather than a failed sign-in — but it is the number to raise first if a real instance reports slow admissions.
72
+ - A leaked or exhausted budget is per-IP and in-memory: a process restart forgets it, and one abusive address cannot close the door on anybody else. Both are asserted.
73
+
74
+ ## When to revisit
75
+
76
+ - **The owner answers the collapse question** (the blocker). If the answer is "collapse it", `login.js`'s declined branch needs a replacement — the honest option is a timeout message that stops promising an approval is coming.
77
+ - **A real instance reports admission polling hitting the ceiling** behind a shared address. Raise the limit; do not split the budget.
78
+ - **A third surface starts answering the same question.** It mounts this same instance. A new bucket for it would re-open exactly the hole this closed.
79
+ - **The routes stop needing to be public.** If a would-be builder ever arrives already holding an identity, the whole oracle can go behind `requireBuilder` and the ceiling becomes belt-and-braces.
@@ -294,5 +294,7 @@ This keeps the decision history honest and traceable.
294
294
  | 0204 | [**The apex is dark only; light is the builders' hall's** ([task 1003336](https://cloudbongos.com/builders#/task/1003336), goal 1000080). Supersedes in part [ADR 0196](<redacted>.md) (the light-mode half of the landing build) and the landing direction's 2026-08-27 pick of "light rebuilt to the dark standard". The owner, reviewing the planets: "only black or only white is a bit bland… leaning towards only dark mode for this page and keep the dark/light mode to be solely for the builder hall", confirmed as the WHOLE apex. Decision: `/`, `/projects`, `/privacy`, `/terms` and `/contact` pin `data-mode="dark"` on `<html>` — no pre-paint script, no `cb-color-mode`, no `?mode=`, no OS-preference read; the moon/sun glyph and its wiring leave both bars; the hall's own `hall-theme` switch is the platform's only light/dark control. The light tier of `world.css` / `cosmos.css` and the dark-twins rule STAY until the tier's own removal (the light-pair contrast tests keep measuring). The kit learns `"modes": ["dark"]` in a states file (validated; `render.js` takes it as the default). Rejected: a dark default with the toggle kept; deleting the light tier in the same diff; `--modes dark` on every kit call instead of the page's contract.](<redacted>.md) | apex / design system |
295
295
  | 0205 | [**A federated check-in may not speak for a person the hub never saw sign in: the membership carries the hub's own witness, and identity is never re-synced from client credentials** ([task 1003340](https://cloudbongos.com/builders#/task/1003340), goal 1000045 — closes security report 1000027, high, LIVE on the hub). `POST /sso/membership/check-in` authenticated the CALLER (`client_id` + `client_secret`) and took the SUBJECT (`github_id`, `github_login`, `avatar_url`) from the request body — client credentials say which PROJECT is calling, never who the person is. So any registered SSO client could (1) rewrite a real person's public `github_login` and `avatar_url`, which `upsertAccount`'s `ON CONFLICT` re-synced unconditionally and stored with no scheme check, and (2) mint the `<redacted>` row that [ADR 0171](<redacted>.md) D7's `membership_required` precheck reads — so the anti-impersonation gate on `POST /sso/activity/rollup` was satisfiable by the very call it was meant to gate, and forged credits/karma/rank reached a public profile. Decisions: (1) migration `<redacted>` adds `signin_verified_at`, written ONLY by `recordFederatedSignin` from the two moments the HUB itself authenticated a human for a client — `/sso/token` (a code minted under a live hub session, bound to the pair) and `/sso/device/poll` (a GitHub approval, audience bound from the typed origin) — so no request-body field reaches the statement and the column is evidence a caller structurally cannot write; (2) `upsertMembership` becomes `touchMembership`, an UPDATE gated on that column — a check-in can no longer CREATE a membership, answering `409 signin_required` and writing nothing, while signing in accepts a standing `pending` invite and leaves any other coarse kind to the instance to report; (3) a federated client gets insert-only `ensureAccount` (`DO NOTHING`), and `upsertAccount`'s identity re-sync belongs to hub-authenticated callers only — nothing is lost, because both of those already re-sync on every sign-in from GitHub's own answer; (4) the rollup gate reads `membershipBacksRollup` (non-pending AND witnessed), not row-existence; (5) `safeAvatarUrl` admits absolute https only, applied inside both account writers rather than at each render; (6) NO backfill — no predicate separates a legitimate check-in row from a forged one, and the first sign-in after deploy heals a real membership because the witness is stamped BEFORE the check-in and rollup that follow it, so a profile goes stale, never blank. No wire-format change: the request schemas are byte-identical and the ADR 0177 `membership_kind` window is untouched, so no federated instance upgrades. Proof `tests/sso_checkin_identity_spoof.mjs` (DB-free, in the default unit gate; 10 of 14 assertions fail against the pre-fix code, 2 controls prove the gate is not a wall). Rejected: carrying the hub assertion in the check-in body (strict validation would 400 every un-upgraded instance); ADR 0156-style source-guarding (answers "did the user set this", the wrong question when the writer is a third party); backfilling `signin_verified_at = joined_at`; a silent 200; retro-filtering `listMembershipsForAccount`.](<redacted>.md) | platform identity / security |
296
296
  | 0206 | [**A leaked bearer's remedy is the account holder's own revoke, not an absolute session ceiling** ([task 1003341](https://cloudbongos.com/builders#/task/1003341), goal 1000045 — criterion `i-control-my-own-access-and-exposure`). `lookupSession` rewrites `expires_at = now() + 24h` on every authenticated request, so a CLI bearer in daily use never expires; if it leaks, the holder keeps it alive simply by using it. `POST /auth/logout` kills only the token presented on that same request — useless when the token you want dead is the one you no longer hold — and the two endpoints that can kill another session are gated on the admin `auth.session.revoke` atom, so the victim had to find an Archon, and a single-owner self-hosted instance may have none. Decision: ship the OWN-scoped remedy (`GET /me/sessions`, `POST /me/sessions/revoke`, `POST /me/sessions/revoke-all` on `requireBuilder` alone, every statement keyed on the caller's own id from the presented session, the token value never selected or returned, `revoke-all` deliberately including the requesting session) and deliberately NOT an absolute ceiling on the sliding refresh: a ceiling is unconditional (migration 035 already had to backfill a 24h grace so it would not kill the session running it), any cap loose enough not to be a daily nuisance leaves a leaked token live for weeks, and the visibility this task ships — `created_at` beside `last_used_at` in the list — is the compounding half of the fix. A demotion already sweeps sessions, and a deactivated builder is denied on the hot path, so the authority-drift case is closed by other means. Rejected: a `created_at + N` cap now; a shorter TTL; three more routes inside the already-largest `routes/auth.js` (a separate file is what lets the no-caller-named-owner rule be asserted over a WHOLE file); a `keep_current` flag; 404 on a session id that is not yours (it would confirm which ids exist); lowering the `auth.session.revoke` floor. Revisit for machine principals, a compliance maximum, or evidence that people do not prune what the list shows them.](<redacted>.md) | auth / account security |
297
+ | 0209 | [**Two routes answering one question spend one budget; whether the answer itself collapses is the owner's call** ([task 1003339](https://cloudbongos.com/builders#/task/1003339), goal 1000045 — criterion `i-control-my-own-access-and-exposure`). `GET /access-requests/status` (public, returns `member|invited|pending|dismissed|none`) and `GET /auth/web/admission-status` (its bare-boolean twin) both answer "does this GitHub login have an account here?", and neither was throttled — `rateLimitMiddleware` returns early for every non-write method, and the one read limiter is mounted only under `/public/*`. Scripted at line rate either becomes a login→membership roster: who builds here, and confirmation that a named person is present. Decision: ship the ceiling as ONE budget — `accountExistenceReadRateLimit`, 120 reads/60s per IP over a single bucket, exported as one middleware INSTANCE rather than a factory and mounted on both routes, because two limiters each configured 120/60s look identical in a diff, pass any test that counts to the limit per route, and hand an enumerator 240/min by alternating. The onboarding module reaches it through the doorway, so `src/bongos/middleware/rate-limit.js` joins `KERNEL_FILES` on `logger.js`'s argument: authz decides WHO may ask, the ceiling decides HOW OFTEN, and the file imports nothing. Deliberately NOT decided here: collapsing the response so a non-subject caller cannot tell `pending`/`dismissed`/`none` from "not admitted" — the consumer sweep found route 1 has exactly ONE consumer (`scripts/gds/login.js`), which already treats `pending` and `none` identically, so collapsing costs exactly one thing: the only message in the codebase that tells a declined applicant they were declined. That is a judgement about what an applicant is owed, so it goes to the owner as a blocker with the evidence. Noted either way: collapsing does not close the oracle — `admitted:true` still means member-or-invited. Rejected: a separate limiter per route; a `createReadRateLimit` factory (two matching call sites get two buckets — the generality loses the property); vendoring a fourth copy of the sliding window; hardening only the smaller route; collapsing on my own judgement because "nothing much" reads it.](<redacted>.md) | security / rate limiting |
298
+ | 0207 | [**Building the connection graph: three departures from ADR 0202, and the three limits it is honest about** ([task 1002295](https://cloudbongos.com/builders#/task/1002295), goal 1000045 criterion C6 — the build of the connections wave). ADR 0202 decided the graph; building it surfaced three places where the decision's MECHANISM could not be written literally without losing the PROPERTY it was about, and three places where the shipped code cannot promise as much as a casual reading suggests. Departures: (1) **level-wise CTEs, not `WITH RECURSIVE`** — a recursive term can carry neither a per-level fan-out cap nor an ordered per-level truncation, which are the two things D3 actually decided, so a recursive build would have had a depth bound and no fan-out bound; a test asserts `RECURSIVE` appears in neither query. (2) **1st degree is an unbounded pair-index probe**, never a scan of the capped frontier — reading it off the `FANOUT_1`-truncated 1-hop reports "2nd" for someone in the viewer's own connections list once they pass 500 connections, and the rule this states is that **a cap may only ever make an answer less specific (`null`), never wrong**. (3) The shared visibility predicate lands in its own file, `modules/platform-identity/account-visibility.js` (`isAccountVisible` / `accountVisibleSql` / `accountActiveSql`), because task 1002967 had not shipped and `platform-identity.js` sat at the size ratchet with zero headroom — **1002967 adopts it rather than writing a second predicate**. Also decided: node visibility during traversal is viewer-relative and asymmetric on purpose (your OWN 1-hop floors on consent alone so a friend who goes private does not vanish from your list; every deeper hop clears the public predicate); cancel is a `DELETE` an ADDRESSEE may not use, so the 90-day cooldown cannot be dodged by calling a decline a delete; and the create response is `201 { status: 'pending' }` and nothing else, so a swallowed re-ask has nothing to fabricate. Limits stated rather than implied: a swallowed re-ask is silent in the RESPONSE and observable by its ABSENCE from the requester's outbox (the claim is narrowed, not the code — a decoy row would be a lie in the database to protect a lie in the API); the TARGET's 1-hop is capped too and that truncation is deliberately NOT reported, because saying so leaks one bit about a third party's degree; and the query COUNT is bounded while the frontier's WORK is O(sum of degree over the 1-hop), since `ORDER BY … LIMIT` materialises before it truncates — the revisit trigger is a measured p95, and the cheap next move is a `statement_timeout` returning the `null` D3 already chose. Deferred and named: a per-builder cap on OUTBOUND requests (the cooldown stops repeat asks to one person, not one builder asking a hundred — a real number is owner-signed), and a real-Postgres pass on the expire-then-insert write path, which CI cannot run.](<redacted>.md) | platform identity / social graph / privacy |
297
299
 
298
300
  > ⚠️ **Numbering collisions are now machine-enforced, not narrated.** Twenty numbers are shared by two ADRs each — assigned in parallel sessions before anything checked. The files keep their filenames (renumbering would break every existing citation, and a number once assigned is never reused), and this table disambiguates each pair as `NNNN-a` / `NNNN-b`. The authoritative list is `LEGACY_DUPLICATE_ADRS` in [`scripts/gds/adr-namespace.js`](../../scripts/gds/adr-namespace.js), frozen by exact filename: a **new** collision — or a third file joining a legacy number, or a rename of either half — hard-fails the `unit` CI gate, as does an index row that points at no file or an ADR with no row. The list may only shrink. This paragraph used to narrate the collisions one by one and had fallen eight behind reality; see [ADR 0195](<redacted>.md) for why the check exists and why the pairs are grandfathered rather than renumbered.