@bongos/core 1.19.404 → 1.19.406
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 +21 -16
- package/docs/adr/0202-connections-graph-and-bounded-degree.md +148 -0
- package/docs/adr/README.md +1 -0
- package/docs/copy-inventory.md +78 -79
- package/docs/copy-registry.json +94 -103
- package/docs/design/landing-page-direction-v2.md +2 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/index.html +18 -41
- package/src/module-api.js +5 -1
- package/tests/landing_page.mjs +16 -4
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.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.406",
|
|
6
|
+
"core_contract": "1.19.406",
|
|
7
|
+
"source_commit": "6464f72c550ae8ca0f0373b81d3ea903da55dc4c",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-28T21:
|
|
9
|
+
"built_at": "2026-08-28T21:46:40.389Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
|
-
"docs_redacted":
|
|
12
|
+
"docs_redacted": 351,
|
|
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":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2036,
|
|
20
|
+
"tree_sha256": "d99e05497bc0373de19e7f4b7b4211112543d78ed56f3577cf41e2935985a8c7",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -1504,10 +1504,15 @@
|
|
|
1504
1504
|
"mode": "0000644",
|
|
1505
1505
|
"sha256": "d73c7f4ebaf18bf81e461532144422b8d343698c50964687c52b13301af42c66"
|
|
1506
1506
|
},
|
|
1507
|
+
{
|
|
1508
|
+
"path": "docs/adr/0202-connections-graph-and-bounded-degree.md",
|
|
1509
|
+
"mode": "0000644",
|
|
1510
|
+
"sha256": "cb5bb269e40262b769bfa626fbc6d4ddf09a328b00742f07894c006b9b3bd885"
|
|
1511
|
+
},
|
|
1507
1512
|
{
|
|
1508
1513
|
"path": "docs/adr/README.md",
|
|
1509
1514
|
"mode": "0000644",
|
|
1510
|
-
"sha256": "
|
|
1515
|
+
"sha256": "5ac08994482310708e0ea1f14bc931677d644cba3b8dc5e4ec6bb1eedd2558a4"
|
|
1511
1516
|
},
|
|
1512
1517
|
{
|
|
1513
1518
|
"path": "docs/api-reference.md",
|
|
@@ -1542,12 +1547,12 @@
|
|
|
1542
1547
|
{
|
|
1543
1548
|
"path": "docs/copy-inventory.md",
|
|
1544
1549
|
"mode": "0000644",
|
|
1545
|
-
"sha256": "
|
|
1550
|
+
"sha256": "9f9e07188c9ec9aba3c12d10d9a18a7a6e9f85d5bff1173e1a6cd5d00973aacd"
|
|
1546
1551
|
},
|
|
1547
1552
|
{
|
|
1548
1553
|
"path": "docs/copy-registry.json",
|
|
1549
1554
|
"mode": "0000644",
|
|
1550
|
-
"sha256": "
|
|
1555
|
+
"sha256": "3001a29a83ccbb653394988f52584745077f5f81472fe267e2e3fa0ad5f1f241"
|
|
1551
1556
|
},
|
|
1552
1557
|
{
|
|
1553
1558
|
"path": "docs/design/apex-pass-2-direction.md",
|
|
@@ -1572,7 +1577,7 @@
|
|
|
1572
1577
|
{
|
|
1573
1578
|
"path": "docs/design/landing-page-direction-v2.md",
|
|
1574
1579
|
"mode": "0000644",
|
|
1575
|
-
"sha256": "
|
|
1580
|
+
"sha256": "a4e5095ced6f287757e7b5c2d0fbef774e24f9bd658fd2e0b4c2b35f96eb728e"
|
|
1576
1581
|
},
|
|
1577
1582
|
{
|
|
1578
1583
|
"path": "docs/design/landing-page-direction.md",
|
|
@@ -5327,12 +5332,12 @@
|
|
|
5327
5332
|
{
|
|
5328
5333
|
"path": "package-lock.json",
|
|
5329
5334
|
"mode": "0000644",
|
|
5330
|
-
"sha256": "
|
|
5335
|
+
"sha256": "fb03e051a1e20e28cc51ba3aca539e857532d0e85d6653b250b5fe1d4426dd37"
|
|
5331
5336
|
},
|
|
5332
5337
|
{
|
|
5333
5338
|
"path": "package.json",
|
|
5334
5339
|
"mode": "0000644",
|
|
5335
|
-
"sha256": "
|
|
5340
|
+
"sha256": "d60bf47b1cc591c6652e56edad2133c073801aa81373cdcf97f340030b310e11"
|
|
5336
5341
|
},
|
|
5337
5342
|
{
|
|
5338
5343
|
"path": "public-docs/index.html",
|
|
@@ -5662,7 +5667,7 @@
|
|
|
5662
5667
|
{
|
|
5663
5668
|
"path": "public-landing/index.html",
|
|
5664
5669
|
"mode": "0000644",
|
|
5665
|
-
"sha256": "
|
|
5670
|
+
"sha256": "45bb60cc5a5e2db12bc76426f3bf7990f9b18f64eb77603cd558f729e0252d71"
|
|
5666
5671
|
},
|
|
5667
5672
|
{
|
|
5668
5673
|
"path": "public-landing/index.probes.json",
|
|
@@ -7347,7 +7352,7 @@
|
|
|
7347
7352
|
{
|
|
7348
7353
|
"path": "src/module-api.js",
|
|
7349
7354
|
"mode": "0000644",
|
|
7350
|
-
"sha256": "
|
|
7355
|
+
"sha256": "4ec16435463ab64507aa74370bfd5c29057b91b2a7279595282bc1ff0b925bc6"
|
|
7351
7356
|
},
|
|
7352
7357
|
{
|
|
7353
7358
|
"path": "src/module-loader/catalog.js",
|
|
@@ -8947,7 +8952,7 @@
|
|
|
8947
8952
|
{
|
|
8948
8953
|
"path": "tests/landing_page.mjs",
|
|
8949
8954
|
"mode": "0000644",
|
|
8950
|
-
"sha256": "
|
|
8955
|
+
"sha256": "5ef9a789374abe826534ecc5ea0c71445a3fceef147ce1b910310f23f4b541cb"
|
|
8951
8956
|
},
|
|
8952
8957
|
{
|
|
8953
8958
|
"path": "tests/lib_sh_resolution.mjs",
|
|
@@ -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.
|
package/docs/adr/README.md
CHANGED
|
@@ -288,5 +288,6 @@ 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
|
+
| 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
292
|
|
|
292
293
|
> ⚠️ **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.
|