@bongos/core 1.19.423 → 1.19.424
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 +54 -24
- package/clients/bongos-client/index.d.ts +2 -2
- package/docs/adr/0208-application-lifecycle-the-handle-waits-and-the-vouch-has-no-minter.md +164 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +10 -5
- package/docs/api-reference.md +2 -2
- package/docs/copy-inventory.md +25 -25
- package/docs/copy-registry.json +27 -27
- package/docs/file-map.md +2 -0
- package/docs/module-api-changelog.md +2 -0
- package/migrations/core_230_access_requests_applicant_vouch.sql +79 -0
- package/modules/discord/discord-approvals.js +7 -2
- package/modules/hall-ui/public/watch.js +10 -1
- package/modules/onboarding/routes/access-requests.js +115 -11
- package/modules/platform-identity/application-echo.js +115 -0
- package/modules/platform-identity/migrations/platform_identity_016_application_echo.sql +68 -0
- package/modules/platform-identity/routes/my-projects.js +57 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/application_echo.mjs +446 -0
- package/tests/application_lifecycle.mjs +634 -0
- package/tests/own_scope_guard.mjs +5 -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.424",
|
|
6
|
+
"core_contract": "1.19.424",
|
|
7
|
+
"source_commit": "b62fe6409fd565071c1a15995b94d19865f69ccb",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-
|
|
9
|
+
"built_at": "2026-08-29T19:14:57.531Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
|
-
"docs_redacted":
|
|
12
|
+
"docs_redacted": 360,
|
|
13
13
|
"agent_docs_stubbed": 23,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1725,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2108,
|
|
20
|
+
"tree_sha256": "2ab3cda669950024136fe89bbbfd1c608c1690467a18e6aa16487bfea257446c",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -372,7 +372,7 @@
|
|
|
372
372
|
{
|
|
373
373
|
"path": "clients/bongos-client/index.d.ts",
|
|
374
374
|
"mode": "0000644",
|
|
375
|
-
"sha256": "
|
|
375
|
+
"sha256": "402be6a9f85c5434bb183a378c9a1631b9a3ca336487c04f969581d9d8041663"
|
|
376
376
|
},
|
|
377
377
|
{
|
|
378
378
|
"path": "clients/bongos-client/index.mjs",
|
|
@@ -1539,6 +1539,11 @@
|
|
|
1539
1539
|
"mode": "0000644",
|
|
1540
1540
|
"sha256": "72c859bdfc70fd4fc0d104439985a43eaf60cce281bbaae8b8faaffb40546973"
|
|
1541
1541
|
},
|
|
1542
|
+
{
|
|
1543
|
+
"path": "docs/adr/0208-application-lifecycle-the-handle-waits-and-the-vouch-has-no-minter.md",
|
|
1544
|
+
"mode": "0000644",
|
|
1545
|
+
"sha256": "0601590480041b8a3ced5b2a3517c1e445d2d9a94c0befea1f04e4f7acee2d70"
|
|
1546
|
+
},
|
|
1542
1547
|
{
|
|
1543
1548
|
"path": "docs/adr/0209-one-budget-for-the-account-existence-oracle.md",
|
|
1544
1549
|
"mode": "0000644",
|
|
@@ -1547,17 +1552,17 @@
|
|
|
1547
1552
|
{
|
|
1548
1553
|
"path": "docs/adr/README.md",
|
|
1549
1554
|
"mode": "0000644",
|
|
1550
|
-
"sha256": "
|
|
1555
|
+
"sha256": "f8aaf80da2aa887dd4dca7e14381353aabb4b028c347e61757646e966f8ba70c"
|
|
1551
1556
|
},
|
|
1552
1557
|
{
|
|
1553
1558
|
"path": "docs/api-reference.md",
|
|
1554
1559
|
"mode": "0000644",
|
|
1555
|
-
"sha256": "
|
|
1560
|
+
"sha256": "eb10a60b6ae1494a678c415d148dc0bf9b7ad552db6bf105993ad80eda7a4ee5"
|
|
1556
1561
|
},
|
|
1557
1562
|
{
|
|
1558
1563
|
"path": "docs/api/openapi.json",
|
|
1559
1564
|
"mode": "0000644",
|
|
1560
|
-
"sha256": "
|
|
1565
|
+
"sha256": "534519a2f54183ff2edd620d08b56f4724419daa0d5ddcd88df657a32be41728"
|
|
1561
1566
|
},
|
|
1562
1567
|
{
|
|
1563
1568
|
"path": "docs/architecture.md",
|
|
@@ -1582,12 +1587,12 @@
|
|
|
1582
1587
|
{
|
|
1583
1588
|
"path": "docs/copy-inventory.md",
|
|
1584
1589
|
"mode": "0000644",
|
|
1585
|
-
"sha256": "
|
|
1590
|
+
"sha256": "af9d7021d1a694ffae47fa99ee504d457b233d9686697f7bf5b172e209f705f3"
|
|
1586
1591
|
},
|
|
1587
1592
|
{
|
|
1588
1593
|
"path": "docs/copy-registry.json",
|
|
1589
1594
|
"mode": "0000644",
|
|
1590
|
-
"sha256": "
|
|
1595
|
+
"sha256": "d72c20b35d13990f0766afda536acc9a59619dcdbf4c18ac22f9a50a0d9a7456"
|
|
1591
1596
|
},
|
|
1592
1597
|
{
|
|
1593
1598
|
"path": "docs/design/apex-pass-2-direction.md",
|
|
@@ -1927,7 +1932,7 @@
|
|
|
1927
1932
|
{
|
|
1928
1933
|
"path": "docs/file-map.md",
|
|
1929
1934
|
"mode": "0000644",
|
|
1930
|
-
"sha256": "
|
|
1935
|
+
"sha256": "7730f2a005a325dd3b5029d45b2230d3276ee24a8183e41c4f1d8c628839aaed"
|
|
1931
1936
|
},
|
|
1932
1937
|
{
|
|
1933
1938
|
"path": "docs/handoff-template.md",
|
|
@@ -1937,7 +1942,7 @@
|
|
|
1937
1942
|
{
|
|
1938
1943
|
"path": "docs/module-api-changelog.md",
|
|
1939
1944
|
"mode": "0000644",
|
|
1940
|
-
"sha256": "
|
|
1945
|
+
"sha256": "e33c30a0a5b1f5b6dca608a3a043a20f6a426a7a1a75d91f515736594437939b"
|
|
1941
1946
|
},
|
|
1942
1947
|
{
|
|
1943
1948
|
"path": "docs/modules-contract.md",
|
|
@@ -2864,6 +2869,11 @@
|
|
|
2864
2869
|
"mode": "0000644",
|
|
2865
2870
|
"sha256": "dd1f70a3414182ac1bfd6ff4a5bd4b18142d55e46d0e67eb708e334d5da6f393"
|
|
2866
2871
|
},
|
|
2872
|
+
{
|
|
2873
|
+
"path": "migrations/core_230_access_requests_applicant_vouch.sql",
|
|
2874
|
+
"mode": "0000644",
|
|
2875
|
+
"sha256": "77b7a9dee4de54743ecea5e1f326521c4483928317882f9e214f3a6f7db07e8c"
|
|
2876
|
+
},
|
|
2867
2877
|
{
|
|
2868
2878
|
"path": "modules/agents/migrations/agents_001_definitions.sql",
|
|
2869
2879
|
"mode": "0000644",
|
|
@@ -3317,7 +3327,7 @@
|
|
|
3317
3327
|
{
|
|
3318
3328
|
"path": "modules/discord/discord-approvals.js",
|
|
3319
3329
|
"mode": "0000644",
|
|
3320
|
-
"sha256": "
|
|
3330
|
+
"sha256": "295d2f45c886cdc7573eb74cf603836ad1cb7ba4333b86e93c1eb7753043fe8f"
|
|
3321
3331
|
},
|
|
3322
3332
|
{
|
|
3323
3333
|
"path": "modules/discord/discord-audit.js",
|
|
@@ -4237,7 +4247,7 @@
|
|
|
4237
4247
|
{
|
|
4238
4248
|
"path": "modules/hall-ui/public/watch.js",
|
|
4239
4249
|
"mode": "0000644",
|
|
4240
|
-
"sha256": "
|
|
4250
|
+
"sha256": "15b50614109474ddfd9eb9d1c2488175992135d2e6ca2db224251a74112da8d7"
|
|
4241
4251
|
},
|
|
4242
4252
|
{
|
|
4243
4253
|
"path": "modules/hall-ui/public/work.css",
|
|
@@ -4767,7 +4777,7 @@
|
|
|
4767
4777
|
{
|
|
4768
4778
|
"path": "modules/onboarding/routes/access-requests.js",
|
|
4769
4779
|
"mode": "0000644",
|
|
4770
|
-
"sha256": "
|
|
4780
|
+
"sha256": "41729b14813c7f9d7ed93d8e9cc272f8ec6a97c610318ac5cb0fa2ff870a74c0"
|
|
4771
4781
|
},
|
|
4772
4782
|
{
|
|
4773
4783
|
"path": "modules/onboarding/routes/onboarding.js",
|
|
@@ -4784,6 +4794,11 @@
|
|
|
4784
4794
|
"mode": "0000644",
|
|
4785
4795
|
"sha256": "e0f58b95aee7e54531928014e851ed74e40ab1262cda6dc4ab13bae156e33d93"
|
|
4786
4796
|
},
|
|
4797
|
+
{
|
|
4798
|
+
"path": "modules/platform-identity/application-echo.js",
|
|
4799
|
+
"mode": "0000644",
|
|
4800
|
+
"sha256": "3793f588d62eaa96a74da6bceb55043e8a9de35bc120dbe63f26bcc2b7e904da"
|
|
4801
|
+
},
|
|
4787
4802
|
{
|
|
4788
4803
|
"path": "modules/platform-identity/connections.js",
|
|
4789
4804
|
"mode": "0000644",
|
|
@@ -4884,6 +4899,11 @@
|
|
|
4884
4899
|
"mode": "0000644",
|
|
4885
4900
|
"sha256": "edaced87ae340bb4c0a8c73d5dc40f8578bf7be0bd4064a02d6c4fcf0cf94be3"
|
|
4886
4901
|
},
|
|
4902
|
+
{
|
|
4903
|
+
"path": "modules/platform-identity/migrations/platform_identity_016_application_echo.sql",
|
|
4904
|
+
"mode": "0000644",
|
|
4905
|
+
"sha256": "d5553c3d06e8d71b89e62db54a9a3a3ae9810d3f29b262c97dca1f8242aca879"
|
|
4906
|
+
},
|
|
4887
4907
|
{
|
|
4888
4908
|
"path": "modules/platform-identity/module.json",
|
|
4889
4909
|
"mode": "0000644",
|
|
@@ -4912,7 +4932,7 @@
|
|
|
4912
4932
|
{
|
|
4913
4933
|
"path": "modules/platform-identity/routes/my-projects.js",
|
|
4914
4934
|
"mode": "0000644",
|
|
4915
|
-
"sha256": "
|
|
4935
|
+
"sha256": "9f3a833a455c14ea535910ec09d86a2c5bfb5419678274cb5d6ea8d790dd7883"
|
|
4916
4936
|
},
|
|
4917
4937
|
{
|
|
4918
4938
|
"path": "modules/platform-identity/routes/profile.js",
|
|
@@ -5497,12 +5517,12 @@
|
|
|
5497
5517
|
{
|
|
5498
5518
|
"path": "package-lock.json",
|
|
5499
5519
|
"mode": "0000644",
|
|
5500
|
-
"sha256": "
|
|
5520
|
+
"sha256": "a84e36935f4aa56400dd42376e09a8aaebc018b3c6a198510e0584d39719e3e1"
|
|
5501
5521
|
},
|
|
5502
5522
|
{
|
|
5503
5523
|
"path": "package.json",
|
|
5504
5524
|
"mode": "0000644",
|
|
5505
|
-
"sha256": "
|
|
5525
|
+
"sha256": "0dfba98b3e4452ea521bd6d2a50ea6cf5ac22a45609256edaecb86cc3df3ef89"
|
|
5506
5526
|
},
|
|
5507
5527
|
{
|
|
5508
5528
|
"path": "public-docs/index.html",
|
|
@@ -7632,7 +7652,7 @@
|
|
|
7632
7652
|
{
|
|
7633
7653
|
"path": "src/module-api.js",
|
|
7634
7654
|
"mode": "0000644",
|
|
7635
|
-
"sha256": "
|
|
7655
|
+
"sha256": "8be6477b20c6063ca604f8f173d183a9f3207023f9a376a571d95b88d036e01b"
|
|
7636
7656
|
},
|
|
7637
7657
|
{
|
|
7638
7658
|
"path": "src/module-loader/catalog.js",
|
|
@@ -7769,6 +7789,16 @@
|
|
|
7769
7789
|
"mode": "0000644",
|
|
7770
7790
|
"sha256": "ac84c8d8b1e2f54ef278506570ed657e68d2da4e136968b0df31b51f943af4bb"
|
|
7771
7791
|
},
|
|
7792
|
+
{
|
|
7793
|
+
"path": "tests/application_echo.mjs",
|
|
7794
|
+
"mode": "0000644",
|
|
7795
|
+
"sha256": "6272bf127a71c682b482353dc2f56e527f54a351805f8b5d7d66be66db111c25"
|
|
7796
|
+
},
|
|
7797
|
+
{
|
|
7798
|
+
"path": "tests/application_lifecycle.mjs",
|
|
7799
|
+
"mode": "0000644",
|
|
7800
|
+
"sha256": "ddf0dd72bda85e452485ed8869feb2fe39d0d230c6647a1573c5ea87efbee42b"
|
|
7801
|
+
},
|
|
7772
7802
|
{
|
|
7773
7803
|
"path": "tests/architect.mjs",
|
|
7774
7804
|
"mode": "0000644",
|
|
@@ -9562,7 +9592,7 @@
|
|
|
9562
9592
|
{
|
|
9563
9593
|
"path": "tests/own_scope_guard.mjs",
|
|
9564
9594
|
"mode": "0000644",
|
|
9565
|
-
"sha256": "
|
|
9595
|
+
"sha256": "3c823f5ad58a6406ecc5b93a5f3f5271fd8400c8839ad0fa99c27bf49032c8d7"
|
|
9566
9596
|
},
|
|
9567
9597
|
{
|
|
9568
9598
|
"path": "tests/own_session_revoke.mjs",
|
|
@@ -84,7 +84,7 @@ export interface GetMemoryFilesResponse { builder_id: unknown; count: unknown; f
|
|
|
84
84
|
export interface GetModulesResponse { modules: unknown; own_built: unknown; core_loaded: unknown; catalog: unknown; submit_pipeline_live: boolean }
|
|
85
85
|
export interface GetModulesSubmissionsResponse { submissions: unknown }
|
|
86
86
|
export interface GetMyProjectsInvitesResponse { invites: unknown }
|
|
87
|
-
export interface GetMyProjectsResponse { owned: unknown; building: unknown }
|
|
87
|
+
export interface GetMyProjectsResponse { owned: unknown; building: unknown; applied: unknown }
|
|
88
88
|
export interface GetOverrideRequestsResponse { requests: unknown }
|
|
89
89
|
export interface GetProfilesHandleResponse { account: unknown; cross_project: unknown }
|
|
90
90
|
export interface GetProjectsFeaturedResponse { projects: unknown }
|
|
@@ -134,7 +134,7 @@ export interface GetVersionsProgressResponse { progress: unknown }
|
|
|
134
134
|
export interface GetVersionsResponse { versions: unknown; page: unknown }
|
|
135
135
|
export interface PageInfo { limit: number; offset: number; total?: number }
|
|
136
136
|
export interface PatchAccessRequestsIdRequest { status: string }
|
|
137
|
-
export interface PatchAccessRequestsIdResponse { ok: boolean; request: unknown }
|
|
137
|
+
export interface PatchAccessRequestsIdResponse { ok: boolean; request: unknown; unchanged?: boolean }
|
|
138
138
|
export interface PatchBoxesBuilderIdBlockRequest { blocked: boolean; reason?: string; close_current?: boolean }
|
|
139
139
|
export interface PatchBoxesBuilderIdBlockResponse { ok: boolean; blocked: unknown; close_queued: unknown }
|
|
140
140
|
export interface PatchBuildersIdBudgetRequest { monthly_budget_usd?: unknown }
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# ADR 0208 — Building the application lifecycle: the handle waits for its writer, and the vouch D2.1 describes has no minter that can express it
|
|
2
|
+
|
|
3
|
+
- **Status:** accepted
|
|
4
|
+
- **Date:** 2026-08-29
|
|
5
|
+
- **Task:** [task 1002284](https://cloudbongos.com/builders#/task/1002284) (R08, goal 1000045 — criteria C3/C4)
|
|
6
|
+
- **Relates to:** [ADR 0201](<redacted>.md) (the design this builds — D1, D3.2, D4), [ADR 0016](<redacted>.md) (descriptive analytics are never authority), [ADR 0141](<redacted>.md) (identity federated, authority local), [ADR 0171](<redacted>.md) D2 (the non-numeric principal guard), [ADR 0116](<redacted>.md) (a refusal names the action available), [ADR 0202](<redacted>.md) D4 (the `account-visibility.js` "its own small file" precedent this reuses)
|
|
7
|
+
|
|
8
|
+
## Context
|
|
9
|
+
|
|
10
|
+
ADR 0201 decided the cross-project application: it is the target project's own `access_requests` row, vouched for by a hub-signed assertion in a header, with the applicant's record resolved live in the reviewer's browser and never stored. Its dependency table splits the build: **R08 (this task) owns D1 + D4 + D3.2** — the migration, the row, the lifecycle, the atom — and **R09 ([task 1002285](https://cloudbongos.com/builders#/task/1002285)) owns D2 wholesale**: the assertion, the header, the live browser read, and the handle.
|
|
11
|
+
|
|
12
|
+
Building R08 surfaced one place where the ADR contradicts itself, one place where its central mechanism cannot be written as described against the code that exists, and a handful of small questions it simply did not reach. This ADR records those, in the shape [ADR 0207](<redacted>.md) set for the connections wave: departures first, then the limits the shipped code is honest about.
|
|
13
|
+
|
|
14
|
+
## Decision
|
|
15
|
+
|
|
16
|
+
### D1 — `applicant_handle` is **not** in this migration. R09 adds it in the same change that first writes one.
|
|
17
|
+
|
|
18
|
+
ADR 0201 says two incompatible things about one table:
|
|
19
|
+
|
|
20
|
+
- **D1.2:** "The migration adds **exactly** what a vouch needs: `applicant_github_id text` and `vouched_at timestamptz`, both nullable."
|
|
21
|
+
- **D2.6:** "The vouch carries the `handle` as well as the `github_id` … **The handle at apply time is stored on the row**."
|
|
22
|
+
|
|
23
|
+
Both cannot be true of `core_230`. The call is that the handle waits, for four reasons, in descending weight:
|
|
24
|
+
|
|
25
|
+
1. **The ADR's own dependency table already routes it.** R09's row reads "D2 wholesale — the assertion, the header, the live browser read, **and the handle**". D2.6 *is* the handle. The contradiction is therefore about sequencing, not design: D1.2 constrains *this* migration, and D2.6 says the row ends up carrying a handle without saying which migration puts it there.
|
|
26
|
+
2. **A column with no writer is worse than no column.** R08 ships no vouch writer at all — the assertion and the header are R09's — so `applicant_handle` would be permanently NULL. Any reader added in between would render "no record" for a reason that has nothing to do with the applicant actually lacking a handle, which is exactly the honest-degrade case D2.6 wants to be able to distinguish.
|
|
27
|
+
3. **Migrations are cheap and additive; a wrong-looking column is not.** `<redacted>` and `core_230` cost one file each. R09 adding `applicant_handle` beside its own writer costs one more and zero coordination, and the console wave (spike 1002786 R2) is already adding `kind` / `created_by` to this same table in a third additive migration — the pattern is established, and only a shared column name (`kind`) would collide.
|
|
28
|
+
4. **The vouch MARK does not need it.** D1.2's own reasoning — one column, no second flag free to disagree with the first — is satisfied by `applicant_github_id` alone. The handle is a *rendering* input for the live profile read, which is a D2 concern by construction.
|
|
29
|
+
|
|
30
|
+
This is recorded in the migration's own header, so the next reader of `core_230` finds the reasoning where the apparent omission is.
|
|
31
|
+
|
|
32
|
+
### D2 — `mintHubAssertion` cannot express D2.1, and the gap is wider than "it takes no `purpose`". R09 needs a third mint/verify pair **and** a tightening of `verifyHubAssertion`.
|
|
33
|
+
|
|
34
|
+
D2.1 says the relay carries a hub-signed assertion "reus[ing] `mintHubAssertion` … with a `purpose: 'application'` marker — the discriminator `mintHubLogoutToken` already established, so a captured device-login assertion cannot be replayed as an application vouch or the reverse."
|
|
35
|
+
|
|
36
|
+
Read against the code, that does not hold, and the reason is structural rather than a missing parameter:
|
|
37
|
+
|
|
38
|
+
- `mintHubAssertion({ clientId, githubId, githubLogin, displayName, avatarUrl })` takes no `purpose` and emits none. `mintHubLogoutToken({ clientId, githubId })` emits `purpose: 'backchannel-logout'` and **no identity**. The two are told apart because each verifier demands what the other lacks: `verifyHubAssertion` throws on a payload with no `github_login`, and `verifyHubLogoutToken` throws on a payload whose `purpose` is not the logout marker.
|
|
39
|
+
- **An application vouch needs identity *and* a purpose.** That is the collision. It shares the login assertion's identity fields, so the asymmetry that protects the logout token does not reach it.
|
|
40
|
+
- **And `verifyHubAssertion` does not reject a payload that carries a `purpose`.** It checks alg, signature, issuer, audience, expiry, and the presence of `sub` + `github_login` — nothing more. So an assertion minted as `{ …identity, purpose: 'application' }` verifies **as a login assertion** today. Adding the marker to `mintHubAssertion` would therefore buy one direction of the separation and neither of the two D2.1 claims: a captured application vouch would still be replayable as a device login.
|
|
41
|
+
|
|
42
|
+
So R09 hits this on line one. What it needs, stated so the next session does not rediscover it:
|
|
43
|
+
|
|
44
|
+
1. A **third** mint/verify pair (`mintHubApplicationVouch` / `<redacted>`) that requires `purpose === 'application'` **and** identity, rather than a `purpose` argument bolted onto the login minter.
|
|
45
|
+
2. A **tightening of `verifyHubAssertion`** to reject any identity payload that carries a `purpose` at all. This is backward-compatible precisely because no minter in the tree emits one today — which is the narrow window in which it is free, and it closes as soon as anything starts adding purposes to identity tokens.
|
|
46
|
+
3. A decision about the single-use `jti` store: the login path consumes a `jti` once per exchange, and reusing that store for a vouch makes one vouch good for one application, which is the right answer but should be chosen rather than inherited.
|
|
47
|
+
|
|
48
|
+
R08 mints nothing and verifies nothing, so it takes no position beyond writing this down. It is filed against R09 rather than fixed here because inventing the vouch's wire format inside the task that owns the *row* is exactly the sequencing this goal split to avoid.
|
|
49
|
+
|
|
50
|
+
### D2b — a rescind REFUSES once the applicant has signed in. It may not report a removal that did not happen.
|
|
51
|
+
|
|
52
|
+
Found by adversarial review before ship, and it is the most consequential thing in
|
|
53
|
+
this task. ADR 0201 D4.2 says the rescind "must never pretend to remove a member",
|
|
54
|
+
and the first implementation did exactly that.
|
|
55
|
+
|
|
56
|
+
`firstTimeAdmissionBlocked` short-circuits on an existing `builders` row, so once
|
|
57
|
+
the applicant has signed in, `hasInvitedAccessRequest` is irrelevant to their
|
|
58
|
+
admission. Nothing anywhere flips the `invited` row at sign-in — every writer of
|
|
59
|
+
`access_requests` was enumerated: this route, `POST /access-requests/invite`, the
|
|
60
|
+
public POST, and the Discord reaction handler; none of them touch it. So
|
|
61
|
+
`status='invited'` beside a live `builders` row is a **permanently reachable
|
|
62
|
+
state** on any instance, and dismissing that row changes nothing at all. The owner
|
|
63
|
+
would get `200 {ok:true, status:'dismissed'}`, the hall would say "Request
|
|
64
|
+
dismissed", the row would leave the invited history — and the member would keep
|
|
65
|
+
full access.
|
|
66
|
+
|
|
67
|
+
That is a **false negative on an admission control**, which is worse than a
|
|
68
|
+
refusal: a refusal makes the owner go and do the right thing, while a false
|
|
69
|
+
success makes them stop looking.
|
|
70
|
+
|
|
71
|
+
So the rescind carries a member check, as a `NOT EXISTS` **inside the UPDATE's own
|
|
72
|
+
WHERE** rather than a read-then-write, and a blocked rescind answers `409
|
|
73
|
+
already_member` naming `/builder-exit` — the act that does remove a member. The
|
|
74
|
+
guard is on the **rescind only**: dismissing a *pending* row for someone who is
|
|
75
|
+
already a member is housekeeping, not a removal, and stays allowed.
|
|
76
|
+
|
|
77
|
+
### D3 — the echo's read joins the project catalog; the echo's **table** is still the three columns D1.3 named.
|
|
78
|
+
|
|
79
|
+
D1.3 says the echo "carries `(github_id, client_id, applied_at)` and **never a status**". The table does exactly that, keyed on the pair. But a `client_id` alone cannot render "you applied to *X*", so `listRecentApplications` `LEFT JOIN`s `platform_identity_sso_clients` for the name and origin — the same join `listMembershipsForAccount` already makes for the same reason. Nothing new is *stored*; a de-registered project degrades to a null name beside a real date rather than vanishing.
|
|
80
|
+
|
|
81
|
+
### D4 — three questions D1.3/D1.4 and D4.4 left open, answered.
|
|
82
|
+
|
|
83
|
+
1. **A re-application refreshes `applied_at`.** D1.4 makes re-applying a success; D1.3 gives the echo a 30-day read window. Left un-refreshed, an applicant who re-asks watches their own card go quiet while their request is still open on the project. The window is therefore measured from the most recent ask, and the upsert says so.
|
|
84
|
+
2. **An idempotent PATCH does not re-stamp the audit trail.** D4.4 says a PATCH to the state already held "returns the row, not a 409". It does not say whether `resolved_by_builder_id` / `resolved_at` move. They do not: the row records who *actually* resolved it, not who clicked the button again afterwards. The response carries `unchanged: true` so a caller can tell a no-op from an act.
|
|
85
|
+
3. **`dismissed → invited` is refused with `409 already_dismissed`, naming `POST /access-requests/invite`.** D4.2 rules that the reverse correction needs no new path; ADR 0116 requires the refusal to name the action available there, so the message does.
|
|
86
|
+
|
|
87
|
+
### D4b — leaving a project forgets the echo.
|
|
88
|
+
|
|
89
|
+
Also from review. Membership hides an echo from `applied` (D5) rather than
|
|
90
|
+
deleting it, so apply → approved → sign in → **leave** resurrected the original
|
|
91
|
+
application on the applicant's own card for the rest of the 30-day window: "you
|
|
92
|
+
applied on ⟨date⟩" about a project they had already joined. The echo means "I
|
|
93
|
+
asked and have not yet joined", and someone who joined and left has neither, so
|
|
94
|
+
`POST /my-projects/:clientId/leave` drops it (best-effort — a stale echo must not
|
|
95
|
+
fail a leave that already happened).
|
|
96
|
+
|
|
97
|
+
### D5 — the hub's `applied` list drops any project the caller now builds on.
|
|
98
|
+
|
|
99
|
+
`GET /my-projects` returns `applied` beside `owned` / `building`. A project appearing in both would let a card claim "applied" about a project the applicant is already a member of — the hub implying an outcome it is not allowed to hold (D1.3). Membership is the *only* approval signal the hub may show, so it wins and the echo drops out.
|
|
100
|
+
|
|
101
|
+
## Consequences
|
|
102
|
+
|
|
103
|
+
- The queue's `vouched` flag is **false for every real row until R09 lands**, because R08 ships no writer. That is the correct reading — no assertion has been verified — but it means the two-class queue the ADR's Consequences describe is, for now, one class. `tests/application_lifecycle.mjs` seeds a vouched row directly so the distinction and its powerlessness are both proved before the writer exists.
|
|
104
|
+
- `core_230` and `<redacted>` are both purely additive and forward-safe: the previous code release ignores every new column and table, so a rollback is a no-op and no federated instance needs to upgrade in step.
|
|
105
|
+
- The rescind (`invited → dismissed`) is a real behaviour change on a live route: an approval can now be revoked before first sign-in. After sign-in the row is history and offboarding is the instance's own act, which the route documents and does not attempt.
|
|
106
|
+
|
|
107
|
+
## Honest limits
|
|
108
|
+
|
|
109
|
+
- **The vouch is proved powerless, not proved absent from the future.** The proof is behavioural — a seeded vouched row and an unvouched twin take the identical statement and the identical admission read, asserted byte-for-byte, plus a structural check that no statement on the approve or admission path names `applicant_github_id` or `vouched_at`. That catches the vouch being wired into *these* paths. It does not stop a new admission path being written elsewhere; the thing that does is `firstTimeAdmissionBlocked` remaining the single gate, which is ADR 0201 D1.1's whole reason for extending this table instead of adding one.
|
|
110
|
+
- **`already_approved` still reaches the hub as "already_member" prose.** The project's public POST answers `409 already_approved` to an applicant who was approved but has not signed in; `joinRelayOutcome`'s bare-409 branch reads that as `already_member`, so the hub says "You already build on this project — sign in to it". The instruction is right and the noun is wrong. Fixing it means adding a sixth outcome to the relay, which is R10/R12's surface, so it is named here rather than changed under a row-and-lifecycle task.
|
|
111
|
+
- **The tests are DB-free by construction, so the SQL is executed by an interpreter, not by Postgres.** The interpreter builds each statement's filter from that statement's own `WHERE` text and refuses any predicate it cannot read, and nine mutations were run to confirm it bites: dropping the PATCH's transition guard reds 3 tests (a declined applicant becomes admissible), dropping `requirePermission` reds 6, writing the echo unconditionally reds 4, dropping the echo's read-time window reds 2, dropping the echo's own-scope `WHERE` reds 2, adding a hidden `AND ar.applicant_github_id IS NOT NULL` to the queue reds 1, dropping the rescind's member guard reds 6, dropping `resolved_by_builder_id = $2` from the `SET` reds 11 (it red **nothing** before review — see below), and making a leave stop forgetting the echo reds 1.
|
|
112
|
+
|
|
113
|
+
- **Two mutations found real holes in the instrument itself, which is why they are listed.**
|
|
114
|
+
The fake pool matched the `UPDATE`'s `SET` list as `.*?` and applied its effect
|
|
115
|
+
from JS, so deleting `resolved_by_builder_id = $2` from the real statement — a
|
|
116
|
+
plain audit-trail regression — passed every test. The `SET` is now matched
|
|
117
|
+
exactly, and that mutation reds 11. And:
|
|
118
|
+
- **The `WHERE` sweep was narrower than its own docstring said.** The interpreter refuses a predicate whose *placeholder* it did not consume — so a predicate carrying **no** placeholder is invisible to it, and a filter that silently hid every unvouched row from the owner passed all 26 other tests. The queue is the one read where that matters (and the one legitimately allowed to name the vouch columns, in its `SELECT` list), so its `WHERE` is now pinned exactly rather than inferred. Elsewhere the placeholder rule is enough, because every filter that decides anything binds a value. What it cannot check is anything only a real engine decides, and **nothing in this change is executed by Postgres anywhere** — there is no integration test for `access-requests` at all, and CI has none. So the partial unique index is *modelled* rather than exercised, the `ON CONFLICT` target is asserted as text, and the PATCH's new transition guard reuses `$1` as both the `SET` value and a comparison, which is why it is spelled `$1::text` — the type is stated rather than left to an inference no test observes. The first real exercise of the rescind path is the first rescind on a deployed instance, and the handoff names it as the manual check to run.
|
|
119
|
+
|
|
120
|
+
- **A rescind overwrites the approver, and this route writes no `audit_log`.**
|
|
121
|
+
`resolved_by_builder_id` / `resolved_at` become the rescinder's, so who
|
|
122
|
+
originally approved is not recoverable from the row. Approve and decline lose
|
|
123
|
+
nothing only because they start from `pending`, which has no resolver to lose —
|
|
124
|
+
the rescind is the first transition here that overwrites one. Carrying the prior
|
|
125
|
+
value out means a CTE on the one statement in this change that no engine
|
|
126
|
+
executes in CI, so it is named rather than coded, and belongs with the queue UI
|
|
127
|
+
that would display an approval history (R11, [task 1002287](https://cloudbongos.com/builders#/task/1002287)).
|
|
128
|
+
- **There is no UI path to the rescind.** `watch.js` fetches only
|
|
129
|
+
`?status=pending`, so the invited history is not rendered and nothing offers the
|
|
130
|
+
action. The route is correct and reachable by API; surfacing it is R11's queue
|
|
131
|
+
page. The handoff's manual check says so, rather than implying a button exists.
|
|
132
|
+
(The same review found `patchJson` was not unwrapping the ADR 0116 envelope, so
|
|
133
|
+
every refusal from this route rendered as `[object Object]` — a pre-existing bug
|
|
134
|
+
that *this* change made user-visible, since its whole point is refusals that name
|
|
135
|
+
the action available. Fixed here by copying the unwrap its sibling `postJson`
|
|
136
|
+
already had; without it, D4.3's claim would be true of the payload and false of
|
|
137
|
+
the product.)
|
|
138
|
+
- **`applied` is not rendered anywhere yet.** Neither the hall's My Projects page
|
|
139
|
+
nor the orbs front door reads it — R10 ([task 1002286](https://cloudbongos.com/builders#/task/1002286))
|
|
140
|
+
and R12 ([task 1002288](https://cloudbongos.com/builders#/task/1002288)) own
|
|
141
|
+
those renders, and D1.3 is listed as their dependency. The read exists so they do
|
|
142
|
+
not have to invent it.
|
|
143
|
+
- **ADR 0201 D4.3 — the read-time 30-day expiry of `invited` — is deferred, not
|
|
144
|
+
done.** `hasInvitedAccessRequest` still admits an `invited` row of unbounded age.
|
|
145
|
+
D4.3 is part of the D4 this task claims, but the ADR's own `What this unblocks`
|
|
146
|
+
table scopes R08's D4 to "the statuses, the rescind gap, idempotency", and the
|
|
147
|
+
expiry touches the admission gate itself rather than this route. It is the
|
|
148
|
+
natural companion to the rescind — both are "an approval should not stand
|
|
149
|
+
forever" — and should be claimed as its own task against `db-kernel.js`.
|
|
150
|
+
- **`config/fitness-baselines.json`'s `console_call_count` is left at 161 although
|
|
151
|
+
the real count is now 159** (two `console.error` became `log.error` in code this
|
|
152
|
+
task rewrote), so `fitness.js` prints a standing "ratchet-down available" WARN.
|
|
153
|
+
Lowering it is the convention, but three sessions were in flight; a branch that
|
|
154
|
+
merges main and adds one `console.*` would go red at 160 through no fault of its
|
|
155
|
+
own. Deliberately left for a session that is not racing.
|
|
156
|
+
|
|
157
|
+
## Rejected
|
|
158
|
+
|
|
159
|
+
- **Adding `applicant_handle` to `core_230` anyway, to satisfy D2.6 literally.** A permanently-NULL column whose NULL means "R09 has not shipped" rather than "this account has no handle" is a column that lies to its first reader.
|
|
160
|
+
- **Adding a `purpose` argument to `mintHubAssertion` in this task.** It would look like D2.1 shipped while leaving both replay directions open, which is worse than the gap being visible.
|
|
161
|
+
- **A `status` column on the echo, "just for the applicant's own view".** The whole reason the echo is allowed to exist on the hub is that it holds no admission fact; a status is the one addition that would make it authority on the wrong side of the boundary.
|
|
162
|
+
- **Refusing `dismissed → invited` with the existing `not_pending`.** True but unhelpful: the code names the wrong state and points nowhere.
|
|
163
|
+
- **A new permission atom for "project owner".** `access_request.review` at floor archon already pins all three routes, and ADR 0201 D3.2 forbids a third atom for one door. This task mints none, and a test pins the atom and its floor to the catalog so a later widening is a deliberate act.
|
|
164
|
+
- **Re-stamping `resolved_by` on an idempotent PATCH** to keep the write path branchless. It would overwrite the audit trail with whoever double-clicked last.
|
package/docs/adr/README.md
CHANGED
|
@@ -294,6 +294,7 @@ This keeps the decision history honest and traceable.
|
|
|
294
294
|
| 0204 | [**The apex is dark only; light is the builders' hall's** ([task 1003336](https://cloudbongos.com/builders#/task/1003336), goal 1000080). Supersedes in part [ADR 0196](<redacted>.md) (the light-mode half of the landing build) and the landing direction's 2026-08-27 pick of "light rebuilt to the dark standard". The owner, reviewing the planets: "only black or only white is a bit bland… leaning towards only dark mode for this page and keep the dark/light mode to be solely for the builder hall", confirmed as the WHOLE apex. Decision: `/`, `/projects`, `/privacy`, `/terms` and `/contact` pin `data-mode="dark"` on `<html>` — no pre-paint script, no `cb-color-mode`, no `?mode=`, no OS-preference read; the moon/sun glyph and its wiring leave both bars; the hall's own `hall-theme` switch is the platform's only light/dark control. The light tier of `world.css` / `cosmos.css` and the dark-twins rule STAY until the tier's own removal (the light-pair contrast tests keep measuring). The kit learns `"modes": ["dark"]` in a states file (validated; `render.js` takes it as the default). Rejected: a dark default with the toggle kept; deleting the light tier in the same diff; `--modes dark` on every kit call instead of the page's contract.](<redacted>.md) | apex / design system |
|
|
295
295
|
| 0205 | [**A federated check-in may not speak for a person the hub never saw sign in: the membership carries the hub's own witness, and identity is never re-synced from client credentials** ([task 1003340](https://cloudbongos.com/builders#/task/1003340), goal 1000045 — closes security report 1000027, high, LIVE on the hub). `POST /sso/membership/check-in` authenticated the CALLER (`client_id` + `client_secret`) and took the SUBJECT (`github_id`, `github_login`, `avatar_url`) from the request body — client credentials say which PROJECT is calling, never who the person is. So any registered SSO client could (1) rewrite a real person's public `github_login` and `avatar_url`, which `upsertAccount`'s `ON CONFLICT` re-synced unconditionally and stored with no scheme check, and (2) mint the `<redacted>` row that [ADR 0171](<redacted>.md) D7's `membership_required` precheck reads — so the anti-impersonation gate on `POST /sso/activity/rollup` was satisfiable by the very call it was meant to gate, and forged credits/karma/rank reached a public profile. Decisions: (1) migration `<redacted>` adds `signin_verified_at`, written ONLY by `recordFederatedSignin` from the two moments the HUB itself authenticated a human for a client — `/sso/token` (a code minted under a live hub session, bound to the pair) and `/sso/device/poll` (a GitHub approval, audience bound from the typed origin) — so no request-body field reaches the statement and the column is evidence a caller structurally cannot write; (2) `upsertMembership` becomes `touchMembership`, an UPDATE gated on that column — a check-in can no longer CREATE a membership, answering `409 signin_required` and writing nothing, while signing in accepts a standing `pending` invite and leaves any other coarse kind to the instance to report; (3) a federated client gets insert-only `ensureAccount` (`DO NOTHING`), and `upsertAccount`'s identity re-sync belongs to hub-authenticated callers only — nothing is lost, because both of those already re-sync on every sign-in from GitHub's own answer; (4) the rollup gate reads `membershipBacksRollup` (non-pending AND witnessed), not row-existence; (5) `safeAvatarUrl` admits absolute https only, applied inside both account writers rather than at each render; (6) NO backfill — no predicate separates a legitimate check-in row from a forged one, and the first sign-in after deploy heals a real membership because the witness is stamped BEFORE the check-in and rollup that follow it, so a profile goes stale, never blank. No wire-format change: the request schemas are byte-identical and the ADR 0177 `membership_kind` window is untouched, so no federated instance upgrades. Proof `tests/sso_checkin_identity_spoof.mjs` (DB-free, in the default unit gate; 10 of 14 assertions fail against the pre-fix code, 2 controls prove the gate is not a wall). Rejected: carrying the hub assertion in the check-in body (strict validation would 400 every un-upgraded instance); ADR 0156-style source-guarding (answers "did the user set this", the wrong question when the writer is a third party); backfilling `signin_verified_at = joined_at`; a silent 200; retro-filtering `listMembershipsForAccount`.](<redacted>.md) | platform identity / security |
|
|
296
296
|
| 0206 | [**A leaked bearer's remedy is the account holder's own revoke, not an absolute session ceiling** ([task 1003341](https://cloudbongos.com/builders#/task/1003341), goal 1000045 — criterion `i-control-my-own-access-and-exposure`). `lookupSession` rewrites `expires_at = now() + 24h` on every authenticated request, so a CLI bearer in daily use never expires; if it leaks, the holder keeps it alive simply by using it. `POST /auth/logout` kills only the token presented on that same request — useless when the token you want dead is the one you no longer hold — and the two endpoints that can kill another session are gated on the admin `auth.session.revoke` atom, so the victim had to find an Archon, and a single-owner self-hosted instance may have none. Decision: ship the OWN-scoped remedy (`GET /me/sessions`, `POST /me/sessions/revoke`, `POST /me/sessions/revoke-all` on `requireBuilder` alone, every statement keyed on the caller's own id from the presented session, the token value never selected or returned, `revoke-all` deliberately including the requesting session) and deliberately NOT an absolute ceiling on the sliding refresh: a ceiling is unconditional (migration 035 already had to backfill a 24h grace so it would not kill the session running it), any cap loose enough not to be a daily nuisance leaves a leaked token live for weeks, and the visibility this task ships — `created_at` beside `last_used_at` in the list — is the compounding half of the fix. A demotion already sweeps sessions, and a deactivated builder is denied on the hot path, so the authority-drift case is closed by other means. Rejected: a `created_at + N` cap now; a shorter TTL; three more routes inside the already-largest `routes/auth.js` (a separate file is what lets the no-caller-named-owner rule be asserted over a WHOLE file); a `keep_current` flag; 404 on a session id that is not yours (it would confirm which ids exist); lowering the `auth.session.revoke` floor. Revisit for machine principals, a compliance maximum, or evidence that people do not prune what the list shows them.](<redacted>.md) | auth / account security |
|
|
297
|
+
| 0208 | [**Building the application lifecycle: the handle waits for its writer, and the vouch ADR 0201 D2.1 describes has no minter that can express it** ([task 1002284](https://cloudbongos.com/builders#/task/1002284), goal 1000045 — R08, criteria C3/C4). Builds [ADR 0201](<redacted>.md) D1 + D4 + D3.2 and records where the design could not be written literally. **The contradiction:** D1.2 says the migration adds "exactly" `applicant_github_id` + `vouched_at`; D2.6 says the applicant's handle is stored on the row. Decision — the handle waits for R09 ([task 1002285](https://cloudbongos.com/builders#/task/1002285)), because the ADR's own dependency table routes D2 *wholesale* (including the handle) there, and because R08 ships no vouch writer at all: a permanently-NULL `applicant_handle` would read as "this account has no handle" when it means "R09 has not shipped". **The hole:** D2.1's replay separation cannot be built as described. `mintHubAssertion` takes no `purpose`, and the asymmetry protecting `mintHubLogoutToken` works only because a logout token carries no identity — an application vouch carries both, so it collides with the login assertion's shape; worse, `verifyHubAssertion` does not reject a payload carrying a `purpose`, so a vouch minted that way would verify *as a login assertion* today. R09 needs a third mint/verify pair plus a tightening of `verifyHubAssertion` (free only while no minter emits a `purpose`), and a deliberate choice about the single-use `jti` store. Also decided: the echo's READ joins `platform_identity_sso_clients` (a `client_id` alone is not renderable) while the TABLE stays the three columns D1.3 named; a re-application refreshes `applied_at`, so the 30-day window runs from the most recent ask; an idempotent PATCH returns the row **without** re-stamping `resolved_by`/`resolved_at`, so the audit trail records who actually resolved it; `dismissed → invited` is refused `409 already_dismissed` naming `POST /access-requests/invite`; and `GET /my-projects` drops from `applied` any project the caller now builds on, so the hub never implies an outcome it is not allowed to hold. Honest limits: `vouched` is false for every real row until R09 lands (the two-class queue is proved by a seeded row, not by a live writer); `already_approved` still reaches the hub as "already_member" prose through `joinRelayOutcome`'s bare-409 branch, which is R10/R12's surface; and the proofs are DB-free, so the partial unique index is modelled rather than exercised — five mutations were run to show the fake pool bites. Rejected: adding `applicant_handle` anyway; bolting a `purpose` argument onto `mintHubAssertion`; a `status` column on the echo; refusing `dismissed → invited` with the existing `not_pending`; a new "project owner" atom.](<redacted>.md) | platform identity / admission lifecycle |
|
|
297
298
|
| 0209 | [**Two routes answering one question spend one budget; whether the answer itself collapses is the owner's call** ([task 1003339](https://cloudbongos.com/builders#/task/1003339), goal 1000045 — criterion `i-control-my-own-access-and-exposure`). `GET /access-requests/status` (public, returns `member|invited|pending|dismissed|none`) and `GET /auth/web/admission-status` (its bare-boolean twin) both answer "does this GitHub login have an account here?", and neither was throttled — `rateLimitMiddleware` returns early for every non-write method, and the one read limiter is mounted only under `/public/*`. Scripted at line rate either becomes a login→membership roster: who builds here, and confirmation that a named person is present. Decision: ship the ceiling as ONE budget — `accountExistenceReadRateLimit`, 120 reads/60s per IP over a single bucket, exported as one middleware INSTANCE rather than a factory and mounted on both routes, because two limiters each configured 120/60s look identical in a diff, pass any test that counts to the limit per route, and hand an enumerator 240/min by alternating. The onboarding module reaches it through the doorway, so `src/bongos/middleware/rate-limit.js` joins `KERNEL_FILES` on `logger.js`'s argument: authz decides WHO may ask, the ceiling decides HOW OFTEN, and the file imports nothing. Deliberately NOT decided here: collapsing the response so a non-subject caller cannot tell `pending`/`dismissed`/`none` from "not admitted" — the consumer sweep found route 1 has exactly ONE consumer (`scripts/gds/login.js`), which already treats `pending` and `none` identically, so collapsing costs exactly one thing: the only message in the codebase that tells a declined applicant they were declined. That is a judgement about what an applicant is owed, so it goes to the owner as a blocker with the evidence. Noted either way: collapsing does not close the oracle — `admitted:true` still means member-or-invited. Rejected: a separate limiter per route; a `createReadRateLimit` factory (two matching call sites get two buckets — the generality loses the property); vendoring a fourth copy of the sliding window; hardening only the smaller route; collapsing on my own judgement because "nothing much" reads it.](<redacted>.md) | security / rate limiting |
|
|
298
299
|
| 0207 | [**Building the connection graph: three departures from ADR 0202, and the three limits it is honest about** ([task 1002295](https://cloudbongos.com/builders#/task/1002295), goal 1000045 criterion C6 — the build of the connections wave). ADR 0202 decided the graph; building it surfaced three places where the decision's MECHANISM could not be written literally without losing the PROPERTY it was about, and three places where the shipped code cannot promise as much as a casual reading suggests. Departures: (1) **level-wise CTEs, not `WITH RECURSIVE`** — a recursive term can carry neither a per-level fan-out cap nor an ordered per-level truncation, which are the two things D3 actually decided, so a recursive build would have had a depth bound and no fan-out bound; a test asserts `RECURSIVE` appears in neither query. (2) **1st degree is an unbounded pair-index probe**, never a scan of the capped frontier — reading it off the `FANOUT_1`-truncated 1-hop reports "2nd" for someone in the viewer's own connections list once they pass 500 connections, and the rule this states is that **a cap may only ever make an answer less specific (`null`), never wrong**. (3) The shared visibility predicate lands in its own file, `modules/platform-identity/account-visibility.js` (`isAccountVisible` / `accountVisibleSql` / `accountActiveSql`), because task 1002967 had not shipped and `platform-identity.js` sat at the size ratchet with zero headroom — **1002967 adopts it rather than writing a second predicate**. Also decided: node visibility during traversal is viewer-relative and asymmetric on purpose (your OWN 1-hop floors on consent alone so a friend who goes private does not vanish from your list; every deeper hop clears the public predicate); cancel is a `DELETE` an ADDRESSEE may not use, so the 90-day cooldown cannot be dodged by calling a decline a delete; and the create response is `201 { status: 'pending' }` and nothing else, so a swallowed re-ask has nothing to fabricate. Limits stated rather than implied: a swallowed re-ask is silent in the RESPONSE and observable by its ABSENCE from the requester's outbox (the claim is narrowed, not the code — a decoy row would be a lie in the database to protect a lie in the API); the TARGET's 1-hop is capped too and that truncation is deliberately NOT reported, because saying so leaks one bit about a third party's degree; and the query COUNT is bounded while the frontier's WORK is O(sum of degree over the 1-hop), since `ORDER BY … LIMIT` materialises before it truncates — the revisit trigger is a measured p95, and the cheap next move is a `statement_timeout` returning the `null` D3 already chose. Deferred and named: a per-builder cap on OUTBOUND requests (the cooldown stops repeat asks to one person, not one builder asking a hundred — a real number is owner-signed), and a real-Postgres pass on the expire-then-insert write path, which CI cannot run.](<redacted>.md) | platform identity / social graph / privacy |
|
|
299
300
|
|
package/docs/api/openapi.json
CHANGED
|
@@ -242,7 +242,7 @@
|
|
|
242
242
|
"access-requests"
|
|
243
243
|
],
|
|
244
244
|
"summary": "GET /access-requests",
|
|
245
|
-
"description": "GET /access-requests?status=pending —
|
|
245
|
+
"description": "GET /access-requests?status=pending — the owner's queue. Defaults to pending; pass ?status=invited|dismissed for the resolved history. rank: archon — the queue exposes would-be builders' usernames. EVERY ROW SAYS WHETHER IT IS VOUCHED, and that is load-bearing rather than decoration (ADR 0201, Consequences). This endpoint is fed by the one PUBLIC unauthenticated write on the GDS, so anyone may file a request naming anyone — including a well-known builder. `vouched` is the reviewer's ONLY basis for reading a cross-project record beside a name: an unvouched row that LOOKED vouched is the entire impersonation vulnerability the vouch exists to close. It is derived here from `applicant_github_id IS NOT NULL` — the single mark (core_230) — rather than stored, so there is no second flag free to disagree with the identity it claims to describe. A VOUCH IS NOT AUTHORITY (ADR 0016). It says the hub confirmed WHO this is; it never says they may enter. Nothing on the approve path reads these three fields, and `hasInvitedAccessRequest` still reads status='invited' alone.\n\n**Rank:** `archon` — Archon only (rank and identity management + the escalation keys — the trust boundary).\n\n**Permissions:** `access_request.review` (all required).",
|
|
246
246
|
"x-rank": "archon",
|
|
247
247
|
"x-source": "modules/onboarding/routes/access-requests.js",
|
|
248
248
|
"x-permissions": [
|
|
@@ -400,7 +400,7 @@
|
|
|
400
400
|
"access-requests"
|
|
401
401
|
],
|
|
402
402
|
"summary": "PATCH /access-requests/:id",
|
|
403
|
-
"description": "PATCH /access-requests/:id —
|
|
403
|
+
"description": "PATCH /access-requests/:id — the owner resolves a request. body: { status: 'invited' | 'dismissed' }. 'invited' = admitted (the only sign-in gate — no repo access needed); 'dismissed' = declined / spam / dup. The row stays as the audit trail either way. rank: archon — same gate as the rest of the roster management surface. THREE STATES, AND EVERY MOVE BETWEEN THEM THAT MEANS SOMETHING (ADR 0201 D4): pending → invited approve pending → dismissed decline invited → dismissed RESCIND (task 1002284, D4.2). An approval given by mistake, BEFORE the applicant has ever signed in, must be revocable: hasInvitedAccessRequest stops matching and the door re-locks. AFTER they have signed in the row is history, and this route REFUSES rather than flipping it. The refusal is the whole point: firstTimeAdmissionBlocked short-circuits on an existing `builders` row, so dismissing an invited row for someone who already signed in changes NOTHING about their access — it would answer 200 and let the queue report a removal that did not happen. Offboarding a member is the instance's own act (/builder-exit), and the guard is a NOT EXISTS in the UPDATE's own WHERE so it cannot be lost to a read-then-write window. KNOWN LIMIT: a rescind overwrites resolved_by/ resolved_at with the RESCINDER, so who originally approved is not recoverable from the row, and this route writes no audit_log for any transition (it never has — approve and decline lose nothing only because they start from `pending`, which has no resolver to lose). Carrying the prior approver out would mean a CTE on the one statement in this change that no engine executes in CI; it belongs with the queue UI that would display it (R11). ADR 0208. x → x IDEMPOTENT (D4.4). A double-clicked button in the queue returns the row it already holds, 200, rather than a 409 that reads as a failure. It does NOT re-stamp resolved_by/resolved_at: the audit trail records who ACTUALLY resolved it, not who clicked again afterwards. dismissed → invited REFUSED here on purpose. Re-admitting a declined applicant is POST /access-requests/invite, which already resolves-or-inserts an invited row for any login (D4.2) — a second path to the same write would be the drift. The refusal names the state and that route, per ADR 0116. The transition is enforced in the UPDATE's own WHERE, so a concurrent write loses the race in the database rather than in a read-then-write window. The follow-up SELECT runs ONLY to explain a refusal — it decides nothing. $1 is spelled $1::text in that WHERE because the same parameter is also the SET value. Postgres infers text from the assignment anyway, but no real engine ever executes this statement — there is no integration test for this route and CI has no Postgres — so the type is stated rather than relied on.\n\n**Rank:** `archon` — Archon only (rank and identity management + the escalation keys — the trust boundary).\n\n**Permissions:** `access_request.review` (all required).",
|
|
404
404
|
"x-rank": "archon",
|
|
405
405
|
"x-source": "modules/onboarding/routes/access-requests.js",
|
|
406
406
|
"x-permissions": [
|
|
@@ -16491,11 +16491,13 @@
|
|
|
16491
16491
|
"type": "object",
|
|
16492
16492
|
"properties": {
|
|
16493
16493
|
"owned": {},
|
|
16494
|
-
"building": {}
|
|
16494
|
+
"building": {},
|
|
16495
|
+
"applied": {}
|
|
16495
16496
|
},
|
|
16496
16497
|
"required": [
|
|
16497
16498
|
"owned",
|
|
16498
|
-
"building"
|
|
16499
|
+
"building",
|
|
16500
|
+
"applied"
|
|
16499
16501
|
]
|
|
16500
16502
|
},
|
|
16501
16503
|
"GetOverrideRequestsResponse": {
|
|
@@ -17075,7 +17077,10 @@
|
|
|
17075
17077
|
"ok": {
|
|
17076
17078
|
"type": "boolean"
|
|
17077
17079
|
},
|
|
17078
|
-
"request": {}
|
|
17080
|
+
"request": {},
|
|
17081
|
+
"unchanged": {
|
|
17082
|
+
"type": "boolean"
|
|
17083
|
+
}
|
|
17079
17084
|
},
|
|
17080
17085
|
"required": [
|
|
17081
17086
|
"ok",
|
package/docs/api-reference.md
CHANGED
|
@@ -11,9 +11,9 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
|
|
|
11
11
|
|
|
12
12
|
| Method | Path | Rank | Body | Description |
|
|
13
13
|
|---|---|---|---|---|
|
|
14
|
-
| GET | `/api/bongos/access-requests` | `archon` | — | GET /access-requests?status=pending —
|
|
14
|
+
| GET | `/api/bongos/access-requests` | `archon` | — | GET /access-requests?status=pending — the owner's queue. |
|
|
15
15
|
| POST | `/api/bongos/access-requests` | `public` | `github_login`, `display_name`, `note` | POST /access-requests — PUBLIC. |
|
|
16
|
-
| PATCH | `/api/bongos/access-requests/:id` | `archon` | `status` | PATCH /access-requests/:id —
|
|
16
|
+
| PATCH | `/api/bongos/access-requests/:id` | `archon` | `status` | PATCH /access-requests/:id — the owner resolves a request. |
|
|
17
17
|
| POST | `/api/bongos/access-requests/invite` | `archon` | `github_login` | POST /access-requests/invite — an Archon admits a GitHub username BEFORE any request exists (task 1003044): the only way in on an invite-… |
|
|
18
18
|
| GET | `/api/bongos/access-requests/status` | `public` | — | GET /access-requests/status?github_login=X — PUBLIC. |
|
|
19
19
|
|