@bongos/core 1.19.405 → 1.19.407

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.405",
6
- "core_contract": "1.19.405",
7
- "source_commit": "a596df349cfc88dad49b9e44d6c342d317391336",
5
+ "core_version": "1.19.407",
6
+ "core_contract": "1.19.407",
7
+ "source_commit": "4f99fa100586551f7e64c3c7da5eb41985addb6d",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-28T21:19:46.932Z",
9
+ "built_at": "2026-08-28T21:53:19.262Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 350,
12
+ "docs_redacted": 352,
13
13
  "agent_docs_stubbed": 23,
14
14
  "functional_verbatim": 1662,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2035,
20
- "tree_sha256": "8adb7d841a0dc4711adc4eede676acc7f0db88b4eb55def156e0255aa532ac9e",
19
+ "file_count": 2037,
20
+ "tree_sha256": "d2b32b944226fc840f9ae0c8bd3f96171d7497d30ab8030362644dccdeddf743",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -1504,10 +1504,20 @@
1504
1504
  "mode": "0000644",
1505
1505
  "sha256": "d73c7f4ebaf18bf81e461532144422b8d343698c50964687c52b13301af42c66"
1506
1506
  },
1507
+ {
1508
+ "path": "docs/adr/0201-cross-project-application-vouched-row-live-record.md",
1509
+ "mode": "0000644",
1510
+ "sha256": "88c0a614b2f317785e4bbe360a9aba686e4ef8f302ff75cb441763960c3b2f36"
1511
+ },
1512
+ {
1513
+ "path": "docs/adr/0202-connections-graph-and-bounded-degree.md",
1514
+ "mode": "0000644",
1515
+ "sha256": "cb5bb269e40262b769bfa626fbc6d4ddf09a328b00742f07894c006b9b3bd885"
1516
+ },
1507
1517
  {
1508
1518
  "path": "docs/adr/README.md",
1509
1519
  "mode": "0000644",
1510
- "sha256": "870abce88d6282c54c7818ba9d643639c866704a8f7e982afb0717c39c6dc9a0"
1520
+ "sha256": "6bd4f20377ad7bf4134be679a5875b0ef50784dc48a2937aefd6607a78bd403e"
1511
1521
  },
