@bongos/core 1.19.1017 → 1.19.1019
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 +45 -25
- package/docs/adr/0208-application-lifecycle-the-handle-waits-and-the-vouch-has-no-minter.md +1 -0
- package/docs/adr/0332-offboarding-secret-scrub-semantics.md +59 -0
- package/docs/adr/0333-the-application-vouch-is-a-third-token-spent-once-and-bound-to-its-login.md +86 -0
- package/docs/adr/README.md +2 -0
- package/docs/module-api-changelog.md +9 -0
- package/docs/modules-contract.md +1 -0
- package/migrations/core_254_access_requests_applicant_handle.sql +23 -0
- package/modules/onboarding/routes/access-requests.js +31 -4
- package/modules/platform-identity/hub-keys.js +51 -32
- package/modules/platform-identity/routes/my-projects.js +33 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/auth-idp.js +93 -48
- package/src/bongos/auth.js +2 -1
- package/src/bongos/db.js +22 -0
- package/src/module-api.js +7 -1
- package/tests/application_echo.mjs +100 -1
- package/tests/application_lifecycle.mjs +145 -6
- package/tests/idp_device_exchange.mjs +174 -0
- package/tests/module_api.mjs +1 -0
- package/tests/offboarding_secret_scrub.mjs +115 -0
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.1019",
|
|
6
|
+
"core_contract": "1.19.1019",
|
|
7
|
+
"source_commit": "f26af09579091ebd9a314f3377b247ae4a9eaca6",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-25T02:04:21.850Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
|
-
"docs_redacted":
|
|
12
|
+
"docs_redacted": 528,
|
|
13
13
|
"agent_docs_stubbed": 26,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2441,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2995,
|
|
20
|
+
"tree_sha256": "789e036fa798799be7cb980ef9b3cbff7574791af643ef7ce2dfd93784258eda",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -1542,7 +1542,7 @@
|
|
|
1542
1542
|
{
|
|
1543
1543
|
"path": "docs/adr/0208-application-lifecycle-the-handle-waits-and-the-vouch-has-no-minter.md",
|
|
1544
1544
|
"mode": "0000644",
|
|
1545
|
-
"sha256": "
|
|
1545
|
+
"sha256": "84f46e47b23542bf6d4fb78c7e1914f1ebbbf9548e40159814c41bb4003f827e"
|
|
1546
1546
|
},
|
|
1547
1547
|
{
|
|
1548
1548
|
"path": "docs/adr/0209-one-budget-for-the-account-existence-oracle.md",
|
|
@@ -2154,6 +2154,16 @@
|
|
|
2154
2154
|
"mode": "0000644",
|
|
2155
2155
|
"sha256": "659b73c16da06bb150434de1eae1bcc1dfe4f6acf61ac0b2d787f3b52f617187"
|
|
2156
2156
|
},
|
|
2157
|
+
{
|
|
2158
|
+
"path": "docs/adr/0332-offboarding-secret-scrub-semantics.md",
|
|
2159
|
+
"mode": "0000644",
|
|
2160
|
+
"sha256": "0c11917e4e24c9cd462917fd82d79f4e9da43384cc04fc3d493374c23f887bb2"
|
|
2161
|
+
},
|
|
2162
|
+
{
|
|
2163
|
+
"path": "docs/adr/0333-the-application-vouch-is-a-third-token-spent-once-and-bound-to-its-login.md",
|
|
2164
|
+
"mode": "0000644",
|
|
2165
|
+
"sha256": "b89c4d4bd55e2fe48686cdeabe5bd7eb08c41d195e4d06ea422096d1b7466e0b"
|
|
2166
|
+
},
|
|
2157
2167
|
{
|
|
2158
2168
|
"path": "docs/adr/0334-the-recruiting-directory-lists-a-hidden-builder-by-name.md",
|
|
2159
2169
|
"mode": "0000644",
|
|
@@ -2172,7 +2182,7 @@
|
|
|
2172
2182
|
{
|
|
2173
2183
|
"path": "docs/adr/README.md",
|
|
2174
2184
|
"mode": "0000644",
|
|
2175
|
-
"sha256": "
|
|
2185
|
+
"sha256": "e221ffd25568bcc9ba803b80538d83b270be7f60e19e301aa1f630a28cf55c74"
|
|
2176
2186
|
},
|
|
2177
2187
|
{
|
|
2178
2188
|
"path": "docs/api-reference.md",
|
|
@@ -2742,12 +2752,12 @@
|
|
|
2742
2752
|
{
|
|
2743
2753
|
"path": "docs/module-api-changelog.md",
|
|
2744
2754
|
"mode": "0000644",
|
|
2745
|
-
"sha256": "
|
|
2755
|
+
"sha256": "cbd66fdd477a54c1b43489dfe1ff49fbce53ad3a9a661c741264e33da280aed9"
|
|
2746
2756
|
},
|
|
2747
2757
|
{
|
|
2748
2758
|
"path": "docs/modules-contract.md",
|
|
2749
2759
|
"mode": "0000644",
|
|
2750
|
-
"sha256": "
|
|
2760
|
+
"sha256": "2d354cb1b436f21c6dc7c1b4aed26d8aa0bc071db30dd9545c3301a5486e903d"
|
|
2751
2761
|
},
|
|
2752
2762
|
{
|
|
2753
2763
|
"path": "docs/onboarding/browser-terminal-guide.md",
|
|
@@ -3864,6 +3874,11 @@
|
|
|
3864
3874
|
"mode": "0000644",
|
|
3865
3875
|
"sha256": "968b1f4b893c9368da682fae0845b69ec367aa465ae608f60644c266818b874e"
|
|
3866
3876
|
},
|
|
3877
|
+
{
|
|
3878
|
+
"path": "migrations/core_254_access_requests_applicant_handle.sql",
|
|
3879
|
+
"mode": "0000644",
|
|
3880
|
+
"sha256": "040de8cddf1a8740ac7e97fb35572e56494a5a3b51ce8873887f71759f6a3b9d"
|
|
3881
|
+
},
|
|
3867
3882
|
{
|
|
3868
3883
|
"path": "modules/agents/CLAUDE.md",
|
|
3869
3884
|
"mode": "0000644",
|
|
@@ -6407,7 +6422,7 @@
|
|
|
6407
6422
|
{
|
|
6408
6423
|
"path": "modules/onboarding/routes/access-requests.js",
|
|
6409
6424
|
"mode": "0000644",
|
|
6410
|
-
"sha256": "
|
|
6425
|
+
"sha256": "301c45e4f72024c7b1507245a9fccf4388224bb544ad747712c69155d41d1861"
|
|
6411
6426
|
},
|
|
6412
6427
|
{
|
|
6413
6428
|
"path": "modules/onboarding/routes/onboarding.js",
|
|
@@ -6462,7 +6477,7 @@
|
|
|
6462
6477
|
{
|
|
6463
6478
|
"path": "modules/platform-identity/hub-keys.js",
|
|
6464
6479
|
"mode": "0000644",
|
|
6465
|
-
"sha256": "
|
|
6480
|
+
"sha256": "a8dcce2d4be3d041c349c59cfc0eab2171a08869225f3ccfdb525c70faffb8eb"
|
|
6466
6481
|
},
|
|
6467
6482
|
{
|
|
6468
6483
|
"path": "modules/platform-identity/invite-authz.js",
|
|
@@ -6652,7 +6667,7 @@
|
|
|
6652
6667
|
{
|
|
6653
6668
|
"path": "modules/platform-identity/routes/my-projects.js",
|
|
6654
6669
|
"mode": "0000644",
|
|
6655
|
-
"sha256": "
|
|
6670
|
+
"sha256": "56700357273c03d412afce1ef9937ba811257d0c5110f08d757bbef3e517a416"
|
|
6656
6671
|
},
|
|
6657
6672
|
{
|
|
6658
6673
|
"path": "modules/platform-identity/routes/profile.js",
|
|
@@ -8287,12 +8302,12 @@
|
|
|
8287
8302
|
{
|
|
8288
8303
|
"path": "package-lock.json",
|
|
8289
8304
|
"mode": "0000644",
|
|
8290
|
-
"sha256": "
|
|
8305
|
+
"sha256": "f457f1b6e75e6cf6f1cd7460ce5c96231ce42c4fb13d13ff28ec18f57ecf9cde"
|
|
8291
8306
|
},
|
|
8292
8307
|
{
|
|
8293
8308
|
"path": "package.json",
|
|
8294
8309
|
"mode": "0000644",
|
|
8295
|
-
"sha256": "
|
|
8310
|
+
"sha256": "5491fa2631ec3752039762f489ce05649b32c094ce05cbfc92434ec5a91f858e"
|
|
8296
8311
|
},
|
|
8297
8312
|
{
|
|
8298
8313
|
"path": "public-docs/index.html",
|
|
@@ -10047,12 +10062,12 @@
|
|
|
10047
10062
|
{
|
|
10048
10063
|
"path": "src/bongos/auth-idp.js",
|
|
10049
10064
|
"mode": "0000644",
|
|
10050
|
-
"sha256": "
|
|
10065
|
+
"sha256": "fb4d7e17e113cdb41e118d3ecaefc6dc81676dcb780deb3f13532bd998b1412d"
|
|
10051
10066
|
},
|
|
10052
10067
|
{
|
|
10053
10068
|
"path": "src/bongos/auth.js",
|
|
10054
10069
|
"mode": "0000644",
|
|
10055
|
-
"sha256": "
|
|
10070
|
+
"sha256": "f35226f4ab906fa8dbcd688e1c429ce9f161e616f6b5c4a96dee3cebdf33e4b1"
|
|
10056
10071
|
},
|
|
10057
10072
|
{
|
|
10058
10073
|
"path": "src/bongos/cascade-dispatch.js",
|
|
@@ -10077,7 +10092,7 @@
|
|
|
10077
10092
|
{
|
|
10078
10093
|
"path": "src/bongos/db.js",
|
|
10079
10094
|
"mode": "0000644",
|
|
10080
|
-
"sha256": "
|
|
10095
|
+
"sha256": "f89fa7199401add96ec042169c5c2657fcff120385e36b899da604d011b696d8"
|
|
10081
10096
|
},
|
|
10082
10097
|
{
|
|
10083
10098
|
"path": "src/bongos/github-repos.js",
|
|
@@ -10337,7 +10352,7 @@
|
|
|
10337
10352
|
{
|
|
10338
10353
|
"path": "src/module-api.js",
|
|
10339
10354
|
"mode": "0000644",
|
|
10340
|
-
"sha256": "
|
|
10355
|
+
"sha256": "accba490951de95be993592e8b695ea260e965b2baf1c0f8dee32940b1871496"
|
|
10341
10356
|
},
|
|
10342
10357
|
{
|
|
10343
10358
|
"path": "src/module-loader/catalog.js",
|
|
@@ -10607,12 +10622,12 @@
|
|
|
10607
10622
|
{
|
|
10608
10623
|
"path": "tests/application_echo.mjs",
|
|
10609
10624
|
"mode": "0000644",
|
|
10610
|
-
"sha256": "
|
|
10625
|
+
"sha256": "eec3a0404674e2a02d3ba7f38ec9fee92e5f06870240161d98986d525cb03d3f"
|
|
10611
10626
|
},
|
|
10612
10627
|
{
|
|
10613
10628
|
"path": "tests/application_lifecycle.mjs",
|
|
10614
10629
|
"mode": "0000644",
|
|
10615
|
-
"sha256": "
|
|
10630
|
+
"sha256": "975389846058a123344a6cd56d2355b606c86e2ce59a48981782f61204090096"
|
|
10616
10631
|
},
|
|
10617
10632
|
{
|
|
10618
10633
|
"path": "tests/architect.mjs",
|
|
@@ -12677,7 +12692,7 @@
|
|
|
12677
12692
|
{
|
|
12678
12693
|
"path": "tests/idp_device_exchange.mjs",
|
|
12679
12694
|
"mode": "0000644",
|
|
12680
|
-
"sha256": "
|
|
12695
|
+
"sha256": "3a57c5a71dae3b35d90fb360efc8ae39ca889f18dbc18d504a68cbc01f0c219c"
|
|
12681
12696
|
},
|
|
12682
12697
|
{
|
|
12683
12698
|
"path": "tests/idp_federation.mjs",
|
|
@@ -13047,7 +13062,7 @@
|
|
|
13047
13062
|
{
|
|
13048
13063
|
"path": "tests/module_api.mjs",
|
|
13049
13064
|
"mode": "0000644",
|
|
13050
|
-
"sha256": "
|
|
13065
|
+
"sha256": "02f08f0c3449d0d3d7e3df4f2116aba31a01732b7f083dad0d1d11358c90ff53"
|
|
13051
13066
|
},
|
|
13052
13067
|
{
|
|
13053
13068
|
"path": "tests/module_api_lazy.mjs",
|
|
@@ -13174,6 +13189,11 @@
|
|
|
13174
13189
|
"mode": "0000644",
|
|
13175
13190
|
"sha256": "41d390570f7ecc8c902f2e4d3d75ca9e4077d565150b2491500108d618456ae3"
|
|
13176
13191
|
},
|
|
13192
|
+
{
|
|
13193
|
+
"path": "tests/offboarding_secret_scrub.mjs",
|
|
13194
|
+
"mode": "0000644",
|
|
13195
|
+
"sha256": "7458dfd09a8c6f62633bb51d38f8c3d97a185a89b927ccc34ab2d9535f42c307"
|
|
13196
|
+
},
|
|
13177
13197
|
{
|
|
13178
13198
|
"path": "tests/onboard.mjs",
|
|
13179
13199
|
"mode": "0000644",
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
- **Status:** accepted
|
|
4
4
|
- **Date:** 2026-08-29
|
|
5
5
|
- **Task:** [task 1002284](https://cloudbongos.com/builders#/task/1002284) (R08, goal 1000045 — criteria C3/C4)
|
|
6
|
+
- **Resolved by:** [ADR 0333](0333-the-application-vouch-is-a-third-token-spent-once-and-bound-to-its-login.md) (task 1002285) — D1's handle and D2's minter, verifier tightening and jti choice are built there.
|
|
6
7
|
- **Relates to:** [ADR 0201](0201-cross-project-application-vouched-row-live-record.md) (the design this builds — D1, D3.2, D4), [ADR 0016](0016-trust-boundary-server-enforced-permissions.md) (descriptive analytics are never authority), [ADR 0141](<redacted>.md) (identity federated, authority local), [ADR 0171](0171-profile-rollup-join-and-consent.md) D2 (the non-numeric principal guard), [ADR 0116](0116-api-error-envelope-and-code-taxonomy.md) (a refusal names the action available), [ADR 0202](0202-connections-graph-and-bounded-degree.md) D4 (the `account-visibility.js` "its own small file" precedent this reuses)
|
|
7
8
|
|
|
8
9
|
## Context
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# 0332 — Offboarding scrubs stored secrets: an event, not a direct write, and reactivation restores neither
|
|
2
|
+
|
|
3
|
+
- **Status:** Accepted
|
|
4
|
+
- **Date:** 2026-09-24
|
|
5
|
+
- **Deciders:** spike task [1003338](https://cloudbongos.com/builders#/task/1003338), goal 1000110
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
|
|
9
|
+
`deactivateBuilder` (`src/bongos/db.js`, the transaction behind `/builder-exit`) is the graceful-offboarding path: it releases active claims, drops rank to `xenos`, and — since the `#871` fix (security report task 1000004) — revokes every live `builder_sessions` row in the same transaction. It is explicitly designed to be **lossless**: credits, `credit_log`, shipped tasks, and session history all survive untouched.
|
|
10
|
+
|
|
11
|
+
It does not, however, touch two third-party credentials the platform holds **on the builder's behalf**:
|
|
12
|
+
|
|
13
|
+
1. **The GitHub OAuth repo token** (`github_oauth_tokens`, one row per builder, written by `upsertGithubToken` at sign-in for the onboarding wizard's repo picker). `deleteGithubToken` already exists (`src/bongos/db.js`) and is called from `routes/github.js` on a 401/invalidation — a *separate* inline `DELETE` inside `getGithubToken` lazily sweeps an expired row on read — but `deleteGithubToken` itself is never called from offboarding. The table's own migration comment (`migrations/core_192_github_oauth_tokens.sql`) calls this "the auth system's OWN handshake token," i.e. core-owned. Its `ON DELETE CASCADE` never fires because offboarding never deletes the `builders` row — it only flips `status`.
|
|
14
|
+
2. **The per-builder Gemini BYOK key** (`builders.gemini_key_enc` / `gemini_key_last4` / `gemini_key_set_at`, AES-256-GCM via `src/bongos/secret-box.js`, migration `122`). Both secrets are encrypted at rest with a server-env-only master key, so a DB dump alone cannot read either — the gap is **latent** for takeover, but a real data-retention/data-minimization defect: an offboarded person's third-party credential is retained indefinitely with no purge path.
|
|
15
|
+
|
|
16
|
+
The Gemini key is a special case: **its CRUD moved out of core into the `art-pipeline` module under ADR 0102**, specifically to stop core importing domain logic (`setBuilderGeminiKey`/`clearBuilderGeminiKey`/`getBuilderGeminiKeyRow` were three of the seven kernel→domain leaks that ADR closed). Core resolves the read side today through the `art.getBuilderGeminiKeyRow` port (`routes/me.js`), degrading to `{ enc: null, last4: null, set_at: null }` when the module isn't installed — which it isn't in this checkout (`config/modules.neutral.json` ships `art-pipeline: false` by default, and `modules/art-pipeline/` does not exist in this repo). The column lives in core's `builders` table (core-owned schema), but the module owns the logic that writes it — the exact split ADR 0102 chose.
|
|
17
|
+
|
|
18
|
+
Two decisions the task description left open, and the deliverable of this spike:
|
|
19
|
+
|
|
20
|
+
1. **Does a module own the key store, and if so, how does core reach across the boundary to scrub it?**
|
|
21
|
+
2. **What does reactivation restore?**
|
|
22
|
+
|
|
23
|
+
## Decision
|
|
24
|
+
|
|
25
|
+
**1. The GitHub token is deleted synchronously, in-transaction — the Gemini key is scrubbed via a new best-effort event, not a direct write.**
|
|
26
|
+
|
|
27
|
+
- In `deactivateBuilder`'s existing `withTx` block, call `deleteGithubToken({ builderId, p: client })` alongside the existing claim-release and session-revocation steps. `github_oauth_tokens` is core's own table; this is no different from the writes already there.
|
|
28
|
+
- Post-commit, alongside the existing `seams.emitAsync('builder.box-reconcile-needed', …)` call, emit a new event: **`builder.secrets-scrub-needed`**, payload `{ builderId, actor }`. This is the teardown port for any module that stores a builder-keyed secret against a core column or its own table — today, that's `art-pipeline`'s Gemini key. `art-pipeline` (when installed) registers `api.on('builder.secrets-scrub-needed', …)` and nulls `gemini_key_enc`/`gemini_key_last4`/`gemini_key_set_at` itself, mirroring exactly how `dev-box` already listens for `builder.box-reconcile-needed` (`modules/dev-box/routes/box.js`).
|
|
29
|
+
- Core must **not** `UPDATE builders SET gemini_key_enc = NULL, …` directly. That column is schema-owned by core but logic-owned by the module by deliberate design (ADR 0102) — reaching in to null it from `db.js` re-opens the exact kernel→domain leak that ADR closed, and it hard-codes one module's column names into core, which breaks the moment a different or additional module owns a different builder-keyed secret.
|
|
30
|
+
- On a vanilla instance (the default: every optional module off), the event has no listener and is a no-op — matching the graceful-degradation posture the `art.getBuilderGeminiKeyRow` port already uses. The GitHub-token delete still runs unconditionally, since it is core's own table regardless of which modules are installed.
|
|
31
|
+
|
|
32
|
+
**2. Reactivation restores neither secret.**
|
|
33
|
+
|
|
34
|
+
`reactivateBuilder` flips `status` back to `active` and explicitly leaves `rank` at `xenos` — "an Archon re-promotes deliberately." The same posture applies to both stored secrets: a returning builder is **not** silently handed back their old GitHub token or Gemini key. They re-enter their own BYOK key from Settings exactly as a first-time builder would, and the GitHub token is re-obtained the next time they run the sign-in flow that requests it. `reactivateBuilder` already never touched either store, so there is no functional change here — this decision adds an explicit comment there (`src/bongos/db.js`) so a future edit doesn't "helpfully" restore a stored credential.
|
|
35
|
+
|
|
36
|
+
Rationale for the deny-by-default reactivation stance:
|
|
37
|
+
|
|
38
|
+
- Secrets held on a builder's behalf are not "work" — the lossless guarantee (credits, shipped tasks, session history) is about *earned* state. A third-party credential is closer to a session token than to a credit ledger, and sessions are already revoked (not preserved) on exit.
|
|
39
|
+
- A gap between deactivation and reactivation is an elevated-risk window by definition (unknown reason for departure; possible compromise). Silently reviving a dormant credential on reactivation reopens exactly the exposure the scrub exists to close, with no signal to the returning builder that anything was restored.
|
|
40
|
+
- Matches `secret-box.js`'s own fail-closed ethos ("a malformed secret fails CLOSED — set/decrypt is disabled, never weak").
|
|
41
|
+
|
|
42
|
+
## Alternatives considered
|
|
43
|
+
|
|
44
|
+
- **Null the Gemini-key columns directly with raw SQL inside `deactivateBuilder`'s transaction.** Rejected: re-opens the kernel→domain leak ADR 0102 deliberately closed, and hard-codes `art-pipeline`'s column names into core. Also stops working the moment any *other* module wants to store a builder-keyed secret — the event scales, a hard-coded `UPDATE` does not.
|
|
45
|
+
- **Do nothing until the server-side memory store (criterion `#54`) lands.** Rejected: unrelated scope — #54 is about the server holding another builder's session/memory corpus, not third-party credential hygiene — and the gap is real today, independent of whether #54 ever ships.
|
|
46
|
+
- **Delete the `builders` row on offboarding (let `ON DELETE CASCADE` clear `github_oauth_tokens` for free).** Rejected: directly contradicts the explicit lossless design of graceful exit (`/builder-exit`'s own doc: "nothing the builder earned or shipped is ever deleted") — credits, `credit_log`, and attribution on shipped tasks all depend on the row surviving.
|
|
47
|
+
- **A cron/offline sweep that scrubs secrets for builders who have been inactive N days.** Rejected: introduces a whole new mechanism (a scheduled sweep) where a synchronous delete + an event mirroring the already-idiomatic `builder.box-reconcile-needed` pattern does the job with no new moving parts, and it closes the window immediately at offboarding time rather than leaving one open until the next sweep.
|
|
48
|
+
- **Auto-restore the Gemini key on reactivation** (treat it like credits — lossless). Rejected per the rationale above: a stored third-party credential is not earned state, and silently reviving it on reactivation defeats the point of scrubbing it in the first place.
|
|
49
|
+
|
|
50
|
+
## Consequences
|
|
51
|
+
|
|
52
|
+
- `deactivateBuilder` grows one direct call (`deleteGithubToken`) and one new best-effort `emitAsync`, both inside the pattern already established by the box-reconcile teardown — no new mechanism, no new dependency. This decision lands that change directly in this task (task 1003338): the in-transaction GitHub-token delete, the new event emission, a `docs/modules-contract.md` row for `builder.secrets-scrub-needed`, and tests (`tests/offboarding_secret_scrub.mjs`) covering the delete, the event payload, a no-listener instance, and that reactivation triggers neither.
|
|
53
|
+
- On this checkout, `art-pipeline` isn't vendored (`modules/art-pipeline/` doesn't exist here), so the module-side listener that would actually clear `gemini_key_enc`/`last4`/`set_at` cannot be implemented in this repo. Follow-up task 1004239 is narrowed to that listener, for whichever repo/host carries `modules/art-pipeline/`.
|
|
54
|
+
- **The Gemini-key scrub is single-shot and best-effort, honestly.** A crash between commit and the `emitAsync` call, an absent listener, or a listener error leaves `gemini_key_enc` in place with no automatic repair — and `deactivateBuilder` throws `ALREADY_INACTIVE` on a second run, so `/builder-exit` cannot be re-invoked to retry it. This is the same reliability profile the existing `builder.box-reconcile-needed` event already has in this codebase (its own repair path, `box-dormant-reclaim`, is cadence-less — "run by hand" per `scripts/gds/fitness.js`'s cron-cadence check), so this decision does not regress the bar, but it does not raise it either. Task 1004239 additionally scopes a periodic reconcile check (any `status='inactive'` builder whose module-owned secret is still set gets it cleared) as the durable backstop — which doubles as the one-time backfill for builders already offboarded before this change ships, so no separate backfill script is needed.
|
|
55
|
+
- Reactivation semantics for secrets are now explicit and symmetric with rank: neither is auto-restored. This is a (minor, and correct) behavior narrowing for any builder who reactivates within the GitHub token's 1h TTL — before this decision that token might have still been live if reactivation raced the TTL; after, it is always gone.
|
|
56
|
+
|
|
57
|
+
## Follow-up
|
|
58
|
+
|
|
59
|
+
Task [1004239](https://cloudbongos.com/builders#/task/1004239) — narrowed to the `art-pipeline` module-side listener for `builder.secrets-scrub-needed` (clearing `gemini_key_enc`/`last4`/`set_at`) plus the periodic reconcile backstop described above. The core-side change (GitHub-token delete, event emission, contract doc, tests) shipped with this task.
|
package/docs/adr/0333-the-application-vouch-is-a-third-token-spent-once-and-bound-to-its-login.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# ADR 0333 — The application vouch is a third token kind, spent once, bound to the login it names, and carries a handle only past the consent floor
|
|
2
|
+
|
|
3
|
+
- **Status:** accepted
|
|
4
|
+
- **Date:** 2026-09-24
|
|
5
|
+
- **Task:** [task 1002285](https://cloudbongos.com/builders#/task/1002285) (R09, goal 1000045 — criterion C4)
|
|
6
|
+
- **Relates to:** [ADR 0201](0201-cross-project-application-vouched-row-live-record.md) D2 (the vouch, the header, the handle), [ADR 0208](0208-application-lifecycle-the-handle-waits-and-the-vouch-has-no-minter.md) D1 + D2 (the handle waits; the minter that cannot express it), [ADR 0260](0260-the-application-is-the-consent-and-the-echo-is-the-gate.md) (the live consumer, built dormant), [ADR 0141](<redacted>.md) §1 (the signed assertion), [ADR 0144](0144-federated-single-logout-backchannel.md) (the logout token), [ADR 0016](0016-trust-boundary-server-enforced-permissions.md) (a vouch is never authority), [ADR 0091](0091-bounding-the-kernel-and-db-carve.md) §2 (boundary-forced duplication)
|
|
7
|
+
|
|
8
|
+
## Context
|
|
9
|
+
|
|
10
|
+
ADR 0201 D2 decided that an application relayed by the hub carries a hub-signed vouch in a header, so a project's reviewer can safely see the applicant's cross-project record beside the name. ADR 0208 D2 then found that the vouch could not be built as D2.1 wrote it: `mintHubAssertion` has no `purpose`, and — worse — `verifyHubAssertion` accepted *any* identity payload, purpose or not, so a captured vouch would have verified as a device sign-in. ADR 0260 built everything downstream of the vouch (the D7 applicant view, the echo-gated `POST /sso/applicant-profile`, the queue's live read keyed on `applicant_github_id`) and left it dormant, because nothing wrote that column.
|
|
11
|
+
|
|
12
|
+
This task writes it. Four choices in the writer are not obvious, and ADR 0208 D2 explicitly asked for one of them to be *chosen* rather than inherited.
|
|
13
|
+
|
|
14
|
+
## Decision
|
|
15
|
+
|
|
16
|
+
### D1 — A third mint/verify pair over one shared envelope, on both sides
|
|
17
|
+
|
|
18
|
+
`mintHubApplicationVouch` (hub) and `<redacted>` (instance) carry `purpose: 'application'` **and** identity (`sub`, `applicant_login`, `handle`). The three token kinds now differ only in their claims, so each side mints and verifies them through one envelope — `signHubToken` in `hub-keys.js`, `verifyHubToken` in `auth-idp.js` (signature first, Ed25519 hardcoded, issuer, audience, expiry) — rather than three copies of the same twenty lines of crypto. Each verifier then demands exactly what the other two lack:
|
|
19
|
+
|
|
20
|
+
| Kind | Identity | `purpose` |
|
|
21
|
+
|---|---|---|
|
|
22
|
+
| login assertion | required | **forbidden** |
|
|
23
|
+
| logout token | absent | `backchannel-logout` |
|
|
24
|
+
| application vouch | required, as `applicant_login` | `application` |
|
|
25
|
+
|
|
26
|
+
The existing error messages are preserved byte-for-byte (the envelope takes the kind's name as a label).
|
|
27
|
+
|
|
28
|
+
**The vouch's login rides as `applicant_login`, not `github_login`, and that is what makes it safe on cores that have not upgraded.** Every login verifier any core ever shipped demands `github_login`, so a vouch relayed to a project still on an older pin is refused there as a device sign-in too — the purpose refusal in D2 only exists on cores that run this release, and the hub cannot know which pin a project runs. Found by review before ship: with a shared `github_login` claim, an older project that ignored the header could have exchanged a captured vouch for a CLI session within its ~120s life. The two defences are independent, and `tests/idp_device_exchange.mjs` asserts each on its own.
|
|
29
|
+
|
|
30
|
+
### D2 — `verifyHubAssertion` refuses ANY `purpose`, not a list of known ones
|
|
31
|
+
|
|
32
|
+
ADR 0208 D2 named the window: the tightening is free only while no minter puts a purpose on an identity token, and this task is the one that starts doing so. The check is presence (`hasOwnProperty`), so `purpose: null`, `''` and any future marker are refused too. A list of forbidden purposes would reopen the device-login replay the day someone adds a fourth kind and forgets the list.
|
|
33
|
+
|
|
34
|
+
### D3 — The jti is CHOSEN: a separate single-use store, and a vouch must carry one
|
|
35
|
+
|
|
36
|
+
One vouch vouches one application. The store is its own register — not the CLI assertion map it could have inherited — for the reason the logout store gave: no two token kinds share dedupe state. All three registers now come from one `jtiStore()` factory, so they cannot drift apart. The vouch is spent when it is read, before the INSERT: a write that then fails answers 500, the hub reports the project unreachable, and the applicant's retry mints a fresh vouch — so a spent-but-unwritten vouch costs nothing. `<redacted>` also **requires** a `jti` (its siblings tolerate a missing one) and **requires an audience**: the shared envelope skips the audience check when no client id is given, and a vouch checked that way would accept one minted for any project on the platform.
|
|
37
|
+
|
|
38
|
+
### D4 — The vouch must name the login the request names; otherwise the row is written unvouched
|
|
39
|
+
|
|
40
|
+
The body's `github_login` is a string anyone may type. Without the match, any valid vouch could be stapled to a request naming someone else, and the reviewer would read "Identity vouched" beside a name the vouch never confirmed — the exact impersonation surface ADR 0201 exists to close. A mismatch, like every other failure (absent, forged, expired, foreign, replayed, no hub key), writes the unvouched row: **never a refusal** (ADR 0201 D2.3).
|
|
41
|
+
|
|
42
|
+
### D4b — A vouched re-application takes over an UNVOUCHED pending row for its own login
|
|
43
|
+
|
|
44
|
+
The one-open-row index means a later application for a login that already has a pending row cannot insert. Left alone, anyone who filed an unvouched request under a well-known builder's login first — off the landing page, no header needed — would pre-empt that builder's real, vouched application for as long as the row sat there: the reviewer would see "Name unvouched" beside the squatter's name and note. So when the INSERT hits the index and the request carried a verified vouch for that login, the route UPDATEs the pending row with the vouch **and replaces its display name and note** with the vouched request's own, so the words beside "Identity vouched" are the vouched applicant's. The UPDATE's own `WHERE applicant_github_id IS NULL` means a row that is already vouched is never re-stamped, and an unvouched re-application still changes nothing (D1.4). Found by review before ship; the first draft named it as a limit instead.
|
|
45
|
+
|
|
46
|
+
### D5 — The handle is identity, not record; it rides only past the consent floor, and it is stored
|
|
47
|
+
|
|
48
|
+
`applicant_handle` (`core_254`) is written in the same statement as `applicant_github_id` and `vouched_at`, so the three can never disagree. ADR 0260 D4 says nothing about the applicant is stored — that sentence is about the **record**, which is still resolved live on every render; the handle is in the class of `github_login` and `applicant_github_id`, which the row already holds.
|
|
49
|
+
|
|
50
|
+
The hub puts a handle in the vouch only when the account clears `isAccountActive` (active **and** the platform terms accepted) — the same floor the applicant view applies — so the vouch never discloses a handle the reviewer's own queue would not show. A **private** account's handle does ride: applying is the consent (ADR 0260 D2), and its D3 sliver carries the handle anyway. The identity itself comes from the hub session, not the account row, so a provisional account is still vouched — just handle-less. The instance re-checks the handle against the hub's handle charset (a boundary-forced mirror of `normalizeHandle`, driven against it in `tests/idp_device_exchange.mjs`) and stores null for anything else.
|
|
51
|
+
|
|
52
|
+
**Storing it does not outlive a withdrawal, because nothing withdraws a handle.** The floor it passed is write-once in both halves — `profile_state` never demotes from `active`, and `terms_accepted_at` is COALESCE-stamped once (ADR 0213) — and neither `hide_stats` nor going private withholds a handle on any surface (the sliver carries it; `hide_stats` suppresses numbers, never addressability). So the stored handle never shows a reviewer more than the live applicant view would, except after a rename (below).
|
|
53
|
+
|
|
54
|
+
Since ADR 0260 moved the record read server-side and keyed it on `github_id`, the handle is no longer the lookup key ADR 0201 D2.5–6 needed. It is what the reviewer's queue can call the applicant when the hub is unreachable, and it is returned only by the archon-gated `GET /access-requests`.
|
|
55
|
+
|
|
56
|
+
### D6 — On the hub the vouch is best-effort, never a gate
|
|
57
|
+
|
|
58
|
+
A hub with no signing key sends no header and reads nothing — the project writes the unvouched row it always wrote. A failed account read still vouches the identity, handle-less. A mint failure sends no vouch. None of them delays or fails the application. The echo — which is what authorizes the project's later profile read — is written by the same relay on the same `requested` outcome, so a vouch never arrives without the echo it needs, except when the echo write itself fails (below).
|
|
59
|
+
|
|
60
|
+
### D7 — The instance reaches it through a narrow doorway port
|
|
61
|
+
|
|
62
|
+
`readApplicationVouch(token, { githubLogin })` joins the doorway beside `<redacted>`. Core supplies the hub public key and this instance's client id; the onboarding module passes a token and a login and can neither substitute a key nor re-address the audience.
|
|
63
|
+
|
|
64
|
+
## Consequences
|
|
65
|
+
|
|
66
|
+
- The ADR 0260 path lights up with no UI or route change: a vouched row's `applicant_github_id` drives the live profile read, and `watch.js` already renders the pill and the profile block.
|
|
67
|
+
- It lights up only when **both** ends run this release: the hub mints, the project verifies and has `core_254`. Either alone is inert and safe — an old project ignores the header, an old hub sends none.
|
|
68
|
+
- The approve path and `hasInvitedAccessRequest` are unchanged and still name no vouch column; `tests/application_lifecycle.mjs` now asserts that for a row written by the real writer, not only a seeded one.
|
|
69
|
+
|
|
70
|
+
## Honest limits
|
|
71
|
+
|
|
72
|
+
- **The vouch travels with every relay, including to a project whose door then refuses.** The header is minted before the project answers, so an invite-only or unpublished project's server receives the account id and (past the consent floor) the handle of someone who pressed Apply there. It learns no more than the body already tells it — the login and display name — plus the handle and the numeric GitHub id, both public-shaped identity, and it was the applicant who chose to knock.
|
|
73
|
+
- **The jti store is per-process memory.** A restart inside the ~120s window forgets spent vouches; the audience binding stays the primary control, and a replay can only re-assert the identity it already carried at the project it was minted for.
|
|
74
|
+
- **The handle is a snapshot.** A rename after applying is not followed; the hub's previous-handle 301 covers one, and the live profile block shows the current handle whenever the hub answers.
|
|
75
|
+
- **A vouched row can still show no record** when the relay's best-effort echo write failed, since ADR 0260 D3 gates the profile read on the echo. That is the degrade the queue already renders.
|
|
76
|
+
- **No Postgres executes the new INSERT in CI** — the ADR 0208 limit stands. The fake pool in `tests/application_lifecycle.mjs` matches the column list and `VALUES` exactly, so dropping or reordering a vouch column reds the suite; the migration is additive and idempotent.
|
|
77
|
+
|
|
78
|
+
## Rejected
|
|
79
|
+
|
|
80
|
+
- **A `purpose` argument on `mintHubAssertion`.** ADR 0208 D2's reason: it would look like D2.1 shipped while leaving the device-login replay open.
|
|
81
|
+
- **Reusing the CLI assertion's jti map.** Shared dedupe state across token kinds is what the logout store already declined.
|
|
82
|
+
- **Refusing an invalid or mismatched vouch.** Hands an attacker a way to make applications fail and locks out older hubs (ADR 0201 D2.3).
|
|
83
|
+
- **Taking the row's login from the vouch instead of the body.** A mismatch means something is wrong; silently renaming the request to whoever the vouch names would hide it.
|
|
84
|
+
- **Upgrading a squatted row but keeping its name and note.** It would stamp "Identity vouched" beside words someone else may have typed.
|
|
85
|
+
- **Asking the project's version before minting, to skip older pins.** A second round-trip on every application to learn what a claim name (D1) already guarantees on every pin.
|
|
86
|
+
- **Sending the handle regardless of consent.** A provisional or no-terms account discloses nothing on any other surface; the vouch would have been the one exception.
|
package/docs/adr/README.md
CHANGED
|
@@ -423,6 +423,8 @@ This keeps the decision history honest and traceable.
|
|
|
423
423
|
| 0329 | [**The rendered fit check is a kit floor and an advisory ship-check row, not a grader worker** ([task 1004203](https://cloudbongos.com/builders#/task/1004203), goal 1000095 — UI quality). Nothing in the pipeline rendered a page, so titles clipped by character count ran past their cards (task 1003480, task 1004204) and a second `.mSec` child would have landed on the first (task 1003078), each found only in a browser. Two new kit floors, `text-fit` (text measured word by word past its box; SVG text past its card rect) and `sibling-overlap` (in-flow, block-level, text-bearing siblings), run in every `render.js` render; `scripts/gds/render-check.js` renders the pages a branch changed at 1280 + 390 and `/ship-check` shows them as an ADVISORY row that never flips its exit (ADR 0162). Not a grader worker: that would put a browser, fixtures and a harness on the grader host for signal the builder already saw; the artist gate (ADR 0241) stays the human look before deploy.](0329-the-rendered-fit-check-is-a-kit-floor-and-an-advisory-ship-check-row.md) | ui-design / kit + ship-check |
|
|
424
424
|
| 0330 | [**An ask is answered where it was asked** ([task 1004216](https://cloudbongos.com/builders#/task/1004216), goal 1000095 — BONGOS-V2), amending [ADR 0187](0187-collab-four-decisions.md) §4a. `lifecycle_004` let a builder ASK for help and let the **asker** mark their own row answered; the person being **asked** had no write at all, so the answer happened somewhere Bongos cannot see and the row sat open until its author remembered it — six asks addressed to the owner had been open between four and thirteen days on 2026-09-23, every one of them answerable. That was not an oversight: §4a deferred conversation to the Agora and a test named *"there is deliberately NO reply thread"* enforced it. **The deferral lost its addressee** — task 1002946 is ABANDONED and goal 1000064 ARCHIVED (verified against the live DB), so the rule had stopped meaning "wait for the Agora" and started meaning "an ask stays unanswerable forever to keep a promise to something that does not exist". **Decisions:** (1) an ask carries a thread of replies, APPEND-ONLY in the SCHEMA rather than by convention — no `updated_at`, `edited_by`, `deleted_at` or `parent_reply_id`, no reactions, no mentions, and a trigger that refuses UPDATE and DELETE outright, because a thing you can edit, nest, react to and address at people is a chat and that is what §4a declined; any builder may reply, since the board read is already project-wide and the good case is somebody you did not ask knowing the answer; (2) the **ADDRESSEE** may close an ask (`answered`), widening an asker-only UPDATE that was coherent only while nothing could be said on the row — but `withdrawn` does NOT widen, since "I no longer need this" is the asker's sentence alone and widening it would be a way to make an ask vanish unanswered; `settled_by` records which of the two closed it, and ADR 0187 §3's consent posture is untouched — closing ends a question, it does not accept work; (3) `/collab-review` posts the reply and **leaves the ask open** (owner decision), since an answer the reader has not read is exactly what an unattended run must halt on. **Consequences:** a reply cannot be taken back, by design; the FK **RESTRICTS rather than cascades** — the first draft cascaded, which reads right and was wrong, because a cascade is a plain DELETE the trigger refuses, so deleting an answered ask failed outright (caught by applying the migration to a real Postgres, not by reading it); the old test was REPLACED, not deleted, so the record that the restraint was deliberate survives; and replies inherit the pull-not-push posture, so nothing still tells an asker they were answered. Rejected: a single `answer` column (cheapest and unambiguously not a chat, but a follow-up would have nowhere to go except a second ask, splitting one conversation across two rows); waiting for the Agora (abandoned — waiting means forever); letting anyone close any ask (an open question is somebody's, and a stranger ending it should not be one click away).](0330-an-ask-is-answered-where-it-was-asked.md) | collab / lifecycle / schema |
|
|
425
425
|
| 0331 | [**The sky is the ideator's home, and the project sky takes the goal map's page** ([task 1004229](https://cloudbongos.com/builders#/task/1004229), goal 1000095 — BONGOS-V2), amending [ADR 0227](0227-a-task-network-map-is-refused-the-goal-graph-is-the-map.md) and decision 11 of `docs/specs/<redacted>.md`. The owner reviewed an interactive prototype of the ideator sky and the project sky over five rounds on 2026-09-24 and adopted it AS IS — copy, layout, colours, tour text, toggles, interactions — which contradicts two things already written down, so they are rewritten rather than leaving the build graded against a contract nobody means any more. **Decisions:** (1) the sky is the ideator's HOME surface — it replaces *Your thinking*, and the project sky replaces the goal map page — so "never load-bearing" and "arrives last" are DROPPED as a sequencing hedge from before there was a design to look at; the rule that survives at full force is the accessibility half, that the sky is never the ONLY path to anything it shows (the list drawer — the room — is one key away, and every drawn thing is a row in it); (2) ADR 0227 is AMENDED, not reversed — its measured refusal of a whole-corpus task map stands, and the project sky is §1's own prescription rather than its refusal: the goal graph is the entry point, tasks are drawn only INSIDE one goal past a zoom threshold (the per-goal drill-down §10.5 measured readable at 85.2px pitch worst case), below that they are dust density on the rings with NO identities, and the goal map's table survives as the sky's ledger drawer (§6's table equivalent kept); the nav slot the goal map was deliberately denied is GRANTED to the project sky, under Goals (owner, 2026-09-24), so part 4 changes the page and the test asserting no slot together; (3) the five states and the two channels are UNTOUCHED — criterion `ideator-sky-tells-the-truth` names a model, not a picture, and this amendment changes the picture's standing only; (4) the two HEURISTICS in the prototype — an idea's origin guessed from its text, and "born of a thought" found by scanning text for idea ids — must not ship as ledger facts: parts 3 and 4 hide both layers, part 5 replaces them with a `filed_via` column and a real born-of read. **Consequences:** `docs/design/mocks/sky/sky.template.html` is the PIXEL CONTRACT for parts 2–5, and every deviation a gate or the file ratchet forces is listed in that part's ship notes; no snapshot JSON or built page is ever committed (it carries real task and idea titles and this repo is public); ADR 0264's deleted goal "Living project sky" is superseded by this work under Working area 3. Rejected: shipping the sky as an extra and keeping "never load-bearing" (makes the adopted home surface unroutable); reversing ADR 0227 (its refusal is measured and still correct — the sky needs its drill-down shape, not its repeal); shipping the heuristics behind a disclaimer (a labelled guess is still a guess in a picture whose promise is that it cannot be faked).](0331-the-sky-is-the-ideators-home.md) | design / ideator / sky |
|
|
426
|
+
| 0332 | [**Offboarding scrubs stored secrets: an event, not a direct write, and reactivation restores neither** (spike [task 1003338](https://cloudbongos.com/builders#/task/1003338), goal 1000110 — BONGOS-V2). `deactivateBuilder` revokes sessions but never scrubs the two third-party credentials the platform holds on a departing builder's behalf: the `github_oauth_tokens` row and the per-builder Gemini BYOK key (`gemini_key_enc`/`last4`/`set_at`, module-owned by `art-pipeline` per [ADR 0102](0102-finish-bounding-the-kernel.md)). **Decisions:** (1) `deleteGithubToken` is called synchronously inside `deactivateBuilder`'s existing transaction — the table is core's own; (2) a new best-effort event, `builder.secrets-scrub-needed`, is emitted post-commit (mirroring the existing `builder.box-reconcile-needed` pattern) so `art-pipeline` — or any future module storing a builder-keyed secret — can clear its own store without core reaching across the module boundary ADR 0102 drew; (3) reactivation restores NEITHER secret, symmetric with rank ("not auto-restored… re-promotes deliberately") — a returning builder re-enters their own key and re-derives a fresh GitHub token. **Rejected:** a direct `UPDATE` nulling the Gemini-key columns from core (re-opens the ADR 0102 leak); deleting the `builders` row to get `ON DELETE CASCADE` for free (breaks the explicit lossless design of graceful exit); a cron sweep (a new mechanism where an event already fits); auto-restoring the key on reactivation (a stored credential is not earned state). The core-side change (GitHub-token delete, event emission, contract doc, tests) shipped with this task; follow-up [task 1004239](https://cloudbongos.com/builders#/task/1004239) is narrowed to the `art-pipeline` module-side listener plus a periodic reconcile backstop.](0332-offboarding-secret-scrub-semantics.md) | security / privacy / modules |
|
|
427
|
+
| 0333 | [**The application vouch is a third token kind, spent once, bound to the login it names, and carries a handle only past the consent floor** ([task 1002285](https://cloudbongos.com/builders#/task/1002285), goal 1000045 — criterion C4), resolving [ADR 0208](0208-application-lifecycle-the-handle-waits-and-the-vouch-has-no-minter.md) D1 + D2. The hub's join relay now carries a signed application vouch in `<redacted>`, so the ADR 0260 live-record path is no longer dormant. `mintHubApplicationVouch` / `<redacted>` share one envelope with the login and logout tokens, and each verifier demands what the other two lack; `verifyHubAssertion` now refuses any `purpose`, closing the device-login replay. The jti is a chosen, separate single-use store; a vouch must carry a jti and an audience, and must name the login the request names, or the row is written unvouched (never refused); a vouched re-application takes over an unvouched pending row for its login. The vouch carries its login as `applicant_login`, so even an un-upgraded core refuses it as a login. `applicant_handle` (`core_254`) is identity, not record, and rides only past the consent floor.](0333-the-application-vouch-is-a-third-token-spent-once-and-bound-to-its-login.md) |
|
|
426
428
|
| 0334 | [**The recruiting directory lists a hidden builder by name, and hide changes a row's shape, not whether it exists** ([task 1002291](https://cloudbongos.com/builders#/task/1002291), R15 — goal 1000110). Privacy spec D2 (owner-signed) lists an active, public, hidden account on the recruiter surface "name-only (if not opted out)"; the directory dropped it outright, making hide a second opt-out while Settings told that builder recruiters would see their name and picture. `GET /scouting` now answers two tiers: `builders` (ranked, `scoutingListableSql`, unchanged) and `name_only` (`scoutingNameOnlySql` = the same predicate with the hide term flipped, so the tiers partition the directory and only the opt-out removes anyone), projected to `github_login`, `display_name`, `avatar_url` by `scouting-name-only.js`. The existence floor stays (a private account is the archon-floor D3 sliver's, never the directory's); presence never depends on activity; the only order is the login; the predicate is pinned to that one reader and its three columns. The hall's `/people` renders the tier as its own panel, never ranked, found by name, never by a role filter. Rejected: fixing only the copy; trailing rows in the ranked ledger; listing private discoverable accounts by name.](0334-the-recruiting-directory-lists-a-hidden-builder-by-name.md) | privacy / recruiting / hall |
|
|
427
429
|
| 0335 | [**An invite is a pre-approved row on the project's own instance, and `project.recruit` says who may send one** ([task 1002813](https://cloudbongos.com/builders#/task/1002813), R1 of the project admin console wave, goal 1000110 — BONGOS-V2). Supersedes ADR 0141 §5's "Invitations (deferred slice)" paragraph only. Formalises spike task 1002786's three decisions and reconciles them with six weeks of shipping: the instance invite route (task 1003044), the PATCH rescind (ADR 0201 D4.2) and the composed join door (ADR 0247) already exist, and the hub invite's dead end is now reachable from the creation wizard. **Decisions:** (D1) authority lives on the project's own instance, and the instance IS the project; the hub is identity, relay, echo, notification and links, and never writes a project's admission row; (D2) a recruit invite is an `access_requests` row with `status='invited'`, so signing in is the acceptance act and the gate is untouched; the row gains `kind ∈ (application, invite)` and `created_by` (the inviter, which a rescind would otherwise overwrite), reuses `note`, backfills exactly (`created_at = resolved_at`), and neither column ever enters an admission read; (D3) one new grantable atom, `project.recruit` (`system:false`, floor archon, widened per instance through the existing governance tab), guards the invite, the outstanding list, the invite rescind and the queue read, while `access_request.review` keeps the verdict; because inviting is admitting, a recruiter without the review atom may not approve or re-admit an APPLICANT through the invite route (409 to the queue); the hub sliver moves onto the atom, and the hub directory and hub invite KEEP `project.curate`, revising ADR 0210/0215/0248, since swapping the directory would raise a gate on a viewing surface (ADR 0217). **Task map:** R2 (task 1002814) = the migration, the invite writes and the sender list, still on `access_request.review`; R3 (task 1002815) = mint the atom, move the gates, the 409 clause, the sliver swap and the pins, and it lands after R2; R5 (task 1002817) is already delivered, so close it into task 1002331; task 1003362 folds into R3. Rejected: a new invitations table; the hub pending row as the invite; widening `access_request.review` instead of minting an atom; a per-project settings row for the floor; letting a recruiter's invite approve applications; `system:true`; swapping `project.curate` on the hub directory.](0335-an-invite-is-a-pre-approved-row-on-the-projects-own-instance.md) | governance / onboarding / platform-identity |
|
|
428
430
|
| 0336 | [**A guild is a hub-side group whose members each consent twice, and its collective record is only ever the sum of what those members already show** ([task 1002299](https://cloudbongos.com/builders#/task/1002299), goal 1000110 — the R23 design spike for goal 1000045's C7/C8/C9). No platform guild existed (every "guild" in the tree is Discord's). Decisions: (1) three hub tables in `platform-identity`, in their own `guilds.js` (the 1,500-line ratchet), distinct from instance-local `goal_members`: guilds (slug, name, `visibility ∈ public|unlisted` with **no default**), members (`membership_kind ∈ owner|member`, never `role` per ADR 0174; a partial unique index holds exactly one owner; a per-member `shown_publicly`), and requests; (2) two consents to join, no self-join, through ADR 0202's connections lifecycle verbatim (silent decline, 90-day swallowed re-ask, 30-day read-time expiry); `accountActiveSql` floors the feature; an **unlisted guild 404s to everyone on the public read, members included**, and takes invites only, because a slug is guessable; (3) the public roster is `shown_publicly AND accountVisibleSql`, and widening a guild to public changes no member's flag but the flipping owner's, so consent given to a small audience is never reused for a large one; (4) **the privacy resolution, pending the owner's ratification:** a member is counted iff shown AND `rollupVisibleSql`, read-time, `no-store`, never stored, and N and M are both lengths of lists the reader can see. The guild record is therefore always a sum of numbers already on members' own public profiles, so nothing can be subtracted out. All-or-nothing is rejected (one member blanks everyone and hiding becomes costly), as is per-member consent to count hidden numbers (it breaks "hide_stats beats everything"). The trade-off: guilds of privacy-minded builders look smaller and rank lower. A union count of all members' projects is refused because it reveals shared private projects; (5) the namespace is `/g/<slug>`, `g` is reserved in the SEO spec's project-slug list, the slug is fixed in V1, and `tests/guild_namespace_reserved.mjs` pins the two documents together; (6) a guild engages a project only through the individual door: a guild application is each member's own vouched application, labelled through ADR 0260's live applicant-profile read (no new instance field); inviting a guild fans out ordinary individual invites to its public roster at that moment, **never a guild-level pre-approval** (that would let a guild owner decide admission to someone else's project); (7) the map ranks public guilds by size and counted works shipped, read-time, with a slug tiebreak, and R29 narrows to the visual. Scope changes for R24–R37 are tabled in the ADR, including R28's dependency on the abandoned R11 and R29/R31's archived orb goals.](0336-a-guilds-record-is-the-sum-of-what-its-members-already-show.md) | platform identity / privacy / social graph |
|
|
@@ -2508,5 +2508,14 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2508
2508
|
landed since 1.19.1015 with no explicit bump. run 36080388706. (task 1002620)
|
|
2509
2509
|
1.19.1017 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2510
2510
|
landed since 1.19.1016 with no explicit bump. run 36081753087. (task 1002620)
|
|
2511
|
+
(next carrier) — readApplicationVouch re-exported (task 1002285, goal 1000110;
|
|
2512
|
+
ADR 0201 D2). The public POST /access-requests' check of the hub's signed
|
|
2513
|
+
application vouch → { githubId, handle } or null. Core supplies the hub
|
|
2514
|
+
public key and this instance's client_id, so the onboarding module passes
|
|
2515
|
+
only a token and a login — the <redacted> precedent.
|
|
2516
|
+
1.19.1018 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2517
|
+
landed since 1.19.1017 with no explicit bump. run 36083406390. (task 1002620)
|
|
2518
|
+
1.19.1019 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2519
|
+
landed since 1.19.1018 with no explicit bump. run 36084684814. (task 1002620)
|
|
2511
2520
|
---------------------------------------------------------------------------
|
|
2512
2521
|
```
|
package/docs/modules-contract.md
CHANGED
|
@@ -226,6 +226,7 @@ A module calls `api.seams.emit(eventName, payload)` to broadcast. Any module tha
|
|
|
226
226
|
| Port: `builder-settings` | `builder-settings` (R80) | core `routes/me.js` (`GET /me`) + `cascade-dispatch.js` | `{ getBuilderBehaviorPrefs, describeWandering, describeRender, computeNeeds, skillCeiling }` — the per-builder prefs read surface. The `GET /me` aggregator resolves it for its wandering/render/needs slices; the dormant cascade router for the skill ceiling. Default-on; degrades gracefully when off ([ADR 0093](adr/0093-tranche-2-core-carve-sequence.md) §1, [02-module-map §2](design/modular-architecture/02-module-map.md)). |
|
|
227
227
|
| Port: `onboarding` | `onboarding` (R81) | the lifecycle (`db.js` claim/ship — transaction-participant `markStage`) + `auth.js` + `routes/me.js`/`builders.js`/`tasks.js`/`claims.js` | `{ getState, shapeForApi, safeMarkStage, markStage, broadcastGraduation, restockForClaim, restockAll, SOURCE_TAG }` — the onboarding state-machine + newcomer-restock surface. The FIRST core→module port the lifecycle ticks inside its own txn (alongside `reward`). Default-on; degrades to best-effort no-ops when off ([ADR 0093](adr/0093-tranche-2-core-carve-sequence.md) §1). |
|
|
228
228
|
| Event: `builder.box-reconcile-needed` | core `db.js` (on rank change / deactivate / auto-graduation) | `dev-box` | `{ builderId, rank, status, actor }` — flags the box for reconciliation. |
|
|
229
|
+
| Event: `builder.secrets-scrub-needed` | core `db.js` (`deactivateBuilder`) | `art-pipeline` (when installed) | `{ builderId, actor }` — task 1003338 / [ADR 0332](adr/0332-offboarding-secret-scrub-semantics.md): best-effort teardown port for a MODULE-owned builder-keyed secret (today: the Gemini BYOK key, `gemini_key_enc`/`last4`/`set_at`). Core does not write those columns directly (ADR 0102) — the module listens and clears its own store. `github_oauth_tokens` (core-owned) is deleted synchronously inside `deactivateBuilder`'s transaction instead, no event needed. |
|
|
229
230
|
|
|
230
231
|
---
|
|
231
232
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
-- core_254_access_requests_applicant_handle.sql — the platform handle the hub's
|
|
2
|
+
-- application vouch carried, beside the vouched github id (ADR 0333 D5; the column
|
|
3
|
+
-- core_230 deferred to its writer, ADR 0208 D1).
|
|
4
|
+
--
|
|
5
|
+
-- Identity, not the record: the applicant's profile is still read live at review
|
|
6
|
+
-- and never stored (ADR 0260 D4). NULL = unvouched, or the account had not cleared
|
|
7
|
+
-- the consent floor, or the hub's account read failed. A snapshot — not re-synced
|
|
8
|
+
-- on rename. The vouch MARK stays `applicant_github_id IS NOT NULL`; no admission
|
|
9
|
+
-- path reads this column (ADR 0016).
|
|
10
|
+
--
|
|
11
|
+
-- Purely additive + idempotent; the previous release never names the column, so a
|
|
12
|
+
-- replay or rollback is a no-op. access_requests is CORE-owned, so this is a core_
|
|
13
|
+
-- migration (ADR 0108 §5); migrate.sh records the stem.
|
|
14
|
+
|
|
15
|
+
BEGIN;
|
|
16
|
+
|
|
17
|
+
ALTER TABLE access_requests
|
|
18
|
+
ADD COLUMN IF NOT EXISTS applicant_handle text;
|
|
19
|
+
|
|
20
|
+
COMMENT ON COLUMN access_requests.applicant_handle IS
|
|
21
|
+
'The platform handle the hub application vouch carried at apply time (ADR 0201 D2.6, task 1002285): identity, never the record, which is read live at review. NULL on an unvouched row, or when the account had not cleared the consent floor. A snapshot — not re-synced on rename. DESCRIPTIVE ONLY — no admission path reads it (ADR 0016).';
|
|
22
|
+
|
|
23
|
+
COMMIT;
|
|
@@ -80,6 +80,24 @@ function normalizeLogin(raw) {
|
|
|
80
80
|
return String(raw || '').trim().replace(/^@/, '');
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// The hub relay's signed application vouch rides in a HEADER, not the body: the
|
|
84
|
+
// strict validator would 400 an unknown field on every older core (ADR 0201 D2.2).
|
|
85
|
+
// Mirrored in modules/platform-identity/routes/my-projects.js (ADR 0091 §2).
|
|
86
|
+
const APPLICATION_VOUCH_HEADER = 'X-Bongos-Platform-Assertion';
|
|
87
|
+
|
|
88
|
+
// vouchPendingRow — a vouched re-application takes over an UNVOUCHED pending row
|
|
89
|
+
// for its own login, name and note included, so a row someone else filed under
|
|
90
|
+
// that login cannot pre-empt the real applicant's vouch (ADR 0333 D4). A row
|
|
91
|
+
// already vouched is never re-stamped.
|
|
92
|
+
function vouchPendingRow({ login, displayName, note, vouch }) {
|
|
93
|
+
return pool.query(
|
|
94
|
+
`UPDATE access_requests
|
|
95
|
+
SET display_name = $2, note = $3, applicant_github_id = $4, applicant_handle = $5, vouched_at = $6
|
|
96
|
+
WHERE lower(github_login) = lower($1) AND status = 'pending' AND applicant_github_id IS NULL`,
|
|
97
|
+
[login, displayName, note, vouch.githubId, vouch.handle, new Date()],
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
83
101
|
// How many applicant profiles one queue render may resolve from the hub (privacy
|
|
84
102
|
// spec D7, task 1002972). The list itself returns up to 200 rows, and a render
|
|
85
103
|
// that fired 200 cross-instance round-trips would make a reviewer wait on the
|
|
@@ -156,6 +174,10 @@ module.exports = function buildAccessRequestsRouter() {
|
|
|
156
174
|
}
|
|
157
175
|
const displayName = (req.body.display_name || '').trim() || null;
|
|
158
176
|
const note = (req.body.note || '').trim() || null;
|
|
177
|
+
// A verified vouch that names this login → the account the hub confirmed; anything
|
|
178
|
+
// else → null, and the unvouched row this route always wrote (never a refusal,
|
|
179
|
+
// ADR 0201 D2.3). Descriptive only: no admission path reads it (ADR 0016).
|
|
180
|
+
const vouch = api.readApplicationVouch(req.get(APPLICATION_VOUCH_HEADER), { githubLogin: login });
|
|
159
181
|
|
|
160
182
|
try {
|
|
161
183
|
// Already a builder? They have an account — point them at sign-in instead
|
|
@@ -177,11 +199,12 @@ module.exports = function buildAccessRequestsRouter() {
|
|
|
177
199
|
return res.fail('already_approved', { status: 409, message: "You're already approved — sign in with GitHub to enter." });
|
|
178
200
|
}
|
|
179
201
|
|
|
202
|
+
// The three vouch columns are written in one statement, so they never disagree.
|
|
180
203
|
const { rows } = await pool.query(
|
|
181
|
-
`INSERT INTO access_requests (github_login, display_name, note)
|
|
182
|
-
VALUES ($1, $2, $3)
|
|
204
|
+
`INSERT INTO access_requests (github_login, display_name, note, applicant_github_id, applicant_handle, vouched_at)
|
|
205
|
+
VALUES ($1, $2, $3, $4, $5, $6)
|
|
183
206
|
RETURNING id, github_login, status, created_at`,
|
|
184
|
-
[login, displayName, note]
|
|
207
|
+
[login, displayName, note, vouch ? vouch.githubId : null, vouch ? vouch.handle : null, vouch ? new Date() : null]
|
|
185
208
|
);
|
|
186
209
|
// Post the request to #member-approvals so an Archon can 👍/👎 it from
|
|
187
210
|
// Discord (task 1100). Fire-and-forget + best-effort — never blocks or
|
|
@@ -195,6 +218,10 @@ module.exports = function buildAccessRequestsRouter() {
|
|
|
195
218
|
// Partial unique index → there's already an open request for this login.
|
|
196
219
|
// Treat as success-ish: the Archon already has them in the queue.
|
|
197
220
|
if (err.code === '23505') {
|
|
221
|
+
if (vouch) {
|
|
222
|
+
try { await vouchPendingRow({ login, displayName, note, vouch }); }
|
|
223
|
+
catch (e) { log.error({ err: e.message }, '[gds] POST /access-requests: vouching the open request failed (non-fatal)'); }
|
|
224
|
+
}
|
|
198
225
|
return res.status(200).json({ ok: true, already_pending: true });
|
|
199
226
|
}
|
|
200
227
|
console.error('[gds] POST /access-requests', err);
|
|
@@ -276,7 +303,7 @@ module.exports = function buildAccessRequestsRouter() {
|
|
|
276
303
|
const { rows } = await pool.query(
|
|
277
304
|
`SELECT ar.id, ar.github_login, ar.display_name, ar.note, ar.status,
|
|
278
305
|
ar.created_at, ar.resolved_at,
|
|
279
|
-
ar.applicant_github_id, ar.vouched_at,
|
|
306
|
+
ar.applicant_github_id, ar.applicant_handle, ar.vouched_at,
|
|
280
307
|
(ar.applicant_github_id IS NOT NULL) AS vouched,
|
|
281
308
|
rb.github_login AS resolved_by_login
|
|
282
309
|
FROM access_requests ar
|