@bongos/core 1.19.433 → 1.19.435
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 +19 -14
- package/docs/adr/0215-the-recruiter-sliver-is-a-ceiling-not-a-step.md +3 -1
- package/docs/adr/0217-rank-gates-inviting-not-viewing.md +77 -0
- package/docs/adr/README.md +2 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/core-release-pipeline.md +6 -6
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
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.435",
|
|
6
|
+
"core_contract": "1.19.435",
|
|
7
|
+
"source_commit": "93f1acd4ec60f7f6d7c3241e716a7a2c25315f02",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-
|
|
9
|
+
"built_at": "2026-08-30T01:56:28.344Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
|
-
"docs_redacted":
|
|
12
|
+
"docs_redacted": 367,
|
|
13
13
|
"agent_docs_stubbed": 23,
|
|
14
14
|
"functional_verbatim": 1740,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2130,
|
|
20
|
+
"tree_sha256": "f47b962f793eb603e2233c230c31e64d83e1c9d0059a8e68f40c27badabb6f72",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -1572,17 +1572,22 @@
|
|
|
1572
1572
|
{
|
|
1573
1573
|
"path": "docs/adr/0215-the-recruiter-sliver-is-a-ceiling-not-a-step.md",
|
|
1574
1574
|
"mode": "0000644",
|
|
1575
|
-
"sha256": "
|
|
1575
|
+
"sha256": "091fd3e4792ecbdae16eea1f53c5d6a88114f47e9abf24f95bcb1c4b9ca8987d"
|
|
1576
1576
|
},
|
|
1577
1577
|
{
|
|
1578
1578
|
"path": "docs/adr/0216-a-fake-pool-interpreter-must-fail-loudly-not-silently-ignore.md",
|
|
1579
1579
|
"mode": "0000644",
|
|
1580
1580
|
"sha256": "779df4072b2096f1f6a106013a1600e694908c009f4608a9b6a86a82cf225d7d"
|
|
1581
1581
|
},
|
|
1582
|
+
{
|
|
1583
|
+
"path": "docs/adr/0217-rank-gates-inviting-not-viewing.md",
|
|
1584
|
+
"mode": "0000644",
|
|
1585
|
+
"sha256": "d1c5ebc00ed981f85dd8a3e57bf02caa454bc3b462969e72a7635661c02432e0"
|
|
1586
|
+
},
|
|
1582
1587
|
{
|
|
1583
1588
|
"path": "docs/adr/README.md",
|
|
1584
1589
|
"mode": "0000644",
|
|
1585
|
-
"sha256": "
|
|
1590
|
+
"sha256": "268c42d6fe8e3519b04bbbe9d3e6b14b6bd1c17f8d8612e75bb7f508ee63ebd9"
|
|
1586
1591
|
},
|
|
1587
1592
|
{
|
|
1588
1593
|
"path": "docs/api-reference.md",
|
|
@@ -1982,7 +1987,7 @@
|
|
|
1982
1987
|
{
|
|
1983
1988
|
"path": "docs/module-api-changelog.md",
|
|
1984
1989
|
"mode": "0000644",
|
|
1985
|
-
"sha256": "
|
|
1990
|
+
"sha256": "a143667876a68f55e726b5342461ebb87ef424c43b73de5d234f13df0ab9e333"
|
|
1986
1991
|
},
|
|
1987
1992
|
{
|
|
1988
1993
|
"path": "docs/modules-contract.md",
|
|
@@ -2087,7 +2092,7 @@
|
|
|
2087
2092
|
{
|
|
2088
2093
|
"path": "docs/recipes/core-release-pipeline.md",
|
|
2089
2094
|
"mode": "0000644",
|
|
2090
|
-
"sha256": "
|
|
2095
|
+
"sha256": "06b938c2ad38915d3ed92b20a94c57621ae835f617b01ab02fc01129cd154e2c"
|
|
2091
2096
|
},
|
|
2092
2097
|
{
|
|
2093
2098
|
"path": "docs/recipes/devbox-app-release.md",
|
|
@@ -5592,12 +5597,12 @@
|
|
|
5592
5597
|
{
|
|
5593
5598
|
"path": "package-lock.json",
|
|
5594
5599
|
"mode": "0000644",
|
|
5595
|
-
"sha256": "
|
|
5600
|
+
"sha256": "bbd8414f9a02067c7742dfbfdf0c5ec5a386330994822a47867d2d04b6b2b29e"
|
|
5596
5601
|
},
|
|
5597
5602
|
{
|
|
5598
5603
|
"path": "package.json",
|
|
5599
5604
|
"mode": "0000644",
|
|
5600
|
-
"sha256": "
|
|
5605
|
+
"sha256": "5cbdd7b7313ace6d7481d61088e1c29805ccd2df5ba21368f28bdfe4fcf7fd8e"
|
|
5601
5606
|
},
|
|
5602
5607
|
{
|
|
5603
5608
|
"path": "public-docs/index.html",
|
|
@@ -7727,7 +7732,7 @@
|
|
|
7727
7732
|
{
|
|
7728
7733
|
"path": "src/module-api.js",
|
|
7729
7734
|
"mode": "0000644",
|
|
7730
|
-
"sha256": "
|
|
7735
|
+
"sha256": "1ff4e0ce398b437fc6cfadfaede99b6b977e108e8cf820ef3b954d49163be38f"
|
|
7731
7736
|
},
|
|
7732
7737
|
{
|
|
7733
7738
|
"path": "src/module-loader/catalog.js",
|
|
@@ -67,6 +67,8 @@ Spec D3 says the sliver is "gated at the route by `project.recruit`" — and tha
|
|
|
67
67
|
|
|
68
68
|
**The audience is the owner's call, and it is filed rather than taken.** [Blocker 1000126](https://cloudbongos.com/builders#/blocker/1000126) asks whether the wider recruiting audience is authorized here, with the evidence and a recommendation (hold the floor; let `project.recruit` — grantable per instance — settle it, which is exactly the knob the question wants). Answering it moves **one line**, and the tests that pin the floor flip with it. Goal 1000045 C5 (*"project owners, not only Archons, browse builder profiles to recruit"*) pulls the other way, and that tension is what the blocker carries: C5 was written about the DIRECTORY, which shows public builders; this surface shows private ones.
|
|
69
69
|
|
|
70
|
+
**Answered 2026-08-29 — the floor stands.** The owner kept the archon floor, and [ADR 0217](<redacted>.md) D2 records why it is now the *consistent* answer rather than the merely safe one: the owner's rule from the same review is that **rank gates inviting, not viewing**, and the per-project invite floor they asked for also defaults to archon. The sliver is a recruiting disclosure of an account that asked not to be publicly visible, so it sits on the inviting side of that line. The C5 tension recorded above is resolved rather than deferred — C5 is about *browsing*, which ADR 0217 D1 makes ungated, and was never about disclosing private accounts. No code moved; the one-line swap remains available if the console wave's `project.recruit` atom later says otherwise.
|
|
71
|
+
|
|
70
72
|
The refusal for an unreachable *builder* is **404, never 403**: a 403 would confirm the handle exists, which is precisely the disclosure the opt-out removes. (A refusal of the *caller* is an ordinary rank 403 naming the floor it wanted, so the hall can render why.) And `Cache-Control: no-store`, the `/profiles/:handle` posture, because the rollup half is live-derived and hide must beat any cache (ADR 0171 D5) — the directory route gained the same header here, having been the last rollup read without it.
|
|
71
73
|
|
|
72
74
|
### D5 — The sliver grants nothing (ADR 0016), and that is enforced structurally
|
|
@@ -94,7 +96,7 @@ Both columns exist (migration `<redacted>.sql`); the activity table's `(github_i
|
|
|
94
96
|
⚠ **Honest limits, recorded rather than fixed:**
|
|
95
97
|
|
|
96
98
|
- **No UI consumes the sliver yet.** This ships the port, the route and the proof; the recruiting *view* is the console wave's (spec D6, `<redacted>.md` CT6) and the application queue's (task 1002972). The route is reachable and gated today; nothing in the hall calls it.
|
|
97
|
-
- **The audience is
|
|
99
|
+
- **The audience was unresolved when this shipped; it is settled now, and the floor did not move** ([blocker 1000126](https://cloudbongos.com/builders#/blocker/1000126), answered 2026-08-29 — see the D4 addendum above and [ADR 0217](<redacted>.md) D2). The archon floor was the conservative reading of an owner-signed spec rather than a call this ADR was entitled to make; the owner has since made it, and kept the floor. **What is still true:** the gate is *narrower* than goal C5 describes, so project owners cannot use the sliver today — ADR 0217 D1 resolves that tension by making C5's subject (browsing) ungated rather than by widening this surface, and `project.recruit` remains the eventual home. An earlier draft of this section called the shipped gate "narrower than the spec's atom" and "not a widening in any direction"; both were wrong, and wrong in the dangerous direction, because the SUBJECT set strictly grows: private accounts were on no recruiter surface at all before this.
|
|
98
100
|
- **The rank gate is a documented HOLDOUT against the module-gate migration rule** (BV1.R105/R107, ADR 0151): `modules/*/routes` gates are supposed to be permission atoms, and `tests/government_require_permission.mjs` enforces it from source. This route is listed in that test's `RETAINED` set with its full argument — the atom it wants is specified and owned by another wave, minting it forks the definition, borrowing a different archon atom would name the wrong authority on a privacy surface, and the sibling's door is wider than the signed floor. It is temporary by construction: spike 1002786 R3 removes the entry.
|
|
99
101
|
- **The two gates are spelled twice, on purpose.** An earlier draft called them "one gate expression … a one-line change reaching both"; they never were one, and now they are deliberately different.
|
|
100
102
|
- **A recruiting read added in another FILE would not trip the two-route guard.** D6's count is over `routes/scouting.js` alone, so the "both recruiting surfaces" copy could become false without reddening. Sweeping every route file for reach-joining reads is a structural guard of its own and belongs with the shared-predicate work, not here.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# ADR 0217 — Rank gates INVITING, not VIEWING: the owner's answers to the account-security review's five blockers
|
|
2
|
+
|
|
3
|
+
- **Status:** accepted
|
|
4
|
+
- **Date:** 2026-08-29
|
|
5
|
+
- **Task:** [task 1003358](https://cloudbongos.com/builders#/task/1003358) (ledger close-out, goal 1000045 — Public builder profiles)
|
|
6
|
+
- **Blockers answered:** [1000121](https://cloudbongos.com/builders#/blocker/1000121), [1000122](https://cloudbongos.com/builders#/blocker/1000122), [1000123](https://cloudbongos.com/builders#/blocker/1000123), [1000124](https://cloudbongos.com/builders#/blocker/1000124), [1000126](https://cloudbongos.com/builders#/blocker/1000126)
|
|
7
|
+
- **Relates to:** [ADR 0210](<redacted>.md) D2 (the instance-asserted owner door), [ADR 0215](<redacted>.md) D4 (the sliver's archon floor), [ADR 0213](<redacted>.md) (the consent axis), [ADR 0209](<redacted>.md) (the account-existence oracle), [ADR 0201](<redacted>.md) D3 (the `project.recruit` atom belongs to the console wave), [ADR 0016](<redacted>.md) (cross-project numbers are descriptive, never authority)
|
|
8
|
+
|
|
9
|
+
## Context
|
|
10
|
+
|
|
11
|
+
The 2026-08-29 account-security batch shipped fourteen tasks into goal 1000045 and left five questions it deliberately refused to answer for itself. Four were audience or consent decisions with visible product consequences; one was a legal page saying something the code did not do. All five went to the owner as blockers rather than being guessed, and this ADR records what came back.
|
|
12
|
+
|
|
13
|
+
They are recorded together because **four of the five turned out to be the same question**, and the owner's answer to one of them reframed the rest. That reframing is the decision worth keeping; the individual answers follow from it.
|
|
14
|
+
|
|
15
|
+
## Decision
|
|
16
|
+
|
|
17
|
+
### D1 — Rank gates the ability to INVITE. It does not gate the ability to VIEW.
|
|
18
|
+
|
|
19
|
+
Blocker 1000122 asked whether a project's other archons should count as project owners on the platform, so that a founder's delegates could reach the recruiting directory. It framed the choice as *widen the `owner` claim* versus *hand out `project.curate` by hand*. The owner declined both framings:
|
|
20
|
+
|
|
21
|
+
> "I think that rank should only gate the ability to invite, not the ability to view a community page. The community page in my mind will be more than just for scouting, it will be a place for people to view the achievements of others across projects. However the ability to invite said builders would be rank gated."
|
|
22
|
+
|
|
23
|
+
This is a cleaner line than either option offered, and it settles more than it was asked about. Seeing what someone has built across projects is **not** a privileged act — that data is public by consent, and the platform T&C already promises it is visible "at your handle's page, in the people directory, to project owners looking for people to work with, and to search engines". Reaching out to that person **is** an act with a cost to them, and that is where a floor belongs.
|
|
24
|
+
|
|
25
|
+
The delegation problem that prompted the blocker dissolves under this line rather than being solved by it. A founder does not need their teammate re-labelled an owner in the platform's vocabulary; they need their teammate able to invite. So:
|
|
26
|
+
|
|
27
|
+
**`membership_kind` is NOT widened.** `membershipKind()` in `src/bongos/auth-admission.js` continues to report `owner` for the instance's `FIRST_ADMIN` login alone. The wire contract every federated instance speaks is unchanged, the My Projects `owned` / `building on` split keeps its current meaning, and the cross-version window in which older and newer releases would have disagreed never opens. [Task 1003350](https://cloudbongos.com/builders#/task/1003350), which existed to build the widening, is abandoned.
|
|
28
|
+
|
|
29
|
+
**A per-project INVITE rank floor is built instead, defaulting to `archon`** ([task 1003362](https://cloudbongos.com/builders#/task/1003362)). It is that project's own rank ladder, set by that project's own archons — which is strictly better than the alternative the blocker offered, where a founder had to ask the platform for a `project.curate` grant per teammate. The floor is the project's; the platform holds no rank of its own for a project (ADR 0016).
|
|
30
|
+
|
|
31
|
+
**Viewing gains no rank gate**, and the surface it applies to is a new one: a cross-project achievements page ([task 1003363](https://cloudbongos.com/builders#/task/1003363)). Per the owner it **lives on cloudbongos.com, not the builders' hall variant** — so it is an apex page under `public-landing/`, inside the `apex-only` region that `stripApexOnly()` removes from child projects, not a `modules/hall-ui/` surface.
|
|
32
|
+
|
|
33
|
+
One thing that must not be conflated as that page is built, and it is the whole risk of it: **public consented profile data and the pre-accept recruiter sliver of a private account are different material.** The first is the page's subject. The second stays at the archon floor (D2 below) and must not leak onto the page in any form — not as a row, not as a facet count, not as a search-result absence that reveals presence.
|
|
34
|
+
|
|
35
|
+
### D2 — The recruiter sliver keeps its archon floor, and the floor is now the *consistent* answer rather than the merely safe one
|
|
36
|
+
|
|
37
|
+
Blocker 1000126 asked whether the D3 pre-accept sliver — the first surface that discloses a **private** account — should keep spec D3's declared archon floor, or reuse the wider door `/scouting` already has (`project.curate` at metic, OR any project owner). It shipped at the archon floor as the conservative reading, with the audience question filed rather than taken (ADR 0215 D4).
|
|
38
|
+
|
|
39
|
+
**The owner kept the archon floor.** No code changes; the swap remains one line in `modules/platform-identity/routes/scouting.js` if the console wave's `project.recruit` atom later says otherwise, and `tests/recruiter_sliver_boundary.mjs` continues to pin it.
|
|
40
|
+
|
|
41
|
+
What changed is the *reason*. ADR 0215 D4 held the floor defensively — narrowing after people have been disclosed is not recoverable, so hold and ask. Under D1 the floor is positively correct: the sliver is a recruiting disclosure of an account that asked not to be publicly visible, which is squarely on the **inviting** side of the line, and the invite floor the owner asked for **also defaults to archon**. The two answers agree, and they agree for the same reason. `project.recruit` — floor archon, widenable per instance through the governance tab — remains the right eventual home, and the per-project invite floor of D1 may well *be* that atom rather than a rival knob; whoever builds 1003362 must check that before shipping a second mechanism answering the same question.
|
|
42
|
+
|
|
43
|
+
The tension ADR 0215 D4 recorded against goal 1000045 C5 ("project owners, not only Archons, browse builder profiles to recruit") is resolved rather than merely noted: C5 is about **browsing**, which D1 makes ungated. It was never about disclosing private accounts.
|
|
44
|
+
|
|
45
|
+
### D3 — The platform T&C is corrected in words now and in behaviour next, and existing accounts are grandfathered with the grandfathering on the record
|
|
46
|
+
|
|
47
|
+
Blockers 1000123 and 1000124 are the consent pair, and 1000124 corrects 1000123 — read in that order.
|
|
48
|
+
|
|
49
|
+
**The words (1000124).** `/terms` said acceptance is recorded "by signing in here", but the stamp is written by exactly one statement reachable only through `GET /sso/authorize`, which a **child project** reaches by redirecting a browser to the hub. A builder who signs in at cloudbongos.com and never joins a project was never stamped — and because `terms_accepted_at IS NULL` makes `isAccountActive` false, their profile was invisible everywhere and their connections denied, permanently, under the no-backfill rule. The page was promising that builder the opposite of what they got.
|
|
50
|
+
|
|
51
|
+
The owner chose **both halves**: correct the sentence immediately ([task 1003353](https://cloudbongos.com/builders#/task/1003353)) so the page stops making a false promise while the real fix is built, and then **record acceptance at the hub's own sign-in too** ([task 1003361](https://cloudbongos.com/builders#/task/1003361)), closing the gap rather than wording around it. The second capture point goes through the existing `platform-identity.hub` seam rather than a core-to-module import, so child instances see no behaviour change.
|
|
52
|
+
|
|
53
|
+
**The grandfathering (1000123).** Nobody had an acceptance on record, because until ADR 0213 there was nothing to accept. The choice was to fail closed — existing public profiles go quiet until each builder next signs in — or to grandfather them. The recommendation was to fail closed, on the reasoning that grandfathering *records a consent that did not happen*, which is the precise fiction ADR 0213 exists to end.
|
|
54
|
+
|
|
55
|
+
**The owner chose to grandfather** ([task 1003360](https://cloudbongos.com/builders#/task/1003360)), having been shown that cost. Nothing goes dark.
|
|
56
|
+
|
|
57
|
+
The ledger is not allowed to absorb that cost silently, and on the follow-up the owner agreed: a grandfathered acceptance is **marked as grandfathered** — queryably distinct from one a human gave, via a recorded source rather than a magic timestamp — and `/terms` **says so**, adding a sentence that accounts existing before this version were accepted on their behalf at the date it took effect.
|
|
58
|
+
|
|
59
|
+
That combination is the decision worth recording. ADR 0213's honest property is not "every acceptance was a sign-in"; it is **the record never claims more than what happened**. Grandfathering with a marker and a disclosure keeps that property. Grandfathering silently would have broken it, and would have been the cheaper thing to build.
|
|
60
|
+
|
|
61
|
+
### D4 — `/access-requests/status` collapses to one `not_admitted` answer, and the CLI copy is fixed in the same change
|
|
62
|
+
|
|
63
|
+
Blocker 1000121 asked whether the route should stop distinguishing `pending` / `dismissed` / `none` to an unauthenticated caller. **The owner chose to collapse it** ([task 1003359](https://cloudbongos.com/builders#/task/1003359)) — the recommendation, and the fix ADR 0209 had already named in its own "When to revisit" section. The response becomes `admitted` plus a single `status` of `member` | `invited` | `not_admitted`.
|
|
64
|
+
|
|
65
|
+
Two things must stay true as it lands, and both are in the task.
|
|
66
|
+
|
|
67
|
+
**It does not close the oracle, and the work must not claim it does.** `admitted: true` still means "member or invited" on both routes, and that is the fact worth having for targeting. The task 1003339 rate limiter is what reduces enumeration. What collapsing narrows is what leaks about **applicants** — that someone applied here, and that we turned them down, which is not our fact to hand to anonymous callers.
|
|
68
|
+
|
|
69
|
+
**The cost is paid in the same change, not deferred.** `scripts/gds/login.js` is the only place in the entire codebase that tells a declined applicant they were declined. Collapsing makes that branch unreachable, so without a copy fix a declined person would watch dots for thirty minutes and then be told to re-run `bongos login` "once an Archon has approved you" — an approval that is never coming. Rewriting that timeout copy to be honest and non-promising is part of the same task, and it is better copy for the genuinely-pending case too.
|
|
70
|
+
|
|
71
|
+
## Consequences
|
|
72
|
+
|
|
73
|
+
- Four blockers that gated real work are answered, and the work is filed: 1003359, 1003360, 1003361, 1003362, 1003363, plus 1003353 auto-promoted from `blocked` to `ready` when 1000124 resolved.
|
|
74
|
+
- One shipped task is abandoned rather than built (1003350). The delegate widening it carried is not wanted; the wire contract stays as it is.
|
|
75
|
+
- `security_report` 1000027 — the live SSO check-in identity hole — is marked **fixed**, its fix having been live since core 1.19.417. It was deliberately held open while the hole was still serving, so that the ledger would not claim a fix before one existed.
|
|
76
|
+
- `verifyHubAssertion`'s purpose-confusion weakness moved from a line in ADR 0208 D2 and a to-do on task 1002285 to a filed security report (1000028, medium, latent). It has no call sites today and becomes an admission bypass the moment ADR 0201 D2.1's application vouch is minted, so the fix must land **before** the feature that arms it.
|
|
77
|
+
- **A new invariant to hold the line D1 draws:** any future gate added to a viewing surface, and any recruiting or invite surface added below the archon floor, contradicts this ADR and needs one that supersedes it. The community page (1003363) is the first surface built under it and is the one to watch — it is where the temptation to re-gate viewing, or to let a private account's sliver ride along with public rows, will actually arise.
|
package/docs/adr/README.md
CHANGED
|
@@ -300,8 +300,9 @@ This keeps the decision history honest and traceable.
|
|
|
300
300
|
| 0212 | [**The connections section is built on the profile page that exists, and it addresses a builder by asking the API to name them** ([task 1002296](https://cloudbongos.com/builders#/task/1002296), R20 · goal 1000045 criterion C6 — the UI half of the connections wave). The profile-page spec puts section 6 on the canonical `cloudbongos.com/u/<handle>`, which [task 1002806](https://cloudbongos.com/builders#/task/1002806) proved does not exist (no route, no page, no search entry, no sitemap row) and which belongs to goal 1000048 R14. Decisions: (1) **section 6 lands in the reserved slot R04 left on the hall profile page** (`modules/hall-ui/public/profile.{html,js}`), as a separate `profile-connections.js` so neither the page script nor `platform-identity.js` grows against its line ratchet, and it follows the CROSS-PROJECT section’s contract exactly — the markup ships `hidden` and reveals only where the hub answered, so an instance without `platform-identity` never renders it. It does not pre-empt the canonical page, which inherits the same three own-scope calls. (2) **Addressing: the page hands the API the `github_login` it holds and uses the `handle` the response echoes back for every write** — core `GET /builders/:id/profile` whitelists nine columns and `handle` is not one of them, and widening that whitelist is task 1002804’s canonical-URL work. `findAccountByHandle` already resolves current-or-previous handle and the route answers with the canonical one, which is the same move the API built for a stale Connect link. It fails closed into the deliberately indistinguishable 404 (unknown / provisional / private stranger), so the ONE honest gap is stated rather than discovered: **a twice-renamed handle has no connect button on this surface**, and it disappears when `/u/<handle>` lands. (3) Three rendering rules, enforced by `tests/profile_connections_ui.mjs` (real router over a WHERE-derived fake pool on a real socket, real client above it): `degree: null` renders "Out of network" and never "not connected", with copy that is BYTE-IDENTICAL whether `truncated` was set — varying it would rebuild the ADR 0207 L2 side channel in words; a sent request claims exactly `201 { status: 'pending' }`, so a re-ask swallowed by the 90-day cooldown simply shows "Connect" again next load with no explanation anywhere; and every count is the length of the list beside it, with `seen_at` and `truncated` never reaching the screen. Rejected: waiting for the hub page (it would block C6’s proof on another goal); building `/u/<handle>` here (the spec says only one such page gets built); adding `handle` to the core profile whitelist or to `cross_project` (the latter is `null` under `hide_stats`, which is exactly where the edge must still show — ADR 0202 D4 §3); resolving through public `GET /profiles/:handle` (it 404s for a private account the viewer IS connected to). Deferred and named: a public per-builder connections list (there is deliberately no such route), removal from the other person’s profile, and sections 7–8.](<redacted>.md) | platform identity / social graph / hall UI |
|
|
301
301
|
| 0214 | [**The canonical profile URL gets one resolver, and it stays dark until the page it names actually resolves** ([task 1002804](https://cloudbongos.com/builders#/task/1002804), P08 · goal 1000045 criterion C1 — the canonical/redirect wiring). Profile-page spec Decision 1 wants `rel=canonical` on the instance, a link-out to the hub, and a numeric-id redirect; `/u/<handle>` does not exist (goal 1000048 R14 is `backlog`, and R01 — the ADR that fixes its PATH — is unwritten, while R03 proposes moving platform pages under `/bongos/…`). Decisions: (1) **one resolver** (`modules/platform-identity/canonical-profile.js`) answers where a profile lives, and it answers `null` while the page 404s — a canonical naming a non-indexable URL consolidates nothing and risks the one page that works, and the address is not even settled yet; one boolean flips all three surfaces together, and a test reds if a `/u/` route lands while it is still false. (2) **`{{hubOrigin}}`** joins `applyBrandTokens` (own `publicOrigin` on the hub, `loadIdpConfig().origin` when federated, `''` otherwise) — the server-side stamp ADR 0171 D1 asked for — delivered as a `<meta>`, never inside an `href`, because an empty value in an href degrades to a same-host URL claiming to be the hub. (3) **`handle` joins `GET /builders/:id/profile`** under the EXISTENCE predicate (`isAccountVisible`), not the rollup one: `hide_stats` suppresses numbers and never addressability, and the `handle`+`cross_project: null` pair is already published unauthenticated by `GET /profiles/:handle`. This closes ADR 0212’s stated gap — a twice-renamed builder now has a connect button. (4) **The hub redirects and a federated instance canonicalizes**, which is how Decision 1’s two apparently contradictory clauses reconcile: the hub serves the same person at both URLs, a federated instance serves genuinely different content. (5) **The redirect is not an existence oracle** — its non-redirect branch is the ordinary working page, absent/provisional/private are byte-identical, and the one bit it exposes is what `/profiles/:handle` already answers with 200-vs-404.](<redacted>.md) | platform identity / SEO / hall UI |
|
|
302
302
|
| 0210 | [**The recruiting directory graduates on an instance-asserted ownership claim, and the recruiting opt-out gates the directory and nothing else** ([task 1002290](https://cloudbongos.com/builders#/task/1002290), R14, goal 1000045 criterion C5 — "project owners, not only Archons, browse builder profiles to recruit"). Two independent problems wore one task id. The reach flag `recruiter_discoverable` protected nobody — task 1002967 said so, and the Settings row stayed in the future tense because of it. And "widen the Archon-only /scouting" was already stale (ADR 0157 lowered `project.curate` to metic) while hiding the real question: **a project owner is not a rank**, and someone who registers a project is an Archon on their OWN instance and usually a bare `xenos` on the hub where the directory lives, so no floor reaches them without reaching every stranger. **D1** "project owner" on the hub is `<redacted>.membership_kind = 'owner'` — written by the PROJECT itself at `POST /sso/membership/check-in` from its own first-admin config, and refused unless the hub's own `signin_verified_at` witness stands (ADR 0205) — and never `platform_identity_projects.owner_github_id`, the hub-curated column ADR 0201 D3 names and forbids; the prohibition is about a hub-held list consulted before an ADMISSION on someone else's box, and this is the hub reading the instance's own mirrored claim before a read of the hub's OWN directory. **D2** the two doors are an OR in `modules/platform-identity/scouting-authz.js` (the `goal-authz.js` owner-or-rank shape, kept out of `platform-identity.js` at its size ratchet): ownership is asked first and a "no" falls through to the UNMODIFIED `requirePermission`, so a refusal keeps its exact code, `required`/`held` and floors map — the widening can only admit; the atom is spelled in the ROUTE's middleware list, because a gate hidden in the helper would make `route-rank-check.js` and `gen-api-docs.js` record this privileged route as `any-builder`; and a broken ownership read falls closed onto the rank door rather than admitting or 500ing. No atom is minted — `project.recruit` belongs to the console wave (spike 1002786 R3). **D3** `PAGE_GATES` gains an optional `alsoAdmits` naming a function on the `platform-identity.hub` port, declared on `requireProjectCuratePage` alone and consulted only after the permission check refused, so the `/people` shell admits exactly the two audiences its data route does; absent port, absent function or a throw all deny. **D4** the reach COALESCE gets its SQL twin in `account-visibility.js` (its sole owner) and composes into `scoutingListableSql` = rollup AND reach, which `listScoutingRollup` joins — but reach is deliberately NOT folded into `rollupVisibleSql`, because the community spec D3 says the opt-out governs the recruiting surface only and folding it down would silently empty a future public leaderboard. **D5** no migration: both columns exist and migration 003's `(github_id)` index already serves the probe. **D6** the ownership query JOINs `platform_identity_sso_clients` on `status='active'` — a membership row never expires and nothing ages it out, so suspending the client (the lever that already stops a project signing anyone in) had to revoke the read it derives, or suspending a rogue project would leave its owner reading every builder on the platform forever. The task's own adversarial review also found the sole-owner drift guard was matching only the UNALIASED spelling of the reach COALESCE — i.e. not the one that ships — and two fail-open defaults (a prototype-reachable `alsoAdmits`, and an alias guard resting on template evaluation order); all three fixed and mutation-checked. ⚠ Honest limits recorded, not fixed: the hall NAV still hides People from a project owner (task 1003349, sequenced with R15 which owns that surface), the `touchMembership` write path stays as ADR 0205 left it, and `openapi.json` cannot express "or owner". ⚠ And DELEGATES do not reach the directory yet (blocker 1000122 + task 1003350, linked) — an instance reports `owner` for its first admin only, so the registrant half works and the delegate half needs the instance to report `owner` for any archon, which changes a cross-version wire contract and a shipped bucketing; filed as blocker 1000122, and nothing here changes when it is answered. Rejected: lowering the floor further (a floor low enough to admit a registrant excludes nobody); a `project.owner` atom (ADR 0201 D3 rejected it); minting `project.recruit` here; gating on `owner_github_id`; folding reach into the rollup predicate; widening `membershipKind()` on our own judgement; shipping the route without the page.](<redacted>.md) | platform identity / authz + privacy |
|
|
303
|
-
| 0215 | [**The pre-accept recruiter sliver is a CEILING, not a step: a whitelist keyed by consent-and-reach, on the recruiting surface’s own door** ([task 1002968](https://cloudbongos.com/builders#/task/1002968), PV3 · goal 1000045 criteria C2/C5; privacy spec D3, owner-signed 2026-08-16). Privacy spec D2 has a row nothing could serve — **private · discoverable** — and ADR 0210 D4 named the reason: *“the full rollup is never its disclosure — the D3 sliver is”*. Decisions: **D1** the audience predicate is `recruiterSliverVisibleSql` = `accountActiveSql AND recruiterReachSql`, composed in `account-visibility.js` (its sole owner) and the ONE composed answer that deliberately **omits the existence axis** — ANDing `accountVisibleSql` would empty the sliver of exactly the account it serves; dropping it is safe only because reach is `COALESCE(recruiter_discoverable, visibility=’public’)`, so a private account reaches this surface only by its owner’s explicit opt-IN — and, after adversarial review, that opt-IN must be the one they MEANT: `setAccountPrivacy` now resets `recruiter_discoverable` to NULL when a PATCH turns an account private without naming reach, because a `true` set while PUBLIC meant “recruiters may see my public profile” and this predicate would have activated that stale consent silently. `profile_state=’active’` still floors it (a provisional account is never recruiter-visible) and `hide_stats` is absent, because hide changes the sliver’s SHAPE, never who appears in it; the guard PINS that existence-less predicate to its one reader, since a future public read joining it would look adopted while serving private accounts. **D2** the projection is a literal whitelist — `handle`, `display_name`, `avatar_url`, `disciplines`, `active_recently`, and under `hide_stats` the last two are **absent, not null**, with the activity query never issued at all (the cheapest way for a later edit to leak a number is for it to already be in hand). `last_active` is compared to the 30-day window IN SQL and only the boolean returns, so the date never enters the process; `github_id`/`github_login` are never KEYS, every count and any profile URL stay out — with one limit stated rather than glossed: the avatar D3 grants is GitHub’s own URL and embeds the account id, so the proof pins WHERE the id may appear instead of claiming an absence the code does not deliver. **D3** the handle is an ADDRESS, not a door — `GET /profiles/:handle` joins `accountVisibleSql` and 404s for the very account whose sliver was just read (asserted, with a 200 control), and a PREVIOUS handle resolves to nothing here unlike the public page’s 301, because a recruiting read has no shared links to preserve and a redirect would confirm a rename. **D4** one route, `GET /scouting/:handle`, beside the directory in `routes/scouting.js` but deliberately NOT sharing its gate: **no `project.recruit` atom is minted** (ADR 0201 D3 and ADR 0210 both reserve it for the console wave that defines it), and until it exists the sliver holds spec D1’s declared floor for that atom — `api.requireRank('archon')`. The first draft reused the directory’s door and that was WIDER than the owner-signed floor on both halves (`project.curate` at metic, OR any project owner — typically a bare xenos), on the first surface that discloses a PRIVATE account; narrowing after people are disclosed is not recoverable, widening later is. The audience question is the owner’s and is FILED, not taken — blocker 1000126, with the C5 tension it carries and a one-line swap either way. 404 never 403 (a 403 confirms the handle), `no-store` (ADR 0171 D5). **D5** it grants nothing (ADR 0016): the module is required by exactly ONE first-party server file — the route that serializes it — enumerated from source, and no rank/role/permission/membership travels with it. **D6** the Settings copy widens back from “the People directory” (narrowed by task 1002290 while this was unbuilt) to name **both** recruiting surfaces — “both” and not “every”, because a guard asserts `routes/scouting.js` mounts exactly two routes and that both readers compose `recruiterReachSql`. The half that was actually WRONG was the grant itself: the copy promised a hidden account “your name and nothing else” while the sliver also ships the AVATAR, never named the picture in the shown branch, and never told a private account this is the one thing a recruiter sees while their page answers “not found”. The guard now iterates SLIVER_KEYS from the module instead of checking transcribed phrases, so a sixth granted field cannot ship undescribed. **D7** no migration; the port lives beside `getPublicProfileExtras` in the MODULE, not the file, which is on the 1500-line ratchet. The proof is a whitelist over the module’s own exported key list, planted improbable sentinels asserted absent from the whole body with controls against vacuity, and a fake pool that reads boolean STRUCTURE — the house interpreter collects terms and ANDs them, so rewriting this predicate’s `AND` to `OR` (which in Postgres serves EVERY active account) passed all three test files green until each term was made to be CONSUMED and the residue required to be pure conjunction. That hole is still open in five sibling proofs (idea 1000877). Four further defects came out of the same review: the credits/karma sentinels sat on rows the sliver never reads, a “control” compared a fixture to itself, and the one-reader pin missed the predicate’s JS twin. ⚠ Honest limits: **no UI consumes it yet** (the recruiting view is the console wave’s and task 1002972’s); **the audience is
|
|
303
|
+
| 0215 | [**The pre-accept recruiter sliver is a CEILING, not a step: a whitelist keyed by consent-and-reach, on the recruiting surface’s own door** ([task 1002968](https://cloudbongos.com/builders#/task/1002968), PV3 · goal 1000045 criteria C2/C5; privacy spec D3, owner-signed 2026-08-16). Privacy spec D2 has a row nothing could serve — **private · discoverable** — and ADR 0210 D4 named the reason: *“the full rollup is never its disclosure — the D3 sliver is”*. Decisions: **D1** the audience predicate is `recruiterSliverVisibleSql` = `accountActiveSql AND recruiterReachSql`, composed in `account-visibility.js` (its sole owner) and the ONE composed answer that deliberately **omits the existence axis** — ANDing `accountVisibleSql` would empty the sliver of exactly the account it serves; dropping it is safe only because reach is `COALESCE(recruiter_discoverable, visibility=’public’)`, so a private account reaches this surface only by its owner’s explicit opt-IN — and, after adversarial review, that opt-IN must be the one they MEANT: `setAccountPrivacy` now resets `recruiter_discoverable` to NULL when a PATCH turns an account private without naming reach, because a `true` set while PUBLIC meant “recruiters may see my public profile” and this predicate would have activated that stale consent silently. `profile_state=’active’` still floors it (a provisional account is never recruiter-visible) and `hide_stats` is absent, because hide changes the sliver’s SHAPE, never who appears in it; the guard PINS that existence-less predicate to its one reader, since a future public read joining it would look adopted while serving private accounts. **D2** the projection is a literal whitelist — `handle`, `display_name`, `avatar_url`, `disciplines`, `active_recently`, and under `hide_stats` the last two are **absent, not null**, with the activity query never issued at all (the cheapest way for a later edit to leak a number is for it to already be in hand). `last_active` is compared to the 30-day window IN SQL and only the boolean returns, so the date never enters the process; `github_id`/`github_login` are never KEYS, every count and any profile URL stay out — with one limit stated rather than glossed: the avatar D3 grants is GitHub’s own URL and embeds the account id, so the proof pins WHERE the id may appear instead of claiming an absence the code does not deliver. **D3** the handle is an ADDRESS, not a door — `GET /profiles/:handle` joins `accountVisibleSql` and 404s for the very account whose sliver was just read (asserted, with a 200 control), and a PREVIOUS handle resolves to nothing here unlike the public page’s 301, because a recruiting read has no shared links to preserve and a redirect would confirm a rename. **D4** one route, `GET /scouting/:handle`, beside the directory in `routes/scouting.js` but deliberately NOT sharing its gate: **no `project.recruit` atom is minted** (ADR 0201 D3 and ADR 0210 both reserve it for the console wave that defines it), and until it exists the sliver holds spec D1’s declared floor for that atom — `api.requireRank('archon')`. The first draft reused the directory’s door and that was WIDER than the owner-signed floor on both halves (`project.curate` at metic, OR any project owner — typically a bare xenos), on the first surface that discloses a PRIVATE account; narrowing after people are disclosed is not recoverable, widening later is. The audience question is the owner’s and is FILED, not taken — blocker 1000126, with the C5 tension it carries and a one-line swap either way. 404 never 403 (a 403 confirms the handle), `no-store` (ADR 0171 D5). **D5** it grants nothing (ADR 0016): the module is required by exactly ONE first-party server file — the route that serializes it — enumerated from source, and no rank/role/permission/membership travels with it. **D6** the Settings copy widens back from “the People directory” (narrowed by task 1002290 while this was unbuilt) to name **both** recruiting surfaces — “both” and not “every”, because a guard asserts `routes/scouting.js` mounts exactly two routes and that both readers compose `recruiterReachSql`. The half that was actually WRONG was the grant itself: the copy promised a hidden account “your name and nothing else” while the sliver also ships the AVATAR, never named the picture in the shown branch, and never told a private account this is the one thing a recruiter sees while their page answers “not found”. The guard now iterates SLIVER_KEYS from the module instead of checking transcribed phrases, so a sixth granted field cannot ship undescribed. **D7** no migration; the port lives beside `getPublicProfileExtras` in the MODULE, not the file, which is on the 1500-line ratchet. The proof is a whitelist over the module’s own exported key list, planted improbable sentinels asserted absent from the whole body with controls against vacuity, and a fake pool that reads boolean STRUCTURE — the house interpreter collects terms and ANDs them, so rewriting this predicate’s `AND` to `OR` (which in Postgres serves EVERY active account) passed all three test files green until each term was made to be CONSUMED and the residue required to be pure conjunction. That hole is still open in five sibling proofs (idea 1000877). Four further defects came out of the same review: the credits/karma sentinels sat on rows the sliver never reads, a “control” compared a fixture to itself, and the one-reader pin missed the predicate’s JS twin. ⚠ Honest limits: **no UI consumes it yet** (the recruiting view is the console wave’s and task 1002972’s); **the audience was unresolved when this shipped and is settled now** (blocker 1000126, answered 2026-08-29 — the owner KEPT the archon floor; see ADR 0217 D2) — the floor is still narrower than goal C5 describes, so project owners cannot use the sliver today, a tension ADR 0217 D1 resolves by making C5’s subject (browsing) ungated rather than by widening this surface; the two-route guard counts only `routes/scouting.js`, so a recruiting read added elsewhere would not trip it; the route is unmetered (idea 1000876); and `active_recently` understates for a builder whose only project never reports a rollup. Rejected: flooring on `accountVisibleSql`; folding reach into `rollupVisibleSql`; minting `project.recruit`; **reusing the directory’s door**; leaving a stale `recruiter_discoverable = true` to read as consent after an account goes private; a second route file; null-instead-of-absent rollup keys; a profile URL / `github_login` / a project count / a raw `last_active`; 301-ing a previous handle; 403 for a non-discoverable builder.](<redacted>.md) | platform identity / privacy |
|
|
304
304
|
| 0213 | [**The platform T&C acceptance is the hub's own witness, written once, and consent becomes two columns** ([task 1002913](https://cloudbongos.com/builders#/task/1002913), goal 1000045). ADR 0141 §4 deferred consent for cross-project aggregation to "the platform T&C" and ADR 0171 D3 wrote its own definition around the moment that acceptance "can later be captured" - but no acceptance record existed, and while `/terms` DOES exist (task 1003251) it says nothing about work crossing between projects, so the public-profile premise ("already carries platform T&C consent") was untrue for every account. The scouting.js:6 pointer the task named had also been overwritten by ADR 0210, so the debt was going invisible rather than getting paid. Decision: `terms_accepted_at` + `terms_version` on `platform_identity_accounts` (migration 019), written ONLY by `upsertAccount`'s `activate` branch - one caller, /sso/authorize, where `activate: true` is a source literal and every field comes from the hub's resolved session (the ADR 0205 D3 property; a record a caller could assert is evidence of nothing). COALESCE makes it write-once, so the column keeps meaning WHEN consent was given. The consent axis in `account-visibility.js` becomes `profile_state = 'active' AND terms_accepted_at IS NOT NULL`, composed into every other predicate, which is what makes the tie structural rather than a promise. NO backfill (D4): existing active accounts re-accept on their next hub sign-in - ADR 0171 D3's own rule and P03's ratified rationale - and that visible consequence, plus the wording itself, went to the owner on blocker 1000123. Rejected: an acceptances TABLE (no event stream yet), a click-through gate (an interstitial in a redirect built to be invisible; additive later), backfilling (records a consent that never happened), deriving profile_state from the timestamp, and putting the predicate in `terms-acceptance.js` (a second owner of an effective-visibility answer, which ADR 0202 D4 forbids).](<redacted>.md) | platform identity / consent / privacy |
|
|
305
305
|
| 0216 | [**A fake-pool interpreter must FAIL LOUDLY on what it cannot model; the refusal is uniform, the interpreters stay separate** ([task 1003354](https://cloudbongos.com/builders#/task/1003354), folding in idea 1000877 from the adversarial review of [task 1002968](https://cloudbongos.com/builders#/task/1002968)). Nine test files carry a **fake pool** whose row filter is derived from each statement’s own WHERE text — the design that makes an isolation proof say something about the real query rather than about a canned response. It has one silent failure mode: a term the interpreter does not recognise is simply not applied, so the boundary goes vacuous and every assertion above it keeps passing. Each file guarded a piece of that (unread `$N`, unknown literal, unmodelled `IS NULL`) and every one of those is a **term** guard — and the term list is not the predicate. Three reviews found the same shape. **The missing term:** ADR 0213 made consent two columns and `scouting_owner_access_and_reach.mjs` modelled only the first; a NULL test is a third syntax neither sweep can see, so the suite reported 17 passed while modelling a predicate the database no longer had, inside a file whose own header calls that impossible. **The rewritten joiner:** an interpreter that COLLECTS recognised terms and ANDs them re-imposes an `AND` the SQL may no longer contain — rewrite a predicate’s `AND` to `OR` and Postgres serves every active account on the platform, or lets any builder revoke any session by id, while the fake answers exactly as before. **Recognition is not application:** ADR 0213’s own first draft checked only that a column name was recognised, and a mutation deleting the filter still passed. Decisions: **D1** the governing rule — an interpreter that cannot model a term must REFUSE the statement, never ignore the term; incompleteness is allowed, silence is not, because a refusal fails the build in the session that changed the SQL while a silent drop fails nothing ever. **D2** the mechanism is a CONSUMING parse: each recognised term is spliced out of the text and the residue must be a pure conjunction (`/^[\s()]*(?:AND[\s()]*)*$/`), which subsumes every term guard, catches `OR`/`NOT`/`CASE`, and retires ADR 0213’s `modelled`-set bookkeeping because an unapplied term is by construction still there; a legitimate `OR` (the rename window, the either-side clause, the expiry disjunction, the rescind guard) is consumed as a whole unit. **D3** the interpreters stay SEPARATE and only the refusal is uniform — the term vocabulary differs completely across six tables, so a shared registry would be a small SQL engine and would couple unrelated proofs; instead every copy carries the identical guard and its OWN calibration test, which is strictly better anti-drift than extraction (a helper proves the guard exists somewhere; a calibration test proves it is reached by that file’s own statements). **D4** a modelled term proves nothing until a fixture makes it observable — three fixtures added, each differing from its control in exactly one column, so the mutation that deletes the term has somewhere to land; where a file deliberately lacks one because another owns that matrix, the comment says so. **D5** every copy verified BY MUTATION against the real source — ten mutations, ten reds, each a 0-failure pass beforehand. ⚠ Honest limits: no fitness check asserts a NEW fake pool carries the guard (identifying “a fake pool” needs a heuristic, and a heuristic gate over test internals has a false-positive cost) — the per-file calibration tests hold the nine that exist and a tenth is a review question; `canonical_profile_url.mjs` is untouched because its residue must be EMPTY, strictly stronger already. Rejected: one shared interpreter module; a fitness heuristic; keeping `KNOWN_NULL_TESTS` + `modelled` beside the structure guard; parsing the boolean tree properly; accepting green as verification.](<redacted>.md) | testing / proof integrity |
|
|
306
|
+
| 0217 | [**Rank gates INVITING, not VIEWING: the owner’s answers to the account-security review’s five blockers** ([task 1003358](https://cloudbongos.com/builders#/task/1003358) · goal 1000045; answers blockers 1000121/1000122/1000123/1000124/1000126). The 2026-08-29 account-security batch shipped fourteen tasks and deliberately refused five audience/consent questions. Four turned out to be the same question, and one answer reframed the rest. Decisions: **D1** the governing line — *rank gates the ability to INVITE; it does not gate the ability to VIEW*. Seeing what someone built across projects is not privileged (the T&C already promises that data is public by consent, to search engines included); reaching out to them costs them something, and that is where a floor belongs. So `membership_kind` is **NOT** widened — `membershipKind()` stays FIRST_ADMIN-only, the federated wire contract is unchanged, the My Projects owned/building split keeps its meaning, the cross-version window never opens, and task 1003350 is **abandoned**. A **per-project INVITE rank floor defaulting to archon** replaces it (task 1003362), set by that project’s own archons — strictly better than a founder requesting a platform-side `project.curate` grant per teammate. Viewing gains no gate, on a new cross-project achievements page that per the owner **lives on cloudbongos.com, not the hall variant** (task 1003363) — so `public-landing/` inside the apex-only region, not `modules/hall-ui/`. The risk to hold as it is built: public consented profile data and a PRIVATE account’s recruiter sliver are different material, and the sliver must not ride along — not as a row, a facet count, or a search-result absence that reveals presence. **D2** the sliver keeps its **archon floor** (blocker 1000126), and ADR 0215 D4’s defensive hold becomes a positive agreement: the invite floor also defaults to archon, and the C5 tension dissolves because C5 was about browsing. Whoever builds 1003362 must check whether it IS the console wave’s `project.recruit` atom before shipping a rival knob. **D3** the consent pair — `/terms` said acceptance is recorded “by signing in here” but the stamp is reachable only via `/sso/authorize`, which a CHILD PROJECT reaches, so a hub-only builder was never stamped and (NULL `terms_accepted_at` → `isAccountActive` false) was invisible everywhere, permanently, under no-backfill. Owner took **both halves**: correct the sentence now (task 1003353) and **capture at the hub’s own sign-in** next (task 1003361), through the `platform-identity.hub` seam so child instances see no change. On grandfathering the owner chose **B — grandfather** (task 1003360) against the recommendation, having been shown that it records a consent which did not happen; the ledger does not absorb that silently, so a grandfathered acceptance is **marked** (queryably distinct from a sign-in, via a recorded source not a magic timestamp) and `/terms` **says so**. ADR 0213’s honest property is not “every acceptance was a sign-in” but **the record never claims more than what happened**, and that survives. **D4** `/access-requests/status` **collapses** to `admitted` + one `status` of `member`|`invited`|`not_admitted` (task 1003359, the fix ADR 0209 already named). It does **not** close the oracle and must not claim to — `admitted:true` still means member-or-invited; the 1003339 limiter is what reduces enumeration, and collapsing narrows what leaks about **applicants**. The cost is paid in the same change: `login.js` is the ONLY place telling a declined applicant they were declined, so its 30-minute timeout copy is rewritten to be honest rather than promising an approval that is never coming. ⚠ Also: report 1000027 (the live SSO check-in identity hole) marked **fixed**, held open until now so the ledger would not claim a fix while the hole still served; `verifyHubAssertion`’s purpose-confusion weakness filed as report **1000028** (medium, latent — zero call sites, becomes an admission bypass the moment ADR 0201 D2.1’s vouch is minted, so the fix must land BEFORE the feature that arms it). **New invariant:** any gate added to a viewing surface, or any recruiting/invite surface below the archon floor, contradicts this ADR and needs one that supersedes it.](<redacted>.md) | platform identity / privacy / permissions |
|
|
306
307
|
|
|
307
308
|
> ⚠️ **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.
|
|
@@ -1303,5 +1303,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1303
1303
|
landed since 1.19.431 with no explicit bump. run 33279328366. (task 1002620)
|
|
1304
1304
|
1.19.433 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1305
1305
|
landed since 1.19.432 with no explicit bump. run 33279772171. (task 1002620)
|
|
1306
|
+
1.19.434 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1307
|
+
landed since 1.19.433 with no explicit bump. run 33281881764. (task 1002620)
|
|
1308
|
+
1.19.435 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1309
|
+
landed since 1.19.434 with no explicit bump. run 33286790214. (task 1002620)
|
|
1306
1310
|
---------------------------------------------------------------------------
|
|
1307
1311
|
```
|
|
@@ -179,24 +179,24 @@ git tag --list 'core-v*' --contains <your-merge-sha> | sort -V | head -1
|
|
|
179
179
|
|
|
180
180
|
### Step 2 — the bump (the owner runs this line, via `!` in the session)
|
|
181
181
|
|
|
182
|
-
|
|
182
|
+
This paste is the **owner’s**: the permission classifier’s treatment of a mutating `sudo` over ssh **varies by session** — some sessions permit it, others block every ssh including read-only probes and `backup.js --trigger`. Don’t retry into a block; hand the owner the line. The `!` prefix also keeps ssh honest: a bare `ssh host` is non-interactive here — it connects, prints the MOTD, and exits — so the command must ride as an argument.
|
|
183
183
|
|
|
184
184
|
```
|
|
185
|
-
! ssh -i ~/.ssh/otb_builder jaxri@REDACTED_IP "sudo -n
|
|
185
|
+
! ssh -i ~/.ssh/otb_builder jaxri@REDACTED_IP "sudo -n bash -c 'set -a; . /etc/cloudbongos/npm.env; set +a; cd /home/lars/cloudbongos-instance && sudo -n -u lars --preserve-env=NPM_TOKEN env PGDATABASE=cloudbongos CLOUDBONGOS_INSTANCE_ROOT=/home/lars/cloudbongos-instance ./node_modules/.bin/bongos upgrade --to <VER> --registry --commit-pin --restart-cmd \"sudo systemctl restart cloudbongos.service\" --health-url http://127.0.0.1:3002/healthz'"
|
|
186
186
|
```
|
|
187
187
|
|
|
188
188
|
What each piece is for — each guards a failure that actually happened:
|
|
189
189
|
|
|
190
|
-
- **
|
|
190
|
+
- **The token lives in root-only `/etc/cloudbongos/npm.env`, and is handed DOWN to `lars`.** This is the step that used to be documented wrongly, so read it before improvising. `NPM_TOKEN` is **not** in lars’s environment, **not** in the interactive profile, and **not** in `/home/lars/.npmrc` — all three probed empty (2026-08-17). An earlier version of this runbook grepped `~/.npmrc` and therefore aborted on its own `[ -n "$NPM_TOKEN" ]` guard every time. Worse, it could not have worked even if that file were populated: the project `.npmrc` says `_authToken=<redacted>, which **overrides** lars’s user `~/.npmrc`, so the variable is the only channel. Hence the shape above: `set -a; . /etc/cloudbongos/npm.env; set +a` reads it as **root** (mode 600), and `--preserve-env=NPM_TOKEN` hands it to the unprivileged `lars` — exactly what systemd does for the sweep unit (`User=lars` + `EnvironmentFile=`). Without it npm falls back to the **public** registry, 404s on the private `@bongos/core`, and rolls back cleanly having taught you nothing.
|
|
191
191
|
- **Never put the token in the command line — `sudo` logs the full argv (task 1003218).** The shape above is safe *because* the token is read INSIDE the quoted script, so only the `grep` reaches argv. The tempting variant is not:
|
|
192
192
|
```
|
|
193
193
|
sudo -u lars -H env NPM_TOKEN="<redacted>" … bongos upgrade … # ← LEAKS
|
|
194
194
|
```
|
|
195
|
-
|
|
195
|
+
Passing "by reference" in the script text is NOT enough; what matters is what argv looks like after expansion. **The command above is the compliant form of exactly this advice** — it uses `--preserve-env=NPM_TOKEN`, so the token travels in the environment and never reaches any argv.
|
|
196
196
|
- **`--to <VER>` is required** — `bongos upgrade` refuses to guess a target.
|
|
197
|
-
-
|
|
197
|
+
- **Use `--restart-cmd`, NOT `--service` — and `--health-url` is what buys the automatic rollback.** The two restart flags are not interchangeable here. `restartService()` ([`scripts/gds/upgrade.js`](../../scripts/gds/upgrade.js)) runs a **bare** `systemctl restart <svc>` for `--service`, with no `sudo`; the upgrade runs as unprivileged `lars`, who has NOPASSWD sudo for the restart but cannot restart a unit directly — so `--service` fails at the last step, after the core is already swapped in. `--restart-cmd` lets you spell the `sudo` (it overrides `--service` when both are passed). Point the health URL at `http://127.0.0.1:3002/healthz` — **not** `:3000` (that is the port named in CLAUDE.md, not this instance's; read the truth from `systemctl cat cloudbongos.service`) and **not** the public URL, where a pre-launch `basic_auth` on the apex makes a healthy boot look failed and triggers a needless rollback that then misreports "health STILL failing after rollback". The service is fine; the probe was wrong. Health polls ~30s, so an immediate `fetch failed` is not a failure.
|
|
198
198
|
- **`--commit-pin` commits AND pushes the pin.** The instance is a pull-deploy repo: the next sync runs `git reset --hard origin/main`, which reverts an uncommitted pin, and `npm ci` then reinstalls the old core. Nothing errors. This is exactly how live sat 13 releases behind (task 1002712). The same applies to any tracked instance-config edit (`config/branding.json`): commit + push or the next deploy tick undoes it. A commit that fails to *push* saves nothing — deploy.sh resets to the remote.
|
|
199
|
-
-
|
|
199
|
+
- **`PGDATABASE=cloudbongos` is inline above, and that is what records the `core_upgrades` ledger row.** The migrate step resolves the instance DB itself, but the ledger write reads only the env; a form that omits it still upgrades successfully and just logs `ledger skipped (no DATABASE_URL or PGDATABASE)`. It is also **load-bearing beyond the ledger** — `migrate.sh` defaults to a *different* production DB (`example`), so name it explicitly rather than letting it default.
|
|
200
200
|
|
|
201
201
|
### Step 3 — verify on the box, not from your browser
|
|
202
202
|
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.435",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.435",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.435",
|
|
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
|
@@ -33,7 +33,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
33
33
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
34
34
|
// the entry to that file. Look for a version's history there, not here.
|
|
35
35
|
// ---------------------------------------------------------------------------
|
|
36
|
-
const CORE_VERSION = '1.19.
|
|
36
|
+
const CORE_VERSION = '1.19.435'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
37
37
|
|
|
38
38
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
39
39
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|