1512
1522
  {
1513
1523
  "path": "docs/api-reference.md",
@@ -5327,12 +5337,12 @@
5327
5337
  {
5328
5338
  "path": "package-lock.json",
5329
5339
  "mode": "0000644",
5330
- "sha256": "8de2a8652a9751ea30286de18865d8fd2cfd413216aca19f52547927be36d061"
5340
+ "sha256": "4d1211ec1bef3a0091ffea5e28b11d15f11577390649cd0c71fb2fb18b26ee38"
5331
5341
  },
5332
5342
  {
5333
5343
  "path": "package.json",
5334
5344
  "mode": "0000644",
5335
- "sha256": "48543bcccd5205d985d1cec7e084eaecad6d9e509bacc121d157bd191a8f07ca"
5345
+ "sha256": "c0d40b27137df6a0bc33514ac70c327e93ae0a73af52e01a54ea8955c61ff01e"
5336
5346
  },
5337
5347
  {
5338
5348
  "path": "public-docs/index.html",
@@ -7347,7 +7357,7 @@
7347
7357
  {
7348
7358
  "path": "src/module-api.js",
7349
7359
  "mode": "0000644",
7350
- "sha256": "c11f58f8290359b604dbe8ce5ccca727ad0d5561c3673b39403b822e5068668f"
7360
+ "sha256": "8b3448f5711cd7428dbd3abfdeea42d90048ac4d6eb2376849261dcf6f776b03"
7351
7361
  },
7352
7362
  {
7353
7363
  "path": "src/module-loader/catalog.js",
@@ -0,0 +1,112 @@
1
+ # ADR 0201 — An application is the project's own `access_requests` row, vouched for by a hub-signed assertion carried in a header; the record it carries is resolved live and never stored
2
+
3
+ - **Status:** accepted
4
+ - **Date:** 2026-08-28
5
+ - **Task:** [task 1002283](https://cloudbongos.com/builders#/task/1002283) (R07, goal 1000045 — public builder profiles; criteria C3/C4)
6
+ - **Relates to:** ADR 0141 (federated hub IdP — identity federated, authority local; §1 the signed assertion, §3 the join relay), ADR 0171 (the profile↔rollup join, `hide_stats`, `profile_state`, `no-store`), ADR 0194 (the joinability door the public POST answers first), ADR 0192 (a gated project's member door), ADR 0016 (the DB enforces), ADR 0116 (the error envelope), ADR 0157 (`access_request.review` is *identity* and stays archon), [`docs/specs/<redacted>.md`](../specs/<redacted>.md) D3/D4/D7 (spike 1002785), [`docs/specs/<redacted>.md`](../specs/<redacted>.md) D1/D2/D3 (spike 1002786)
7
+
8
+ ## Context
9
+
10
+ C3 says a builder browsing the orbs page applies to a project **through the existing join path**, and C4 says the application **carries the applicant's cross-project record** so the receiving project sees a track record, not a name — with the owner able to approve or decline. The goal's own hard constraint (ADR 0016 / 0141) is that those numbers are descriptive analytics, never authority.
11
+
12
+ Every piece already exists, and none of them are joined up:
13
+
14
+ - **The relay** ([`modules/platform-identity/routes/my-projects.js`](../../modules/platform-identity/routes/my-projects.js)) — `POST /my-projects/join { origin, note }` resolves the origin against the hub's own `sso_clients` registry (the SSRF guard: never the caller's raw string), then makes a **plain, unauthenticated** `POST ${client.origin}/api/gds/access-requests` carrying `{ github_login, display_name, note }`. `joinRelayOutcome` translates the answer into one of five hub-authored sentences. The applicant's note already rides it (task 1002330); the privacy spec's claim that the note is hardcoded is stale.
15
+ - **The row** (`migrations/060_access_requests.sql`, untouched since) — `pending → invited | dismissed`, terminal rows kept as the audit trail, one open row per login (a partial unique index on `lower(github_login)`).
16
+ - **The gate** — `hasInvitedAccessRequest` in [`src/bongos/db-kernel.js`](../../src/bongos/db-kernel.js) reads `status='invited'` directly, and `firstTimeAdmissionBlocked` is the single decision all four sign-in paths call. `invited` is not a queue label; it *is* the admission verdict.
17
+ - **The record** — `getPublicProfileExtras(githubId)` returns `null` unless `profile_state='active' AND NOT hide_stats`, and `GET /profiles/:handle` already serves it publicly, CORS-enabled (`corsPublicGet`), `Cache-Control: no-store` (ADR 0171 D5), rate-limited per IP.
18
+
19
+ Two facts found in this spike shape everything below.
20
+
21
+ **The relayed request is indistinguishable from a stranger's.** The target's `POST /access-requests` is the one public unauthenticated write on the GDS, and the relay presents nothing the applicant could not have typed into the project's own landing page. Attaching a cross-project record to a `github_login` that nobody vouched for turns the queue into an impersonation surface: anyone can file a request naming a well-known builder, the owner reads that builder's excellent record beside it, approves — and the person who actually signs in is whoever holds *that* GitHub account. The record is exactly the thing that makes the unauthenticated write dangerous, so C4 cannot be built without closing it.
22
+
23
+ **`validateOrRespond` is strict by default** ([`src/bongos/routes/_helpers.js`](../../src/bongos/routes/_helpers.js)) — an unknown body field is rejected `validation_failed` 400. The public POST validates strictly (`modules/onboarding/routes/access-requests.js`). A 400 is not one of `joinRelayOutcome`'s five cases, so it returns `null` and the hub answers "the project could not be reached" (502). Adding a body field to the relay would therefore break applications to **every project pinned to a core older than the change** — which, since projects pin their own core, is most of them, indefinitely.
24
+
25
+ ## Decision
26
+
27
+ ### D1 — The application is an `access_requests` row on the target project. No cross-project applications table, on either side.
28
+
29
+ 1. **Extend `access_requests`; do not add a table.** `status='invited'` is what `hasInvitedAccessRequest` reads, so a parallel record would need its own branch wired into `firstTimeAdmissionBlocked` — a second place where admission is decided, which is precisely the drift that decision exists as one function to prevent. It would also need its own queue, its own audit trail and its own reconciliation with the row the same person may already hold. The console wave (spike 1002786 D2) reached the same conclusion for invites; two waves inventing two tables for one door is the outcome to avoid.
30
+
31
+ 2. **The migration adds exactly what a vouch needs: `applicant_github_id text` and `vouched_at timestamptz`, both nullable.** `text`, not `bigint`, because core `builders.github_id` is `text` (`migrations/003_pms.sql`) and the hub's numeric cast is the module's own concern (ADR 0171 D2 already handles non-numeric principals there). `applicant_github_id IS NOT NULL` **is** the vouch mark — no `source` enum, because a second column would let the two disagree. The console wave's `kind` / `created_by` columns (spike 1002786 R2) are a **separate** additive migration; the two waves must not both add `kind`, and either order works.
32
+
33
+ 3. **Nothing about the application is stored on the hub except when it was filed.** A hub-held row saying a project has a pending application would be a hub-held admission fact — authority on the wrong side of the boundary, and stale the moment the owner acts. So the hub keeps an **echo** for one purpose only: so the applicant's own surfaces (My Projects, the orbs card) can say *"you applied on <date>"* without asking every project on every render. The echo carries `(github_id, client_id, applied_at)` and **never a status** — it is not allowed to claim what the project decided. An approval becomes visible the way membership always becomes visible: the check-in (ADR 0141 §3) creates the membership row and the card flips to *member*. The echo ages out of the applicant's view after **30 days**, the same read-time constant the privacy spec D4 chose, so a declined application does not read as "applied" forever.
34
+
35
+ 4. **Re-applying is not an error.** The partial unique index already makes one open row per login the invariant; the endpoint answers `requested` for an application that is already open rather than a 409, so a double-clicked Apply button never tells the applicant they failed. A dismissal does **not** bar the door — resolved rows do not occupy the index, and a project that wants a permanent bar has `invite_only` (ADR 0194).
36
+
37
+ ### D2 — The hub vouches for the applicant's identity in a **header**; the record itself is resolved **live**, in the reviewer's browser, and is never snapshotted.
38
+
39
+ 1. **The relay carries a hub-signed, audience-scoped assertion in `<redacted>`.** It reuses `mintHubAssertion` ([`modules/platform-identity/hub-keys.js`](../../modules/platform-identity/hub-keys.js)) with a `purpose: 'application'` marker — the discriminator `mintHubLogoutToken` already established, so a captured device-login assertion cannot be replayed as an application vouch or the reverse. The target verifies it **offline** with the existing `verifyHubAssertion` ([`src/bongos/auth-idp.js`](../../src/bongos/auth-idp.js)) against the `<PREFIX>_HUB_PUBLIC_KEY` every federated instance is already provisioned with (ADR 0141 §4): signature, issuer, `aud === this instance's client_id`, unexpired. No new secret, no new key, no instance→hub call at apply time.
40
+
41
+ 2. **A header, not a body field — and that is the whole compatibility story.** Per Context, a body field would 400 on every older pin and the hub would report the project unreachable. A header is invisible to the strict validator, so a project on any pin keeps taking applications unchanged, and a project that upgrades starts vouching with no coordination at all. (The CLI's `POST /auth/device/exchange` carries its assertion in the body; it could, because that route was new. This one is not.)
42
+
43
+ 3. **An absent or invalid assertion writes the row anyway — unvouched.** It is never a refusal. Refusing would lock out applicants from an older hub, and would hand an attacker a way to make applications fail by sending garbage. Unvouched is simply the row the project has always had: a name and a note, with no record beside it.
44
+
45
+ 4. **The record is never copied. The row stores an identity; the record is fetched at render.** C2 requires the hide to be honored *live everywhere, including pending applications* — the goal's own text says hiding after applying re-hides it on the owner's view. A stored snapshot is a copy the builder can no longer withdraw: honoring a later hide would mean a purge sweep across every instance that ever received one, and any instance missed is a permanent leak. Privacy spec D7 already decided "live at review time, never a stored snapshot"; this ADR ratifies it and adds the vouch (D2.1) that makes the live render *safe* rather than an impersonation surface.
46
+
47
+ 5. **The fetch is made by the reviewer's browser against the hub's already-public `GET /profiles/:handle`, not by the instance's server.** It is CORS-enabled, `no-store`, and `getPublicProfileExtras` applies `profile_state='active' AND NOT hide_stats` inside the module — so C2 is honored by construction, at the instant of render, with no cache to outlive a toggle. Doing it server-side instead would extend ADR 0141's always-on hub dependency from sign-in onto the console's own request path, and would need the client-authenticated read plus timeout/retry/cache that ADR 0171 D1 rejected for exactly this reason and named as a deliberate separate task. It would also meter the per-IP read limit against the *instance* rather than each reviewer. A hub outage degrades to "the platform record isn't available right now" beside a queue that still works — the degrade-don't-500 rule.
48
+
49
+ 6. **The vouch carries the `handle` as well as the `github_id`, and a handle-less account shows no record.** `GET /profiles/:handle` is addressed by handle; `github_id` is the stable join key but has no public read. The handle at apply time is stored on the row, and the previous-handle 301 window (ADR 0171 D8) covers a rename. An account with no handle yet, or a `provisional` one (404 by design, ADR 0171 D3), renders as an application with a note and no record — which is honest, not broken.
50
+
51
+ 7. **A private account's application carries name, handle and note, and nothing else — in V1.** The D3 recruiter sliver is gated on `project.recruit`, an *instance* permission the hub cannot check from a cross-origin browser read, and inventing a fourth authorization plane to carry it is not this goal's job. The apply UI states exactly what the application shares (privacy spec D7's rule). The sliver half is already sequenced behind the recruiter port — task 1002972 depends on task 1002968 — and lands there, not here.
52
+
53
+ ### D3 — "Project owner" is the project's own archon. There is no cross-project owner authority, and none is created.
54
+
55
+ 1. **The registrant is whoever the instance seated as its first Archon** (`maybeSeatFirstAdmin`) — for a platform-provisioned project, the founder who filled the create wizard. **Delegates are whoever that Archon grants on that instance**, through the governance UI that already exists: promote to archon, lower a permission's floor, or a custom role. Because the instance *is* the project (ADR 0108; console spec D1), per-project scoping dissolves — the instance is the scope, so the governance grant needs no resource column and the per-project-scoped-permission problem never arises.
56
+
57
+ 2. **The check is the atom that already guards this act: `access_request.review`, floor archon** (`modules/government/catalog.js`), which pins `GET /access-requests`, `PATCH /access-requests/:id` and `POST /access-requests/invite`. **This goal mints no new permission.** Widening the queue's *read* half to metics is the console wave's `project.recruit` (spike 1002786 R3); until it lands, the owner view ships on `access_request.review` alone and moves over when it does. Adding a third atom for the same door would be the drift.
58
+
59
+ 3. **`platform_identity_projects.owner_github_id` stays advisory attribution and is never read as a gate** — its own comment says so, and `project-stats.js` already declines to read it, taking the project's own archon off its own leaderboard instead (ADR 0170). A hub-held owner list consulted before an admission would be the hub deciding who may admit on someone else's box: the exact inversion ADR 0141 exists to prevent. The hub's role here is unchanged from §3 — relay, echo, and a link to the project's own console.
60
+
61
+ 4. **So the owner's pending-applications view is a page on the project's own instance**, and the hub links to it. It absorbs the Watch page's access-requests section on the same terms the console spec set (one queue, one home).
62
+
63
+ ### D4 — The lifecycle keeps three states; approval is a door, not a membership.
64
+
65
+ 1. **No new status values.** `invited` means approved — the door opens and **the acceptance act is signing in** (console spec D2), whose authoritative record is the `builders` row plus the membership check-in. An `accepted` state would be a second place membership is asserted, free to drift from the one that actually admits. `dismissed` means declined. Both are terminal and the row stays as the audit trail.
66
+
67
+ 2. **Rescind is `invited → dismissed`, and the PATCH must learn to accept it.** Today it resolves only from `pending`. An approval given by mistake, before the applicant has ever signed in, must be revocable: `hasInvitedAccessRequest` stops matching and the door re-locks. After they have signed in the row is history and offboarding is the instance's own act (`/builder-exit`) — this route must never pretend to remove a member. The reverse correction (`dismissed → invited`) needs no new path: `POST /access-requests/invite` already resolves or inserts an `invited` row for any login.
68
+
69
+ 3. **Approval expires read-time at 30 days, whatever created it.** The privacy spec D4 set that constant for recruit invites; an `invited` row produced by approving an application is the same open admission door, and one rule for `status='invited'` is the only version of this that cannot rot into two. An **application** carries no expiry — a stale `pending` row admits nobody, and the owner dismissing it is the whole mechanism.
70
+
71
+ 4. **Approve and decline are idempotent on the value they already hold** (a PATCH to the current terminal state returns the row, not a 409), so a double-clicked button in the queue is safe. Every refusal names the state that blocked it and the action available there, in the ADR 0116 envelope.
72
+
73
+ ## What this unblocks
74
+
75
+ | Task | Depends on |
76
+ |---|---|
77
+ | [task 1002284](https://cloudbongos.com/builders#/task/1002284) — R08, the application API (apply / list-pending / approve / decline) | D1 (the migration + the row), D4 (the statuses, the rescind gap, idempotency), D3.2 (the atom it pins) |
78
+ | [task 1002285](https://cloudbongos.com/builders#/task/1002285) — R09, the application carries the cross-project record (+ note) | D2 wholesale — the assertion, the header, the live browser read, the handle |
79
+ | [task 1002286](https://cloudbongos.com/builders#/task/1002286) — R10, "Apply" on the in-core browse surface | D1.3 (the echo is where *applied* comes from), D1.4 (a re-apply is not an error) |
80
+ | [task 1002287](https://cloudbongos.com/builders#/task/1002287) — R11, the owner pending-applications view | D3 (who may see it, and that the page lives on the project), D2.5 (the record is fetched from the page) |
81
+ | [task 1002288](https://cloudbongos.com/builders#/task/1002288) — R12, "Apply" on the orbs front door | D1.3, D1.4 — the same state and the same idempotency, from the separate static site |
82
+ | [task 1002289](https://cloudbongos.com/builders#/task/1002289) — R13, PROOF: apply → owner sees record → approves → applicant joins | all four; the proof must also pin the unvouched case (no record rendered) and a hide toggled between two renders |
83
+ | [task 1002972](https://cloudbongos.com/builders#/task/1002972) — applications carry the applicant profile: note through the relay, live render in the queue | D2.4 (live, never a snapshot) and D2.7 (the private-account sliver is its half, behind [task 1002968](https://cloudbongos.com/builders#/task/1002968)) |
84
+
85
+ Two things R08 should not rediscover: the relay's applicant note already exists (task 1002330), and `PATCH /access-requests/:id` resolving only from `pending` is a real gap D4.2 asks it to close.
86
+
87
+ ## Consequences
88
+
89
+ - A project running any core pin keeps taking applications exactly as it does today; upgrading is what turns the vouch on. A vanilla or standalone instance holds no hub public key, verifies nothing, and degrades to the local queue it already has — which is the goal spec's stated degrade for non-federated instances.
90
+ - The queue gains a two-class row: vouched (a platform account, a record beside it) and unvouched (a name and a note). The owner must be able to see which is which — an unvouched row that *looked* vouched is the whole vulnerability, so the distinction is UI-load-bearing, not decoration.
91
+ - `GET /profiles/:handle` picks up a second class of caller. Its per-IP limit meters each reviewer separately (D2.5), and its `no-store` is now doing double duty: it is what makes a hide land on the owner's next queue render.
92
+ - The hub can say *when* someone applied and never *what came of it*. A card that wants to say "declined" is asking for a fact the hub is not allowed to hold; the honest reading is membership appearing, or not.
93
+ - The echo is the one new hub table this goal adds, and it is deliberately statusless so it can never be mistaken for the application.
94
+
95
+ ## Rejected
96
+
97
+ - **A cross-project `applications` table on the hub.** It would hold an admission fact about someone else's project, need its own path into `firstTimeAdmissionBlocked` to mean anything, and be stale from the moment the owner acted. C3 asks for the existing join path precisely to avoid this.
98
+ - **A separate `project_applications` table on the instance**, beside `access_requests`. Two queues for one door, two audit trails, and the same person able to hold a row in each.
99
+ - **A profile snapshot stored on the application row.** Fails C2's live hide, and honoring a later hide would mean sweeping every instance that ever received a copy.
100
+ - **Adding the assertion to the relay body.** 400s on every older pin under strict validation, which `joinRelayOutcome` reads as unreachable — breaking applications to most projects, for good.
101
+ - **Refusing an application whose assertion is missing or invalid.** Locks out an older hub and hands an attacker a denial vector; unvouched is the row the project has always had.
102
+ - **An instance→hub server-side pull for the record.** Extends the always-on hub dependency onto the console's request path and needs the client-authenticated read with timeout/retry/cache that ADR 0171 D1 rejected and named as its own task.
103
+ - **Reading `owner_github_id` as an authorization.** The hub deciding who may admit on someone else's instance — the inversion ADR 0141 exists to prevent — and the column's own comment already says it is advisory.
104
+ - **A new "project owner" permission atom.** `access_request.review` already guards this exact act at the right floor; widening its read half is the console wave's `project.recruit`, and a third atom for one door is drift.
105
+ - **An `accepted` status.** Membership is asserted by the sign-in and the check-in; a second assertion is free to disagree with the one that admits.
106
+
107
+ ## Deferred (named, not dropped)
108
+
109
+ - **The private-account sliver in the queue** — needs `project.recruit` gating the hub can see; task 1002972 behind task 1002968.
110
+ - **The console page absorbing the Watch section, and the `kind` / `created_by` columns** — spike 1002786's wave, whose migration must not collide with D1.2.
111
+ - **A hub-side aggregate of every project's queue** ("all my projects' applications in one view") — needs the push-model federation `project-stats.js` describes; the console spec already records it as the eventual shape.
112
+ - **Guild applications (C8)** — spike R23; a guild applying is a different applicant class and this ADR decides nothing for it.
@@ -0,0 +1,148 @@
1
+ # ADR 0202 — A connection is one directed row on the platform account, and every degree label is computed over the graph the viewer is allowed to see
2
+
3
+ - **Status:** accepted
4
+ - **Date:** 2026-08-28
5
+ - **Task:** [task 1002294](https://cloudbongos.com/builders#/task/1002294) (goal 1000045 — Public builder profiles, criterion C6)
6
+ - **Relates to:** ADR 0141 (identity federated, authority local — the cross-project data plane), ADR 0171 (the rollup join, fail-closed consent, `hide_stats` and the leak-by-subtraction ban), ADR 0182 (a predicate written once as JS and as a SQL fragment), ADR 0192 / ADR 0194 (fail closed to the narrower door; one reader, never re-derived), ADR 0083 (module-owned, namespaced migrations), `docs/specs/<redacted>.md` (D1 flags, D2 matrix, D4 invite lifecycle), `docs/specs/<redacted>.md` (page section 6 is reserved for this)
7
+
8
+ > ADR **0201** is reserved by a sibling session in flight at the time of writing; this ADR takes 0202 rather than race it. The gap is deliberate (the ADR 0199 precedent).
9
+
10
+ ## Context
11
+
12
+ Criterion C6 asks for LinkedIn-shaped connections: request / accept / decline / remove, connections on the profile with 1st/2nd/3rd-degree labels, and "builders you may know" from platform-native signals. Four facts found while reading the tree shape every decision below.
13
+
14
+ 1. **The data plane already exists and it is account-global, not per-instance.** `platform_identity_accounts` is the stable cross-project account (keyed on `github_id`); `<redacted>` and `platform_identity_builder_project_activity` hang off it. Core `builders` rows are per-instance and cannot express "these two people know each other across projects". So a connection is **account↔account on the hub**, and it belongs to `modules/platform-identity/`.
15
+ 2. **An analogous lifecycle is already written.** `goal_membership_requests` (migration `core_188`) runs `pending → accepted | rejected | cancelled` with a **partial unique index on the pending row only**, resolved rows kept as the audit trail, and an atomic resolve-and-act statement so an accepted row can never exist without its membership. Its vocabulary and its shape carry over almost unchanged; where this ADR diverges it says so.
16
+ 3. **Privacy here is not one flag but a matrix, and it is owner-signed.** `profile_state` (`provisional|active`), `account_visibility` (`public|private`, [task 1002966](https://cloudbongos.com/builders#/task/1002966)), `hide_stats`, and the nullable `recruiter_discoverable` whose effective value is `COALESCE(recruiter_discoverable, account_visibility = 'public')`. The two rules the matrix encodes — `hide_stats` beats everything, and public responses stay viewer-independent — are the ones a graph feature is most likely to break.
17
+ 4. **ADR 0171 D4 already bans the exact bug a graph invites**: *"no partial numbers; a count plus a hidden total is a leak by subtraction."* A mutual-connection count, a connection count, and a degree label are all counts computed over a set that privacy filters — the three places that rule has to be applied by construction rather than by memory.
18
+
19
+ Nothing in this feature may confer authority. A connection grants no rank, no credits, no admission, and no access to any project (ADR 0016 / ADR 0141).
20
+
21
+ ## Decision
22
+
23
+ ### D1 — One directed row per relationship, made unordered by a generated pair key
24
+
25
+ **We store the request, not the edge — one row, directed, with the unordered pair generated from it as the uniqueness key.** `requester_github_id` and `addressee_github_id` are kept because the inbox, the cancel path and the decline rule all need to know who asked; `pair_lo` / `pair_hi` are `GENERATED ALWAYS … STORED` from `LEAST`/`GREATEST` of the two, and a **partial unique index on `(pair_lo, pair_hi)` over the open statuses** is what makes a duplicate *and* a simultaneous reciprocal request impossible in the database rather than in a route. Reads are symmetric: one helper unions both directions, and no caller writes a direction-aware `WHERE` of its own.
26
+
27
+ Migration sketch — `modules/platform-identity/migrations/<redacted>.sql`, additive and `platform_identity_`-namespaced (ADR 0083 §5), authored by [task 1002295](https://cloudbongos.com/builders#/task/1002295), not here:
28
+
29
+ ```sql
30
+ CREATE TABLE IF NOT EXISTS platform_identity_connections (
31
+ id bigserial PRIMARY KEY,
32
+ requester_github_id bigint NOT NULL,
33
+ addressee_github_id bigint NOT NULL,
34
+ -- The unordered pair, derived — never written by a caller, so A→B and B→A
35
+ -- collide on the index instead of both being accepted.
36
+ pair_lo bigint GENERATED ALWAYS AS (LEAST(requester_github_id, addressee_github_id)) STORED,
37
+ pair_hi bigint GENERATED ALWAYS AS (GREATEST(requester_github_id, addressee_github_id)) STORED,
38
+ status text NOT NULL DEFAULT 'pending'
39
+ CHECK (status IN ('pending', 'accepted', 'rejected', 'cancelled', 'expired')),
40
+ note text,
41
+ seen_at timestamptz,
42
+ created_at timestamptz NOT NULL DEFAULT now(),
43
+ resolved_at timestamptz,
44
+ updated_at timestamptz NOT NULL DEFAULT now(),
45
+ CHECK (requester_github_id <> addressee_github_id)
46
+ );
47
+
48
+ -- At most ONE open relationship per pair. Resolved rows stay as the audit trail
49
+ -- and never block a later ask (the goal_membership_requests precedent).
50
+ CREATE UNIQUE INDEX IF NOT EXISTS uniq_platform_identity_connections_open
51
+ ON platform_identity_connections (pair_lo, pair_hi)
52
+ WHERE status IN ('pending', 'accepted');
53
+
54
+ -- The three reads: my inbox, my outbox, and the 1-hop neighbourhood both
55
+ -- traversal and suggestions expand from.
56
+ CREATE INDEX IF NOT EXISTS platform_identity_connections_addressee
57
+ ON platform_identity_connections (addressee_github_id, status);
58
+ CREATE INDEX IF NOT EXISTS platform_identity_connections_requester
59
+ ON platform_identity_connections (requester_github_id, status);
60
+ CREATE INDEX IF NOT EXISTS platform_identity_connections_pair_accepted
61
+ ON platform_identity_connections (pair_lo, pair_hi) WHERE status = 'accepted';
62
+ ```
63
+
64
+ **Rejected.** *Two mirrored rows written on accept* — the LinkedIn-on-paper model: it doubles every write, and the two halves can drift under a partial failure, which is the class of bug the goal-invite ADR fixed by making resolve-and-add one statement. *A pure symmetric edge with no requester* — cheap to read and impossible to build an inbox on: cancel, decline and the cooldown all need to know which side asked. *A core table beside `builders`* — connections are account-global; a core table would exist on every self-hosted instance with nothing to put in it and would breach the module boundary.
65
+
66
+ ### D2 — Lifecycle: the goal-invite vocabulary, three deliberate divergences, remove is a delete
67
+
68
+ `pending → accepted` (the addressee accepts) `| rejected` (the addressee declines) `| cancelled` (the requester withdraws) `| expired`. The accept path resolves the row and returns the new connection in **one statement**, so an `accepted` row can never exist without being readable as a connection. Divergences from `goal_membership_requests`, each on purpose:
69
+
70
+ 1. **A decline is silent.** The requester is never told. The row leaves their outbox as "no longer pending" and no notification is emitted. A goal invite's decline is inbox cleanup between two people who already build together; a connection decline is a social judgement, and telling someone they were refused turns a cheap ask into an event and manufactures a retaliation surface.
71
+ 2. **A decline closes the door for 90 days, and re-asking inside that window is answered as if it worked.** `rejected` is load-bearing state, not just audit: a new request from the same requester inside `resolved_at + 90 days` is **swallowed** — nothing is written, and the endpoint returns the same body it returns for a request that was genuinely created. This is ADR 0194 D3's rule applied to a person instead of a project: the response must reveal nothing about the addressee's prior decision, and a 409 saying "declined" would disclose exactly the thing decision 1 keeps quiet. After 90 days the door reopens; people change. Decline is **not** a block and does not hide the requester anywhere (see Deferred).
72
+ 3. **Expiry is 30 days, read-time, no sweeper** — the constant and the mechanism the privacy spec D4 already chose for recruit invites (`created_at + 30 days`, computed in every queue view). An expired request is absent from both inboxes. Because `now()` cannot live in an index predicate, the write path **resolves any expired row of the pair to `'expired'` in the same statement that inserts the new request** — so a stale `pending` row can never keep the partial unique index locked against a legitimate re-ask.
73
+
74
+ **Remove hard-deletes the row.** An accepted connection is consensual data about *two* people; when either withdraws, a tombstone saying "A removed B" is data neither consented to keep and that no read path uses. The partial unique index covers only `pending|accepted`, so the deleted row blocks nothing. The asymmetry with `rejected` — which is kept — is exactly that `rejected` still does work (the cooldown) and a removal row would not. `seen_at` is stamped on first inbox render, the D4 parity item, for a "delivered/seen" affordance later.
75
+
76
+ ### D3 — Degree is bounded expansion from the viewer, capped, and it degrades rather than guesses
77
+
78
+ **A recursive CTE seeded at the viewer, hard-capped at depth 3 with a per-level fan-out cap and a deterministic frontier order.** There is no closure table and no cached graph. Two shapes cover every product question, and both are bounded *from the viewer*:
79
+
80
+ - **The label V→T** (profile section 6). 1st = one index hit on the accepted-pair index. 2nd = `EXISTS` on the intersection of two index-ranged neighbour sets — which yields the mutual list for free. 3rd = V's 1-hop materialised (cap `FANOUT_1 = 500`) expanded to a 2-hop frontier (cap `FANOUT_2 = 5000`), intersected with T's 1-hop.
81
+ - **The 2nd-degree frontier** (suggestions, D5) — the same expansion, one level, ranked, `LIMIT 20`.
82
+
83
+ **Past the caps, or past depth 3, the label is `null` — "out of network" — never a number the query did not actually establish.** The frontier is ordered by `github_id` so a truncation is reproducible rather than a different answer each call. `DEGREE_MAX = 3`, `FANOUT_1`, `FANOUT_2` are named constants exported beside the query, not literals inside it.
84
+
85
+ **Honest envelope.** Cost is `O(d)` for 1st, `O(d)` for 2nd (two index range scans and a hash intersect), and `O(min(d², FANOUT_2))` for 3rd. At the hub's present scale — order 10² active accounts — every one of these is a few index scans and single-digit milliseconds. The caps begin to bite around 10⁴ accounts with a heavy-tailed degree distribution, and they bite in the right direction: a hub-heavy account with 400 connections has a 3-hop neighbourhood in the millions, and the cap converts that from a slow query into a *missing* 3rd-degree label on a page that still renders. The revisit trigger is a measured p95, not a guess.
86
+
87
+ **Rejected.** *A materialised closure or adjacency table* `(a, b, degree)` — it is `O(N·d²)` rows, every accept and every remove invalidates an unbounded slice of it, and worse, it would have to be privacy-filtered *at write time*, freezing a visibility decision into stored data, which is precisely what ADR 0171 D2 forbids ("every surface reads through the module's filtered functions at render time, never from a stored copy"). *An in-process graph cache* — one Node process makes it tempting, and the no-store privacy model makes it wrong: a hide or a private-flip must beat the cache. *A graph database or a second store* — a whole new dependency and monthly cost for a workload that fits in three index scans (CLAUDE.md §4).
88
+
89
+ ### D4 — One shared effective-visibility predicate; the count is the length of the list you may see
90
+
91
+ **Connections get no privacy predicate of their own.** They consume the single effective-visibility reader [task 1002967](https://cloudbongos.com/builders#/task/1002967) establishes, published in both forms the ADR 0182 precedent requires — a JS function and a SQL fragment (`accountVisibleSql(alias)`, beside `publishableSql`) — so a route and a query cannot drift. Adding a second, connections-flavoured predicate is the drift ADR 0194 D7 exists to prevent.
92
+
93
+ 1. **`profile_state = 'active'` is the floor for the entire feature.** A provisional account cannot send, cannot receive, and never appears in a list, a frontier or a suggestion. Fail closed: an account row that cannot be read is treated as invisible, never as visible (ADR 0192 §6 / ADR 0194 §2).
94
+ 2. **A private account is not addressable, so a stranger cannot send it a request** — the connect button does not render and `POST /connections` answers **404, not 403**; a 403 confirms the handle exists (the profile-page spec's rule). A private account may still *send* requests and hold real connections; those appear in its own-scope list and in the counterparty's own-scope list, and **never in any public connection list**, because rendering a private account inside someone else's public page is the 404 bypass wearing a different hat.
95
+ 3. **`hide_stats` does not suppress the edge; it suppresses the numbers beside it.** A connection is not rollup-derived, so hiding stats does not hide that you know someone. Anything rendered *alongside* a connection row that comes from `platform_identity_builder_project_activity` — credits, ships, karma, the disciplines union, `last_active` — is suppressed exactly as everywhere else. Stating this here is the point: without it, [task 1002296](https://cloudbongos.com/builders#/task/1002296) has to guess.
96
+ 4. **Degree is an own-scope read, never a public one.** `GET /profiles/:handle` carries no degree label; the page composes one from a second authenticated call about the caller. This is how a viewer-relative number coexists with ADR 0171 D4's viewer-independent public responses — the same page-shell composition the privacy spec D2 uses to render an owner their own private page from a public endpoint that 404s.
97
+
98
+ **The leak-by-subtraction rule, in one sentence: every count is the length of the array the viewer is allowed to see.** Never a raw count beside a filtered list. Three sites it binds:
99
+
100
+ - **Mutual connections.** "You and Ada have 4 mutuals" over a list of 3 names discloses the fourth by subtraction — ADR 0171 D4's ban, exactly. The count is computed over the filtered set or it is not shown.
101
+ - **The connection count on a profile.** Same rule; filtered accounts are not counted, and there is no "+N hidden".
102
+ - **The degree label.** If every path from V to T runs through a node V may not see, the label is `null`, not "3rd". A degree label is a property of *the viewer's visible subgraph*, not a global fact, and two viewers legitimately seeing different labels for the same target is the model working, not a bug.
103
+
104
+ ### D5 — "Builders you may know": three native signals, one explainable score, and three refusals
105
+
106
+ Every suggestion carries the reason that produced it, because an unexplainable suggestion on a page about who you know is worse than none.
107
+
108
+ | Signal | Source | Weight |
109
+ |---|---|---|
110
+ | Mutual connections | the accepted-edge set, filtered per D4 | `3 × min(mutuals, 10)` |
111
+ | Shared projects | co-membership on the same `client_id` in `<redacted>` / `platform_identity_builder_project_activity` | `2 × shared_projects` |
112
+ | Recency | `max(last_active)` inside the last 30 days | `1` |
113
+
114
+ Ties break on `last_active`, then `github_id` so the list is stable. A hand-tuned linear sum, deliberately — the requirement is a sentence a human reads ("you both build on Foo"), not accuracy. **A named project appears in a reason only where its catalogue row is publicly listed**; otherwise the reason reads "a project you both build on" and the name is withheld — ADR 0171 D6's rule that the aggregate is not the leak, the name is.
115
+
116
+ **Three refusals, named rather than quietly skipped.**
117
+
118
+ - **Shared goals are not a signal.** `goal_members` is instance-local; the hub has no goal table and reading one would mean a per-instance fetch on a page render. Out.
119
+ - **Shared guilds are a slot, not a feature.** C7's guilds do not exist yet ([task 1002300](https://cloudbongos.com/builders#/task/1002300)). The scorer reserves the term and ships it at zero rather than pre-building a signal against a schema nobody has written.
120
+ - **Discord is refused outright.** Discord is a one-way mirror outside the trust boundary, and `platform_identity_accounts` stores no Discord identity. Using Discord co-membership would mean importing an identity link the account never gave the hub — the opposite of the fail-closed consent ADR 0171 D3 built. Likewise refused: the GitHub follower graph, email, and any address-book import. The suggester uses only what the account handed the platform by building here.
121
+
122
+ Suppressed suggestions are suppressed silently — no "N more" counter, which would be leak-by-subtraction with a friendlier label. Anyone the viewer already has a `pending`, `accepted`, or in-cooldown `rejected` row with is simply absent, which also keeps D2's silence intact.
123
+
124
+ ## What this unblocks
125
+
126
+ | Task | Depends on |
127
+ |---|---|
128
+ | [task 1002295](https://cloudbongos.com/builders#/task/1002295) — Connections API: request / accept / decline / remove / list + degree queries | D1 (the migration + the uniqueness constraint), D2 (the five statuses, the atomic resolve-then-insert, the swallowed re-ask, read-time expiry), D3 (the capped CTE and its constants), D4 (`accountVisibleSql` on every read) |
129
+ | [task 1002296](https://cloudbongos.com/builders#/task/1002296) — Connections on the profile + connect button | D4 §2 (404 not 403, private accounts absent from public lists), §3 (the edge shows, the numbers do not), §4 (degree comes from a second authenticated call, not from `GET /profiles/:handle`), and the filtered-count rule |
130
+ | [task 1002297](https://cloudbongos.com/builders#/task/1002297) — "Builders you may know" | D5 entire — the three signals, the score, the reason string, and the three refusals |
131
+ | [task 1002298](https://cloudbongos.com/builders#/task/1002298) — PROOF: two builders connect; each sees the other + a 2nd-degree suggestion | D2 (the happy path), D3 (a real 2nd-degree label), D5 (a mutual-derived suggestion). The proof should also assert one negative: a private third party is absent from the public list *and* from the mutual count |
132
+
133
+ **What the guild and orb-physics wave gets** ([task 1002309](https://cloudbongos.com/builders#/task/1002309) – [task 1002313](https://cloudbongos.com/builders#/task/1002313)), and what it does not. Exposed: `neighbours(githubId)` (the visible 1-hop set), `degreeBetween(viewer, target)` → `1|2|3|null`, and `mutualCount(viewer, target)` over the visible set. **Not exposed: a connection strength scalar.** V1 has one edge type and it is unweighted. If R33 wants strength it must *derive* it from mutuals, shared projects and recency at read time — a stored strength column would need re-filtering per viewer and would be a cached privacy decision, the thing D3 rejected the closure table for.
134
+
135
+ ## Consequences
136
+
137
+ - The hub gains one table, three indexes and one uniqueness constraint. A duplicate connection, a self-connection and a simultaneous reciprocal request all become database errors rather than route bugs.
138
+ - Every connection read joins the same effective-visibility predicate as every other public read on the hub, so a future privacy flag lands in one place and reaches this feature for free — and a new connections read that forgets the join is a visible omission in review rather than a silent leak.
139
+ - Degree labels are honest but not always complete: a viewer can see `null` where a global oracle would say "3rd", either because a bridge is private or because the fan-out cap truncated. This is a deliberate trade of completeness for both privacy and a bounded query, and the UI copy must say "out of network", never "not connected".
140
+ - A declined request is invisible to the person declined, including on re-ask. The cost is that a builder can send the same request twice a year and never learn why nothing happens; the benefit is that declining stays cheap enough that people actually do it instead of leaving requests pending forever.
141
+ - Nothing here is authority. No connection table is read by `requireRank`, `requirePermission`, or any admission gate, and accepting a connection changes no one's rank, credits, or access to any project.
142
+
143
+ ## Deferred (named, not silently dropped)
144
+
145
+ - **A connections-specific visibility toggle** ("hide my connections list"). The three flags plus one consent state were owner-signed as a set; a fourth forks the D2 matrix and needs the owner, not a builder. `account_visibility` already answers the question for the whole page.
146
+ - **Blocking.** Decline closes a door for 90 days; it does not hide the requester from search, suggestions or the graph. A real block is a different feature with its own matrix row and should not be smuggled in as a decline side-effect.
147
+ - **What an accepted connection unlocks on a private account.** V1's rule is minimal and deliberate: an accepted connection grants exactly what a name in a list needs — handle, display name, avatar — and the private account's `/u/<handle>` still 404s. The wider rule (a connection unlocks the full profile) is defensible and is how LinkedIn behaves, but it is an owner decision about the privacy matrix, not a builder's.
148
+ - **Notifications.** Connection requests ride the existing attention-card inbox pattern (privacy spec D5, no parallel inbox); an SSE nudge and email stay deferred for the same reason they are deferred there.
@@ -288,5 +288,7 @@ This keeps the decision history honest and traceable.
288
288
  | 0199 | [**An unreachable ACTIVE project gets a restart of its own, not a re-provision — and the restart never claims the verdict its probe cannot reach** ([task 1003238](https://cloudbongos.com/builders#/task/1003238), goal 1000058). The liveness overlay (task 1002696) could report a project unreachable while its status stayed `active`, and that reading came with no action: `PROVISION_ENQUEUE_FROM` deliberately excludes `active` (task 1002691 — a blind re-enqueue left a stale provision intent blocking the owner’s own teardown), so the card’s existing *Try again* / *Bring it back* doors 409 on an active project by construction, and `settings-apply` — the only leg that runs from `active` — refuses unless a stored value actually changed, which is exactly the case a stuck project does not have. Decisions: (1) a `restart` intent (migration `provisioning_011`, `INTENT_ACTIONS` in lockstep) behind `RESTART_FROM = {active}` + `RESTART_SHAPES = {co-tenant, standalone}`; the web tier still only ENQUEUES (ADR 0111 §2). (2) `restartInstance` is `settingsApplyInstance` MINUS the env patch — one `systemctl restart`, then the same patient healthz budget; it reads and writes no file, so ADR 0190’s in-place-patch hazard cannot arise. (3) The `probeIsVerdict` narrowness carries over and is load-bearing: the liveness sweep probes the PUBLIC `https://<domain>/healthz` while the runner verifies LOOPBACK, so a project can answer here while DNS/Caddy/TLS is what is broken — no up-verdict is written for a domained instance, and the event says “answering on the box; whether its public address answers is for the next sweep to say” rather than a sentence the still-`unreachable` card would contradict. (4) A project that stays down is `ok:true` — the restart RAN; re-pending it to the attempt cap would restart a broken project four more times and end by stamping an `error_note` over the owner’s own action. Only a THROW returns `ok:false`, and it returns rather than throws because the drain loop’s catch would relabel a merely-unreachable instance `error` (ADR 0190 §2). (5) `enqueueRestart` is idempotent against its own kind in EITHER open state — where it differs from the settings push, which must refuse to ride a RUNNING sibling that may have read the row already; a restart carries no payload, so one already running IS the ask. (6) A pending restart joins the teardown’s supersede list, more sharply than its siblings since the owner reaches for Restart exactly when they might next give up and delete. (7) The door shows only at `active && last_verified_up === false && shape !== dedicated && !open_intent`, mirrored on card and manage page; `null` (never probed) is NOT `false` and raises no door. (8) Every refusal names the action that IS available there, the state gate is checked before the shape gate, and no branch promises a duration (F16). The `INTENT_ACTIONS`↔migration set-equality assertion moved to the NEWEST migration’s test — pinned to 010 it would fail for every future action. Rejected: widening `PROVISION_ENQUEUE_FROM`; re-sending unchanged settings to borrow their restart; reporting a loopback pass as “answering again”; retrying a restart that left the project down; a Restart door on every active card.](<redacted>.md) | provisioning / project lifecycle |
289
289
  | 0198 | [**Third-party skill vendoring: a scan verdict the session cannot override, a licence allowlist, a provenance sidecar, one preamble block, and no auto-update** ([task 1003321](https://cloudbongos.com/builders#/task/1003321), goal 1000080 — the second of the five ui-design tasks). Twelve design skills installed on one builder's machine were to become the `ui-design` module's vendored set. Both origins reduced to DANGEROUS under `/scan-before-install` at the default tier: `Leonxlnx/taste-skill` on one floor critical (an injection-phrase hit the panel identifies as a filename placeholder) with a caution panel; `pbakaus/impeccable` on three floor criticals and a panel that upheld shipped PostToolUse/Stop hooks written into five harness configs, a child agent spawned with permissions bypassed from a loopback HTTP route, a directive telling the model to disregard its harness system prompt, a page-embedded live-server token and an unverified remote bundle download. DECISION: the scan verdict is the gate and a session cannot override it — `dangerous` lands a rebuild spec (a GDS task), never a copy, and vendoring one anyway is an owner ADR plus a test amendment; a licence allowlist (Apache-2.0, MIT, BSD-2/3, CC-BY-4.0); a `PROVENANCE.md` sidecar with the full pinned sha; ONE preamble block (`skills/PREAMBLE.md`, byte-identical, fenced by two markers so an upstream refresh is a three-way merge) that reads the instance's world before any taste rule; `skills/vendor/<name>/` as the home because `fitness-ratchets.js` already exempts `vendor/` from the first-party budgets; the manifest stays the single declaration and `claude-materialize.js` step 1b lands a declared module skill — sidecar included — in every instance's `.claude/skills/` and withdraws it with the module; no auto-update; the playwright plugin a documented prerequisite. The roster ships EMPTY and that is the policy working: the machinery is proven by fixture (`tests/ui_design_skills.mjs`), the twelve are named in `/design` with their verdicts, two rebuild-spec tasks hold the documented functionality. Rejected: treating the placeholder hit as a false positive; scanning a fork or subtree; vendoring impeccable with the hooks stripped; `.claude/skills/` as the home; a second roster file.](<redacted>.md) | modules / design system / security |
290
290
  | 0200 | [**A mistyped bring-your-own domain can be corrected — the address it leaves is released to a blackhole, and the attach leg stops regenerating web.env from the row** ([task 1003237](https://cloudbongos.com/builders#/task/1003237), goal 1000058). `POST /provisioning/instances/:id/domain` refused a second address with a deliberate `409 domain_already_set` ("A genuine BYO address stays un-re-pointable, as before") while direction §8 said "attach/change stays for custom domains" — so a founder who mistyped one character in the create wizard was permanently addressed at a hostname they did not own, with no path back but teardown. Decisions: (1) a BYO address re-points under the guards the FIRST attach already applied — `isReservedHostname`, the task-1002704 in-apex squat rule, `getInstanceByDomain` uniqueness, `DOMAIN_ATTACH_FROM` (which already admitted `active`) — none added, none weakened; the platform-assigned `<slug>.<apex>` is un-re-pointable because the squat rule says so. (2) The address it leaves is RELEASED: `provisioning_012` adds `released_domains text[]`, written in the SAME statement as the new domain (a crash between the two would leave a hostname aimed at platform infra with nothing in the row to say so), an ARRAY because a founder can mistype twice before the runner drains. Only a BYO address is queued — the assigned `<slug>.<apex>` is the platform’s own name and the revive path re-uses it (ADR 0181 §2). (3) Release is a REPOINT to `192.0.2.1` (RFC 5737 TEST-NET-1), not a delete — the box lane’s idea-309 shape: a delete opens an NXDOMAIN window resolvers negative-cache for the zone’s SOA neg-TTL (~30 min, not editable), and an UPSERT needs only the contract every operator `BOX_DNS_HOOK` already implements. The Caddy vhost and the tls-check allow-list release THEMSELVES (the snippet is per-slug and carries the current domain; tls-check answers from the row). (4) A failed release is `ok:true` + `releaseFailed`, and KEEPS the queue so the next attach retries — a release that failed must never fail a re-point that already landed. (5) `domainAttachInstance` now patches web.env IN PLACE via `upsertEnvVars`/`originEnvVarsFor`, closing the ADR 0190 §2 hazard (idea 1000842) on this path: the template re-write regenerated the file FROM THE ROW, blanking the OAuth creds `placeManifestCreds` placed, the App private key and the federation block — masked for a FIRST attach by wizard order, and NOT masked for a re-point, which runs on a project the founder is already signed in to. Dry-run plans; an unreadable/empty file is refused, never patched. (6) A CHANGED domain clears `last_verified_{at,up}` — the overlay is a verdict on an ADDRESS (task 1002696), so carrying it would let the card claim the new name "last answered 2 minutes ago" when only the old one was ever probed (F16). (7) The card gains a third door — *Use a different domain*, pre-filled so a typo is edited not retyped — and says what it costs where the button is. Rejected: amending direction §8 to call the address fixed; deleting the old record; sequencing behind task 1002897; a `previous_domain` scalar (drops the middle address).](<redacted>.md) | provisioning / project lifecycle |
291
+ | 0201 | [**An application is the project's own `access_requests` row, vouched for by a hub-signed assertion carried in a HEADER; the record it carries is resolved live and never stored** ([task 1002283](https://cloudbongos.com/builders#/task/1002283), goal 1000045 — public builder profiles, C3/C4). The join relay (`POST /my-projects/join`) already posts `{github_login, display_name, note}` to the target's PUBLIC, unauthenticated `POST /access-requests` — presenting nothing the applicant could not have typed into the project's own landing page. Attaching a cross-project record to a login nobody vouched for turns the owner's queue into an impersonation surface: file a request naming a well-known builder, the owner reads that builder's track record beside it and approves, and whoever holds that GitHub account signs in. So the record is the thing that makes the unauthenticated write dangerous, and C4 cannot be built without closing it. Decisions: **D1** the application stays an `access_requests` row on the target — `status='invited'` IS what `hasInvitedAccessRequest` reads, so a parallel record would need a second branch in `firstTimeAdmissionBlocked`, the one decision that exists as one function to prevent drift; the migration adds only `applicant_github_id text` (core's own type) + `vouched_at`, the non-null id IS the vouch mark, and the console wave's `kind`/`created_by` stay a separate additive migration; the hub keeps a STATUSLESS echo `(github_id, client_id, applied_at)` so the applicant's own cards can say "you applied on <date>" without polling every project — it may never claim what the project decided (an approval becomes visible the way membership always does, via the check-in), and it ages out at 30 days; re-applying answers `requested`, not 409. **D2** the hub signs an audience-scoped `purpose: 'application'` assertion with the existing `mintHubAssertion`, verified OFFLINE by the existing `verifyHubAssertion` against the already-provisioned `<PREFIX>_HUB_PUBLIC_KEY` — no new secret, no apply-time hub call — and it travels in `<redacted>` because `validateOrRespond` is STRICT by default: a body field would 400 on every older pin, and `joinRelayOutcome` reads a 400 as "could not be reached", breaking applications to most projects for good (the CLI's device exchange carries its assertion in the body because that route was new; this one is not). A missing or invalid assertion writes the row UNVOUCHED, never refuses — refusing locks out an older hub and hands an attacker a denial vector. The record itself is never copied: the row stores an identity (+ the handle, since `GET /profiles/:handle` is the addressed read), and the REVIEWER'S BROWSER fetches the hub's already-public, already-CORS'd, already-`no-store` profile at render, where `profile_state='active' AND NOT hide_stats` is applied inside the module — so C2's live hide is honoured by construction with no cache to outlive it, and a hub outage degrades to "record unavailable" beside a queue that still works. An instance→hub server pull was rejected on ADR 0171 D1's own grounds. A private account's application carries name + handle + note in V1; the D3 sliver needs `project.recruit`, which the hub cannot check cross-origin. **D3** "project owner" is the project's OWN archon — the registrant is whoever `maybeSeatFirstAdmin` seated, delegates are whoever that archon grants on that instance, and because the instance IS the project the per-project-scoped-permission problem dissolves; the check is the atom that already guards the act, `access_request.review` (floor archon), and this goal mints NO new permission (widening the read half is the console wave's `project.recruit`). `platform_identity_projects.owner_github_id` stays advisory and is never a gate — a hub-held owner list consulted before an admission is the inversion ADR 0141 exists to prevent. **D4** three statuses, unchanged: `invited` IS the approval and the acceptance act is signing in (an `accepted` state would be a second place membership is asserted, free to drift from the one that admits); rescind is `invited → dismissed`, which the PATCH must learn to accept (it resolves only from `pending` today) and which must never pretend to remove a member who already signed in; an `invited` row expires read-time at 30 days whatever created it, while an application never expires because a stale `pending` admits nobody; approve/decline are idempotent on the value they hold. Rejected: a cross-project `applications` table on either side; a stored profile snapshot; the assertion in the body; refusing an unvouched application; the server-side pull; reading `owner_github_id` as authorization; a new "project owner" atom; an `accepted` status.](<redacted>.md) | platform identity / applications |
292
+ | 0202 | [**A connection is one directed row on the platform account, and every degree label is computed over the graph the viewer is allowed to see** ([task 1002294](https://cloudbongos.com/builders#/task/1002294), goal 1000045 criterion C6 — the design spike for the connections wave). Criterion C6 asks for LinkedIn-shaped connections; the cross-project data plane it must sit on is account-global (ADR 0141) and governed by an owner-signed privacy matrix (`docs/specs/<redacted>.md` D1/D2), where ADR 0171 D4 already bans "a count plus a hidden total" as a leak by subtraction. Decisions: (1) **one directed request row** on a new `platform_identity_connections` — `requester`/`addressee` kept because the inbox, cancel and cooldown all need to know who asked, with `pair_lo`/`pair_hi` GENERATED from LEAST/GREATEST and a partial unique index over `pending|accepted` so a duplicate AND a simultaneous reciprocal request are database errors, not route bugs; module-owned and `platform_identity_`-namespaced (ADR 0083 §5). (2) The `goal_membership_requests` lifecycle (`pending → accepted|rejected|cancelled`, atomic resolve-and-act) with three deliberate divergences: a decline is **silent**, a re-ask inside its 90-day cooldown is **swallowed and answered as if it worked** (ADR 0194 §3 applied to a person — a 409 saying "declined" would disclose what the silence protects), and pending requests expire at **30 days read-time** (privacy spec D4), with expiry resolved in the same statement that inserts, since `now()` cannot live in an index predicate. Remove **hard-deletes** — a "A removed B" tombstone is data neither party consented to keep and no read path uses. (3) Degree = a **recursive CTE seeded at the viewer**, `DEGREE_MAX = 3` with `FANOUT_1`/`FANOUT_2` caps and a deterministic frontier order; past the caps the label is `null` ("out of network"), never a number the query did not establish. Envelope stated honestly: single-digit ms at hub scale, caps bite around 10⁴ accounts, and they convert a hub-heavy account's millions-wide 3-hop into a *missing label* on a page that still renders. (4) Connections get **no predicate of their own** — they consume task 1002967's single effective-visibility reader, published as JS and as `accountVisibleSql(alias)` (the ADR 0182 form); `profile_state='active'` floors the feature, a private account answers **404 not 403** and never appears in a public list, `hide_stats` suppresses the numbers beside an edge but not the edge, degree is an **own-scope read** so viewer-relative labels coexist with viewer-independent public responses — and every count is the length of the array the viewer may see. (5) "Builders you may know" from mutuals + shared projects + recency, one explainable linear score, a named project only where its catalogue row is publicly listed; shared goals refused (instance-local), guilds a reserved empty slot, and **Discord refused outright** (outside the trust boundary; the hub stores no Discord identity), as are the GitHub follower graph and address-book import. Rejected: two mirrored rows on accept; a symmetric edge with no requester; a core table beside `builders`; a materialised closure/adjacency table (privacy-filtered at write time = a cached visibility decision, which ADR 0171 D2 forbids); an in-process graph cache; a graph database. Deferred and named: a connections-specific hide toggle, blocking, what an accepted connection unlocks on a private account, and notifications beyond the existing attention card. Exposes to the guild/orb wave: `neighbours`, `degreeBetween`, `mutualCount` — **not** a stored strength scalar.](<redacted>.md) | platform identity / privacy / social graph |
291
293
 
292
294
  > ⚠️ **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.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.405",
3
+ "version": "1.19.407",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.405",
9
+ "version": "1.19.407",
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.405",
3
+ "version": "1.19.407",
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
@@ -1250,8 +1250,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1250
1250
  // landed since 1.19.403 with no explicit bump. run 33211074633. (task 1002620)
1251
1251
  // 1.19.405 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1252
1252
  // landed since 1.19.404 with no explicit bump. run 33212035244. (task 1002620)
1253
+ // 1.19.406 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1254
+ // landed since 1.19.405 with no explicit bump. run 33213981964. (task 1002620)
1255
+ // 1.19.407 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1256
+ // landed since 1.19.406 with no explicit bump. run 33214451873. (task 1002620)
1253
1257
  // ---------------------------------------------------------------------------
1254
- const CORE_VERSION = '1.19.405'; // CI auto-patch carrier (ADR 0161); see changelog above
1258
+ const CORE_VERSION = '1.19.407'; // CI auto-patch carrier (ADR 0161); see changelog above
1255
1259
 
1256
1260
  // A namespaced logger so a module's log lines are attributable + consistent.
1257
1261
  // Usage: const log = api.logger('dev-box'); log.info('mounted');