@bongos/core 1.19.568 → 1.19.570
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 +43 -28
- package/docs/adr/0256-the-visibility-ceiling-is-the-sign-in-gate-too-and-the-reader-moves-to-the-composer.md +204 -0
- package/docs/adr/README.md +1 -0
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/modules/copy-desk/routes/copy-desk.js +4 -0
- package/modules/lifecycle/cascade.js +11 -0
- package/modules/lifecycle/db-tasks.js +37 -7
- package/modules/lifecycle/kickoff-seed.js +5 -0
- package/modules/lifecycle/routes/task-write-routes.js +12 -0
- package/modules/onboarding/newcomer-restock.js +4 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness.js +5 -3
- package/src/bongos/auth-admission.js +48 -50
- package/src/bongos/project-door.js +75 -2
- package/src/bongos/routes/auth.js +17 -4
- package/src/bongos/routes/instance.js +7 -0
- package/src/branding.js +2 -1
- package/src/module-api.js +1 -1
- package/tests/admission_ceiling_gate.mjs +203 -0
- package/tests/joinability_invite_only.mjs +7 -1
- package/tests/reward_gate.mjs +4 -4
- package/tests/task_goal_required.mjs +223 -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.570",
|
|
6
|
+
"core_contract": "1.19.570",
|
|
7
|
+
"source_commit": "7f686cc8bf351a08ac85f77eea54525fa067509b",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-06T21:14:46.455Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
|
-
"docs_redacted":
|
|
12
|
+
"docs_redacted": 449,
|
|
13
13
|
"agent_docs_stubbed": 24,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2039,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2512,
|
|
20
|
+
"tree_sha256": "e0096a8f2ab3ba5363c72d452787c803202d6f43c78964e74a36a079ff086f2e",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -1794,10 +1794,15 @@
|
|
|
1794
1794
|
"mode": "0000644",
|
|
1795
1795
|
"sha256": "2c82050a733a0a82dde5079bbc486fc3378b2be954817a811aab92b9e46c9c5b"
|
|
1796
1796
|
},
|
|
1797
|
+
{
|
|
1798
|
+
"path": "docs/adr/0256-the-visibility-ceiling-is-the-sign-in-gate-too-and-the-reader-moves-to-the-composer.md",
|
|
1799
|
+
"mode": "0000644",
|
|
1800
|
+
"sha256": "4b7d5b4ca5ad92a128235a6c3407617f2d8b34c26ed6e1c8b2a72e63eed7adac"
|
|
1801
|
+
},
|
|
1797
1802
|
{
|
|
1798
1803
|
"path": "docs/adr/README.md",
|
|
1799
1804
|
"mode": "0000644",
|
|
1800
|
-
"sha256": "
|
|
1805
|
+
"sha256": "52422b934d0b3245605ec0913049be65768a814a6f9aee5f7ad8149c4221a093"
|
|
1801
1806
|
},
|
|
1802
1807
|
{
|
|
1803
1808
|
"path": "docs/api-reference.md",
|
|
@@ -2672,7 +2677,7 @@
|
|
|
2672
2677
|
{
|
|
2673
2678
|
"path": "docs/file-map.md",
|
|
2674
2679
|
"mode": "0000644",
|
|
2675
|
-
"sha256": "
|
|
2680
|
+
"sha256": "dd40cd29025789ec5a9447aa44ec6c7d3bee6aaae183c65713a6a3ca8ed8275b"
|
|
2676
2681
|
},
|
|
2677
2682
|
{
|
|
2678
2683
|
"path": "docs/handoff-template.md",
|
|
@@ -2682,7 +2687,7 @@
|
|
|
2682
2687
|
{
|
|
2683
2688
|
"path": "docs/module-api-changelog.md",
|
|
2684
2689
|
"mode": "0000644",
|
|
2685
|
-
"sha256": "
|
|
2690
|
+
"sha256": "bfa90a0b7ff77b9befa37207824797d0268260fd83f960fb8e2f5ff52bf97e63"
|
|
2686
2691
|
},
|
|
2687
2692
|
{
|
|
2688
2693
|
"path": "docs/modules-contract.md",
|
|
@@ -3837,7 +3842,7 @@
|
|
|
3837
3842
|
{
|
|
3838
3843
|
"path": "modules/copy-desk/routes/copy-desk.js",
|
|
3839
3844
|
"mode": "0000644",
|
|
3840
|
-
"sha256": "
|
|
3845
|
+
"sha256": "63ed245f47d15758ef0926a489ad1fc7ef56d8539e836ed9e319e09b99a4a38e"
|
|
3841
3846
|
},
|
|
3842
3847
|
{
|
|
3843
3848
|
"path": "modules/copy-desk/tests/copy_flags.mjs",
|
|
@@ -5402,7 +5407,7 @@
|
|
|
5402
5407
|
{
|
|
5403
5408
|
"path": "modules/lifecycle/cascade.js",
|
|
5404
5409
|
"mode": "0000644",
|
|
5405
|
-
"sha256": "
|
|
5410
|
+
"sha256": "d609ecb540af92f84e365fc7c4233b0b73517665939bcffe21d068de04167a6d"
|
|
5406
5411
|
},
|
|
5407
5412
|
{
|
|
5408
5413
|
"path": "modules/lifecycle/claim-eligibility.js",
|
|
@@ -5487,7 +5492,7 @@
|
|
|
5487
5492
|
{
|
|
5488
5493
|
"path": "modules/lifecycle/db-tasks.js",
|
|
5489
5494
|
"mode": "0000644",
|
|
5490
|
-
"sha256": "
|
|
5495
|
+
"sha256": "e978cadb879a57d8d7e3bb84657a787ac7e83125666fa2d31013ac64b37b0d7a"
|
|
5491
5496
|
},
|
|
5492
5497
|
{
|
|
5493
5498
|
"path": "modules/lifecycle/db-versions.js",
|
|
@@ -5572,7 +5577,7 @@
|
|
|
5572
5577
|
{
|
|
5573
5578
|
"path": "modules/lifecycle/kickoff-seed.js",
|
|
5574
5579
|
"mode": "0000644",
|
|
5575
|
-
"sha256": "
|
|
5580
|
+
"sha256": "d92124875d601a0ff7ad06bf140cdbce2f1160eb53ea950ccbb509b604733d1c"
|
|
5576
5581
|
},
|
|
5577
5582
|
{
|
|
5578
5583
|
"path": "modules/lifecycle/lifecycle.js",
|
|
@@ -5722,7 +5727,7 @@
|
|
|
5722
5727
|
{
|
|
5723
5728
|
"path": "modules/lifecycle/routes/task-write-routes.js",
|
|
5724
5729
|
"mode": "0000644",
|
|
5725
|
-
"sha256": "
|
|
5730
|
+
"sha256": "87843c62d8ce7af3d7d6aa5772791bd52823afe5f2c4b9b7427ca9f00dd14c11"
|
|
5726
5731
|
},
|
|
5727
5732
|
{
|
|
5728
5733
|
"path": "modules/lifecycle/routes/tasks.js",
|
|
@@ -5867,7 +5872,7 @@
|
|
|
5867
5872
|
{
|
|
5868
5873
|
"path": "modules/onboarding/newcomer-restock.js",
|
|
5869
5874
|
"mode": "0000644",
|
|
5870
|
-
"sha256": "
|
|
5875
|
+
"sha256": "65f8f2de9ac4582a6672ddeddc78b23e98d9b32865f7748efe58057145712161"
|
|
5871
5876
|
},
|
|
5872
5877
|
{
|
|
5873
5878
|
"path": "modules/onboarding/onboarding-state.js",
|
|
@@ -7532,12 +7537,12 @@
|
|
|
7532
7537
|
{
|
|
7533
7538
|
"path": "package-lock.json",
|
|
7534
7539
|
"mode": "0000644",
|
|
7535
|
-
"sha256": "
|
|
7540
|
+
"sha256": "6460f782c667dc03846bfbc1dbf299a80e2825488f4eafa664b39767a5c78c47"
|
|
7536
7541
|
},
|
|
7537
7542
|
{
|
|
7538
7543
|
"path": "package.json",
|
|
7539
7544
|
"mode": "0000644",
|
|
7540
|
-
"sha256": "
|
|
7545
|
+
"sha256": "e0c2ff1988ca94bcc1f36b6defa3e098b12db1fdcbcc1b7616441e492628bffe"
|
|
7541
7546
|
},
|
|
7542
7547
|
{
|
|
7543
7548
|
"path": "public-docs/index.html",
|
|
@@ -7977,7 +7982,7 @@
|
|
|
7977
7982
|
{
|
|
7978
7983
|
"path": "scripts/gds/fitness.js",
|
|
7979
7984
|
"mode": "0000644",
|
|
7980
|
-
"sha256": "
|
|
7985
|
+
"sha256": "a93e0d79fe1d557d09711e9b9bb62ffc7687b91ee2981b71eb89176eb0fc2ce6"
|
|
7981
7986
|
},
|
|
7982
7987
|
{
|
|
7983
7988
|
"path": "scripts/gds/gate-review.js",
|
|
@@ -8947,7 +8952,7 @@
|
|
|
8947
8952
|
{
|
|
8948
8953
|
"path": "src/bongos/auth-admission.js",
|
|
8949
8954
|
"mode": "0000644",
|
|
8950
|
-
"sha256": "
|
|
8955
|
+
"sha256": "8dc4cc0ab956d387b8044c22e83fce883f2f0129ed27c03503f7348e9d4f00bb"
|
|
8951
8956
|
},
|
|
8952
8957
|
{
|
|
8953
8958
|
"path": "src/bongos/auth-config.js",
|
|
@@ -9082,7 +9087,7 @@
|
|
|
9082
9087
|
{
|
|
9083
9088
|
"path": "src/bongos/project-door.js",
|
|
9084
9089
|
"mode": "0000644",
|
|
9085
|
-
"sha256": "
|
|
9090
|
+
"sha256": "60a342af5b1d4aaeb781faf5bfea75d69a32b9bba71d0740ccc4ca1a9af2bba4"
|
|
9086
9091
|
},
|
|
9087
9092
|
{
|
|
9088
9093
|
"path": "src/bongos/project-settings.js",
|
|
@@ -9122,7 +9127,7 @@
|
|
|
9122
9127
|
{
|
|
9123
9128
|
"path": "src/bongos/routes/auth.js",
|
|
9124
9129
|
"mode": "0000644",
|
|
9125
|
-
"sha256": "
|
|
9130
|
+
"sha256": "f7e24d83d5fceceb33eacc7d82ee8136a2177bb190e5b912e5857ffc07090009"
|
|
9126
9131
|
},
|
|
9127
9132
|
{
|
|
9128
9133
|
"path": "src/bongos/routes/backup.js",
|
|
@@ -9147,7 +9152,7 @@
|
|
|
9147
9152
|
{
|
|
9148
9153
|
"path": "src/bongos/routes/instance.js",
|
|
9149
9154
|
"mode": "0000644",
|
|
9150
|
-
"sha256": "
|
|
9155
|
+
"sha256": "c3bfdf5f1242fe4bbbb51ea79401dd5ae09cca95adc475e0dc0e5c76055677c4"
|
|
9151
9156
|
},
|
|
9152
9157
|
{
|
|
9153
9158
|
"path": "src/bongos/routes/live.js",
|
|
@@ -9217,7 +9222,7 @@
|
|
|
9217
9222
|
{
|
|
9218
9223
|
"path": "src/branding.js",
|
|
9219
9224
|
"mode": "0000644",
|
|
9220
|
-
"sha256": "
|
|
9225
|
+
"sha256": "2bbb5774c9ae8659b4d5ce9b62cb558d28d036b2aa0075f9329ae85c15734a2d"
|
|
9221
9226
|
},
|
|
9222
9227
|
{
|
|
9223
9228
|
"path": "src/build-info.js",
|
|
@@ -9237,7 +9242,7 @@
|
|
|
9237
9242
|
{
|
|
9238
9243
|
"path": "src/module-api.js",
|
|
9239
9244
|
"mode": "0000644",
|
|
9240
|
-
"sha256": "
|
|
9245
|
+
"sha256": "2679c7764d5c874f7f1ecc546e552276b9e540bb66ee9f40fb3a281b9294543f"
|
|
9241
9246
|
},
|
|
9242
9247
|
{
|
|
9243
9248
|
"path": "src/module-loader/catalog.js",
|
|
@@ -9309,6 +9314,11 @@
|
|
|
9309
9314
|
"mode": "0000644",
|
|
9310
9315
|
"sha256": "f7434d510d656e5081f735d1f9356ecb11e8c3630a86a86137c3290134541c8d"
|
|
9311
9316
|
},
|
|
9317
|
+
{
|
|
9318
|
+
"path": "tests/admission_ceiling_gate.mjs",
|
|
9319
|
+
"mode": "0000644",
|
|
9320
|
+
"sha256": "79c216e3ad3d91388a4a48f4ec5f3af9cf440d82791f05a1eb89860573be8dff"
|
|
9321
|
+
},
|
|
9312
9322
|
{
|
|
9313
9323
|
"path": "tests/adopt_detect.mjs",
|
|
9314
9324
|
"mode": "0000644",
|
|
@@ -11017,7 +11027,7 @@
|
|
|
11017
11027
|
{
|
|
11018
11028
|
"path": "tests/joinability_invite_only.mjs",
|
|
11019
11029
|
"mode": "0000644",
|
|
11020
|
-
"sha256": "
|
|
11030
|
+
"sha256": "0c2c540a794fcdafbdf4c496f2dd3efc508edfb9f30f88a6c0f977fc99bb75cc"
|
|
11021
11031
|
},
|
|
11022
11032
|
{
|
|
11023
11033
|
"path": "tests/joinability_open.mjs",
|
|
@@ -11827,7 +11837,7 @@
|
|
|
11827
11837
|
{
|
|
11828
11838
|
"path": "tests/reward_gate.mjs",
|
|
11829
11839
|
"mode": "0000644",
|
|
11830
|
-
"sha256": "
|
|
11840
|
+
"sha256": "1ed242c879472dd872c1327c8e7c91de61886dcb108ece3fa455ae6be4b4e025"
|
|
11831
11841
|
},
|
|
11832
11842
|
{
|
|
11833
11843
|
"path": "tests/reward_mode_cost_plus_only.mjs",
|
|
@@ -12354,6 +12364,11 @@
|
|
|
12354
12364
|
"mode": "0000644",
|
|
12355
12365
|
"sha256": "93a5d1e91bfea7af8487b409e60cd609ea25a690df9099d278ebd35f253abcb8"
|
|
12356
12366
|
},
|
|
12367
|
+
{
|
|
12368
|
+
"path": "tests/task_goal_required.mjs",
|
|
12369
|
+
"mode": "0000644",
|
|
12370
|
+
"sha256": "915711866bc194d977225326511c55ac5084b52d10a45a855bc9d6da815f4b8b"
|
|
12371
|
+
},
|
|
12357
12372
|
{
|
|
12358
12373
|
"path": "tests/task_lib.mjs",
|
|
12359
12374
|
"mode": "0000644",
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# ADR 0256 — The visibility ceiling binds the SIGN-IN gate too, and the knob reader moves to the composer
|
|
2
|
+
|
|
3
|
+
- **Status:** accepted
|
|
4
|
+
- **Date:** 2026-09-06
|
|
5
|
+
- **Task:** [task 1003579](https://cloudbongos.com/builders#/task/1003579) (BONGOS-V1, goal 1000046 — *Project creation*, criterion `proj-join-policy`)
|
|
6
|
+
- **Deciders:** Claude, under the Archon's standing scope
|
|
7
|
+
- **Closes:** [ADR 0247](<redacted>.md)'s written *KNOWN LIMIT* — *"the door narrows, the sign-in gate does not"*
|
|
8
|
+
- **Implements:** [ADR 0182](<redacted>.md) D6's **Self-join** column (the half ADR 0247 enforced was the *Applications* column)
|
|
9
|
+
- **Related:** [ADR 0194](<redacted>.md) (the one-reader move this repeats one level up; D2's fail-closed rule) · [ADR 0192](<redacted>.md) §2 (where that fail-closed rule comes from) · [ADR 0050](<redacted>.md) (the legacy `OPEN_XENOS_ENROLLMENT` flag) · [ADR 0190](<redacted>.md) (how a knob reaches the instance)
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Context
|
|
14
|
+
|
|
15
|
+
ADR 0182 D6 gave the project plane a two-column table — who may **self-join**, and who
|
|
16
|
+
may **apply** — and one composition rule: *visibility sets the ceiling; join policy may
|
|
17
|
+
only narrow it.* ADR 0247 built that composition (`src/bongos/project-door.js`,
|
|
18
|
+
`projectJoinDoor()`) and wired it to the **applications** column: the public
|
|
19
|
+
`POST /access-requests`, and through it the hall's landing form, the CLI and the hub's
|
|
20
|
+
join relay.
|
|
21
|
+
|
|
22
|
+
It did not wire the **self-join** column, and said so in as many words:
|
|
23
|
+
|
|
24
|
+
> **KNOWN LIMIT — the door narrows, the sign-in gate does not.** `openEnrollmentEnabled()`
|
|
25
|
+
> is still `joinabilityMode() === 'open'` … Closing it means composing the ceiling into
|
|
26
|
+
> `src/bongos/auth-admission.js`, a kernel admission surface this task was scoped out of
|
|
27
|
+
> touching.
|
|
28
|
+
|
|
29
|
+
So an owner who took their project off the map — `visibility: private`, or `stealth`, or
|
|
30
|
+
`joinGrant: view` — while leaving `joinability: open` got two surfaces answering the same
|
|
31
|
+
stranger differently: the application endpoint queued a request for review, and a
|
|
32
|
+
first-time GitHub sign-in handed the same person a `xenos` account with no request and no
|
|
33
|
+
invite. Half of D6's table held only where the owner had never explicitly opened enrolment.
|
|
34
|
+
|
|
35
|
+
The gap's direction was safe — the door was *stricter* than the gate, never looser, so
|
|
36
|
+
nobody was admitted whom `joinability` alone had not already admitted — which is why
|
|
37
|
+
ADR 0247 shipped it as a written limit rather than halting.
|
|
38
|
+
|
|
39
|
+
## Decision 1 — `openEnrollmentEnabled()` reads the composed door, and nothing else moves
|
|
40
|
+
|
|
41
|
+
```js
|
|
42
|
+
function openEnrollmentEnabled(deps = {}) {
|
|
43
|
+
return effectiveJoinDoor(deps) === 'open';
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
That one expression is the whole behavioural change. It is deliberately the *same* SR-19
|
|
48
|
+
move ADR 0194 D1 made when `joinabilityMode` replaced the raw env flag, one level further
|
|
49
|
+
up: the four sign-in call sites (`auth-github.js` web + device, `auth-idp.js` federated
|
|
50
|
+
twins), `firstTimeAdmissionBlocked`, `webAdmissionStatus` and the manifest's
|
|
51
|
+
`admission.policy` all read this one function, so they follow without being touched. Two
|
|
52
|
+
surfaces that ask the same function cannot answer one stranger differently; two surfaces
|
|
53
|
+
that each read "the relevant knob" always eventually do.
|
|
54
|
+
|
|
55
|
+
**Nothing changes for any existing project.** `src/branding.js` normalises an absent
|
|
56
|
+
`visibility` to `public` and an absent `joinGrant` to `full` (ADR 0247 D1's "the default
|
|
57
|
+
is the value that narrows nothing"), so `public` + `full` puts the ceiling at `open` and
|
|
58
|
+
the composed door is `joinability` verbatim. Every live project is `public`; a self-hosted
|
|
59
|
+
instance carrying the legacy `OPEN_XENOS_ENROLLMENT` flag (ADR 0050) still resolves to
|
|
60
|
+
`open` through the same branch it always did.
|
|
61
|
+
|
|
62
|
+
**The ceiling closes self-enrolment and nothing else.** An invited first-timer, an
|
|
63
|
+
existing builder and the configured `firstAdmin` are all admitted to a `private` or
|
|
64
|
+
`stealth` project exactly as before — `firstTimeAdmissionBlocked`'s other three terms are
|
|
65
|
+
untouched. An owner taking their project off the map is closing the front door, not
|
|
66
|
+
evicting the people they let in, and a `private` project that locked out its own founder
|
|
67
|
+
would be the R62 bootstrap dead-end all over again.
|
|
68
|
+
|
|
69
|
+
## Decision 2 — The manifest's `admission.policy` reports the GATE; `brand.project` keeps reporting the SETTING
|
|
70
|
+
|
|
71
|
+
`GET /instance` carries both, and after this change they can differ: a `private` +
|
|
72
|
+
`joinability: open` project reports `brand.project.joinability: "open"` and
|
|
73
|
+
`admission.policy: "invite-only"`.
|
|
74
|
+
|
|
75
|
+
That is not a contradiction, it is the two questions the manifest has always been
|
|
76
|
+
answering — *what did the owner set?* and *what will the gate do?* — finally giving
|
|
77
|
+
honest answers to both. Before this, `admission.policy` said `open` while the gate would
|
|
78
|
+
have refused, and a CLI reading it to decide whether to offer `bongos login` believed it.
|
|
79
|
+
|
|
80
|
+
**The narrowed value leaks no state.** `admission.policy` is binary and `invite-only` is
|
|
81
|
+
already what every `apply` project reports, so `private`, `stealth`, a `view` grant and a
|
|
82
|
+
plain `invite_only` project remain indistinguishable through it — ADR 0247 D3's rule that
|
|
83
|
+
a refusal may never be read backwards into a state, preserved because the field had only
|
|
84
|
+
two values to begin with. Keeping the read-back honest on `brand.project` is the other
|
|
85
|
+
half of the same rule (ADR 0194 D1: the manage page's *"did my save take effect?"* must
|
|
86
|
+
show the owner's own value, which is why the ceiling is composed on the instance rather
|
|
87
|
+
than folded into what the platform pushes).
|
|
88
|
+
|
|
89
|
+
## Decision 3 — The knob reader moves to the composer, because the alternative is a require cycle
|
|
90
|
+
|
|
91
|
+
`joinabilityMode()` and its `legacyOpenEnrollmentFlag()` helper moved from
|
|
92
|
+
`src/bongos/auth-admission.js` into `src/bongos/project-door.js`. `auth-admission`
|
|
93
|
+
**re-exports** `joinabilityMode`, so `auth.js`, the doorway's `api.joinabilityMode` and
|
|
94
|
+
every existing import keep resolving; the doorway surface is unchanged, so this needs no
|
|
95
|
+
`module.json` floor bump (contrast ADR 0194 D7, which added an export).
|
|
96
|
+
|
|
97
|
+
The reason is mechanical and worth writing down, because the obvious patch does not
|
|
98
|
+
compile past the gate. `project-door.js` resolved its own `joinability` input with a lazy
|
|
99
|
+
`require('./auth-admission')`. The moment the admission gate requires the door back, the
|
|
100
|
+
two files point at each other — and `fitness-ratchets.js` counts require cycles from the
|
|
101
|
+
**source text**, so a lazy require inside a function body is still an edge. The
|
|
102
|
+
`require_cycle_count` ratchet is shrink-only at 4; adding a fifth is a hard build failure,
|
|
103
|
+
and it would be right to fail: a cycle here is a real one, not a measurement artifact.
|
|
104
|
+
|
|
105
|
+
Three ways out were considered:
|
|
106
|
+
|
|
107
|
+
1. **Duplicate the composition in the gate.** Rejected on sight — it is the exact drift
|
|
108
|
+
SR-19, ADR 0194 and ADR 0247 were each written to prevent, and the ratchet would only
|
|
109
|
+
be measuring that we had hidden it.
|
|
110
|
+
2. **Make `joinabilityMode` a required argument of `effectiveJoinDoor`, supplied by the
|
|
111
|
+
doorway.** Zero new files and no cycle, but it turns `effectiveJoinDoor()` — the
|
|
112
|
+
no-argument call ADR 0247 D2 published as *the* one call a door surface makes — into a
|
|
113
|
+
footgun that silently answers `invite_only` for any future caller who forgets.
|
|
114
|
+
3. **Move the reader to the composer** (chosen). The file whose header already says it is
|
|
115
|
+
*"composed from every knob that can close it"* now owns the readers for all of them.
|
|
116
|
+
The dependency runs one way — gate → door → branding/env — `effectiveJoinDoor()` keeps
|
|
117
|
+
working with no arguments, and one lazy cross-require disappears rather than a second
|
|
118
|
+
appearing.
|
|
119
|
+
|
|
120
|
+
`joinabilityMode` was only ever in the admission file because the admission gate was its
|
|
121
|
+
first caller (ADR 0194); the layering was accidental, and this task is what surfaced it.
|
|
122
|
+
Its `[auth]` log prefix is kept verbatim across the move — operators grep sign-in refusals
|
|
123
|
+
by it, and the child logger's `src` field already says which file emitted the line.
|
|
124
|
+
|
|
125
|
+
## Decision 4 — `locked` still cannot fire from the sign-in gate
|
|
126
|
+
|
|
127
|
+
`openEnrollmentEnabled` passes **no** `publishable` verdict, so the tri-state stays `null`
|
|
128
|
+
— *no verdict* — and the door is answered on the three stored knobs alone. ADR 0247 D4's
|
|
129
|
+
finding has not changed: neither plane holds a live, correct dark-matter verdict, the
|
|
130
|
+
instance's branding pack carries no `description`, and the hub's cached copy says every
|
|
131
|
+
live project is dark matter. A gate that read that column would refuse every first sign-in
|
|
132
|
+
on the platform. The `deps` object is forwarded verbatim, so wiring a verdict later is one
|
|
133
|
+
argument at one call site, not a redesign.
|
|
134
|
+
|
|
135
|
+
## Decision 5 — The sign-in refusal page tests the door, not the knob
|
|
136
|
+
|
|
137
|
+
`routes/auth.js` chose between its two refusal pages on
|
|
138
|
+
`joinabilityMode() === 'invite_only'`, which was equivalent to the door only while the
|
|
139
|
+
knob *was* the door. Under Decision 1 the ceiling can close a project whose `joinability`
|
|
140
|
+
still reads `apply` — `stealth`, or a `view` grant — and that page would have handed
|
|
141
|
+
the stranger a "request access at the hall" form the application endpoint 403s. The test
|
|
142
|
+
becomes `effectiveJoinDoor() !== 'apply'`: `apply` is the only door that accepts an
|
|
143
|
+
application, so everything else lands on the invite-only page.
|
|
144
|
+
|
|
145
|
+
**Both closed doors show the same sentence, and that is the point.** Distinguishing them
|
|
146
|
+
would be the state read-out ADR 0247 D3 forbids; showing one honest "this project takes no
|
|
147
|
+
applications, an invite is the way in" for all of them is the same indistinguishability
|
|
148
|
+
the application endpoint already has. `open` cannot reach this branch (the gate would have
|
|
149
|
+
admitted) and `locked` cannot fire at all (D4), so in practice it is `invite_only`.
|
|
150
|
+
|
|
151
|
+
## What was deliberately not built
|
|
152
|
+
|
|
153
|
+
- **No new refusal COPY.** The two existing pages are reused verbatim; only which one is
|
|
154
|
+
chosen changed (Decision 5). Saying *"this project is private"* would be the state
|
|
155
|
+
read-out ADR 0247 D3 forbids, and a sentence written for this case alone would make the
|
|
156
|
+
refusals distinguishable — the same leak by another route.
|
|
157
|
+
- **The hall landing's door copy is left on the raw knob — a NAMED limit, in the same
|
|
158
|
+
shape ADR 0247 named this one.** `modules/hall-ui/public/hall-render.js` renders one
|
|
159
|
+
landing per `window.__BRANDING__.project.joinability`, and its own comment already calls
|
|
160
|
+
that *"cosmetic — the server refuses on its own terms either way"*. After this change
|
|
161
|
+
a `private` + `open` project's landing still reads "sign in and you're in" while the gate
|
|
162
|
+
refuses, so the visitor meets the truth one click later instead of on the page. Fixing it
|
|
163
|
+
properly means the server injecting the COMPOSED door into the pack the browser reads
|
|
164
|
+
— re-deriving the composition in browser JS is the drift this whole line of ADRs
|
|
165
|
+
exists to prevent — which is a public-field addition, not a side effect of this one.
|
|
166
|
+
It is reachable only on a project whose owner has taken it off the map, and every live
|
|
167
|
+
project is `public` (ADR 0247's own Consequences).
|
|
168
|
+
- **No `webAdmissionStatus` special case.** It reads `openEnrollmentEnabled()` like the
|
|
169
|
+
gate does, and `tests/web_admission_status.mjs` already sweeps the input space asserting
|
|
170
|
+
the probe and the gate agree. The composition arrives there for free; a second code path
|
|
171
|
+
would be a second thing to keep in sync.
|
|
172
|
+
- **No manage-page control for the grant.** Still a rendering task, unchanged from
|
|
173
|
+
ADR 0247's "what was deliberately not built".
|
|
174
|
+
|
|
175
|
+
## Consequences
|
|
176
|
+
|
|
177
|
+
- ADR 0182 D6's table is enforced in **both** columns, from one function, on every surface
|
|
178
|
+
that admits or refuses a stranger.
|
|
179
|
+
- `tests/admission_ceiling_gate.mjs` boots a real instance on `visibility: private` +
|
|
180
|
+
`joinability: open` — the exact pair ADR 0247's limit paragraph named — through the
|
|
181
|
+
production channel (`<PREFIX>_VISIBILITY` / `<PREFIX>_JOINABILITY` in the environment,
|
|
182
|
+
read once through the memoized pack, as the provisioning runner writes them into
|
|
183
|
+
`web.env`). It then sweeps all 75 knob combinations asserting `openEnrollmentEnabled()`
|
|
184
|
+
equals *"the composed door is `open`"*, and that **exactly one** combination —
|
|
185
|
+
`public` / `full` / `open` — self-enrols.
|
|
186
|
+
- `src/bongos/project-door.js` no longer imports `src/bongos/auth-admission.js`; the
|
|
187
|
+
`KERNEL_FILES` note in `fitness.js` that described that import is updated with it.
|
|
188
|
+
`require_cycle_count` stays at 4.
|
|
189
|
+
- A surface that genuinely wants the owner's *setting* rather than the *door* — the
|
|
190
|
+
manifest read-back, the hall's door copy — still calls `joinabilityMode()`. The
|
|
191
|
+
distinction is now written on the function itself, because "read the knob" and "decide
|
|
192
|
+
admission" being the same call is what produced this bug.
|
|
193
|
+
|
|
194
|
+
## Alternatives rejected
|
|
195
|
+
|
|
196
|
+
- **Composing the ceiling into the `joinability` value the platform pushes**, so the
|
|
197
|
+
instance needs no second reader. Rejected here for the same reasons ADR 0247 rejected
|
|
198
|
+
it: stored and pushed values would disagree, the manage page's read-back would break,
|
|
199
|
+
and it cannot express `locked`. This task is additional evidence for that call — the
|
|
200
|
+
read-back is exactly what `tests/admission_ceiling_gate.mjs` now pins.
|
|
201
|
+
- **Leaving the gap and documenting it harder.** It was already documented; a written
|
|
202
|
+
limit that survives two releases is a bug with good manners. The direction being safe
|
|
203
|
+
bought the deferral once, not indefinitely — and the owner-facing statement *"my project
|
|
204
|
+
is private"* is false while any stranger can still sign themselves in.
|
package/docs/adr/README.md
CHANGED
|
@@ -340,6 +340,7 @@ This keeps the decision history honest and traceable.
|
|
|
340
340
|
| 0250 | [**Strict versioning — the version boundary is the scope gate** ([task 1003589](https://cloudbongos.com/builders#/task/1003589) · goal 1000086). The work hierarchy ([ADR 0086](<redacted>.md)) has a bottom and no top: tasks close, criteria close, goals close, and a version never does. Measured live — **14 goals reading `achieved`/`archived` while holding 35 unfinished tasks**, 523 open tasks across 37 open goals, and **two versions `building` on one project**; every number was worse than the planning session had recorded two days earlier, which is the argument: the drift is the steady state, not a backlog. The cause is four mechanical gaps, not a social one — `POST /goals` fetches the version row and never reads its `status`; goal achievement is computed from criteria alone and never consults `tasks` (both the `db-goals.js` writer and the `done-when.js` cascade); `POST /goals/:id/archive` checks nothing at all; and no route ships a version, so version status has only ever moved as hand-run SQL on the droplet. **Decision, five rules:** the goal set closes when a version starts `building` (new goals go on the `planning` version — where the next version is scoped in advance) with a named 409 an agent can act on; a goal reaches `achieved` only when its criteria are satisfied **and** no task in it is non-terminal, where terminal is `shipped`|`abandoned` per [task 1003588](https://cloudbongos.com/builders#/task/1003588)'s one vocabulary and the mid-ship `completed`/`confirmed` hold it open; archiving a goal that holds open tasks refuses until the caller dispositions each (abandon with the reason, or move to a named surviving goal); `goal_id` becomes required and the per-version catch-all fallback is deleted; and exactly one version builds per project, auto-closing when its last non-maintenance goal achieves, with the single `planning` version promoting itself so the project is never without a live train. **The escape hatches are deliberate and visible**: an Archon may admit one *named* goal to a building version with a reason, and the overrides are counted on the version (the standard failure of a hard scope gate is a fake hotfix version cut to hold what did not fit — an untraced override is just a slower version of no rule); plus one auto-created maintenance goal per version, exempt from the auto-close count, so bugs never need an override to be filed. **Nothing here can refuse a ship, a claim, or a task** — every rule acts on a *closing* move. Rejected: counting `completed`/`confirmed` as finished (they can fail a grade or strand — the goals **board** buckets them as done, but that is a progress ring, and `task-status.js` keeps the two questions apart); a nightly reconciler that reopens wrongly-closed goals (detection is what you build when prevention is unavailable, and it is available here); a human review gate before a goal may close (the queue [ADR 0183](<redacted>.md) dismantled — the check is a fact the table already holds); concurrent building versions scoped by module (two live trains means every "what is in this version" question has two answers); retiring versions entirely (the version is the unit the owner sets scope in and `limitations/<version>-shipped.md` archives). **Amends [ADR 0157](<redacted>.md) for exactly one key:** `version.create` returns to the Archon floor. 0157's "authoring delegates, disposition does not" was right about what a version *was* — but once the boundary carries the scope gate, cutting a new version is no longer authoring, it is the way *around* the gate, so create joins close as a disposition over scope.](<redacted>.md) | lifecycle / versioning / scope |
|
|
341
341
|
| 0251 | [**A suggestion of who to invite is a recruiting surface, and owes the recruiting opt-out** ([task 1002347](https://cloudbongos.com/builders#/task/1002347) · goal 1000046, criterion `proj-invite-builders` / C6). BV1.R28 fills the seam [ADR 0239](<redacted>.md) §6 left open: the wizard’s pure rule engine emits a **seek** (`solo` → `null`, `small-team` → 3, `community` → 5) and never a list, because ranking builders is a directory read a pure file may not run and `provisioning` may not import a sibling. Two readers could answer “who are the top builders” and they differ by **one SQL predicate**: `economy`’s `leaderboard()` joins `rollupVisibleSql` and deliberately honours **no** recruiting opt-out, while `platform-identity`’s `listScoutingRollup()` joins `scoutingListableSql`, which carries it. The leaderboard is cheaper in every mechanical sense — already a public route, no port, callable straight from the browser. **Decision: use the recruiting directory anyway**, because `account-visibility.js` says the split in its own words (`hide_stats` suppresses *numbers* everywhere; the recruiting opt-out “removes an account from the RECRUITING SURFACE only”, community spec D3), and a list of people the platform proposes you recruit **is** that surface — sourcing it from the leaderboard would route around the one control built for this exact case and name every builder who asked not to be recruited. The rule for later readers: **the cheapness of a public read is not an argument about who consented to appear in it.** The lookup ships as `listInviteSuggestions` on the existing `platform-identity.hub` port, reached with `resolveOptional` and NOT declared in `consumes` (the `catalog-bridge.js` precedent — platform-identity is hub-only, so its absence is “no suggestions”, never a 500); `github_id` **does not cross the port** (explicit whitelist — login, display name, avatar, disciplines, two counts — so a new rollup column cannot leak by default), and the owner-exclusion is applied inside, over-read by the exclusion count so filtering never returns short. The route `GET /provisioning/instances/:id/invite-suggestions` is own-scoped by the **existing** `instanceAccessDenied` (same 404 as the manage read) and deliberately does NOT reuse `/scouting`’s gate ([ADR 0210](<redacted>.md)): a brand-new owner has no hub-witnessed owner row at the moment the done panel paints, so the correct-looking gate would refuse the exact caller the feature exists for — and reaching the route IS the ownership answer ([ADR 0249](<redacted>.md) §4). On the surface: a **second door in** (`setInviteSuggestions`) beside `setInviteSlug`, so the widget still answers neither “signed in” nor “theirs”; the reason is **always** said including when the answer is nobody (`seeking: null` costs no query — rendering a stated answer as an empty list would make it look like a failed lookup); a picked name fills the field and takes the **same write path** as a typed one, so the refusal vocabulary cannot fork; a filed invite stops being a suggestion; and every failure is silent, because a suggestion is an extra and a just-created project must not look broken over one. Rejected: the browser reading the economy leaderboard (the whole decision — consent, not architecture); reusing `/scouting` (right data, gate that refuses this caller); filling `invites.suggested` inside the engine (I/O in a pure file + a sibling import); a `provisioning → economy` port (legal, still the wrong directory — the boundary was never the objection).](<redacted>.md) | provisioning / platform-identity / privacy |
|
|
342
342
|
| 0252 | [**The self-hosted visibility report is a PULL, and auto-appear buys a retraction lever** ([task 1002321](https://cloudbongos.com/builders#/task/1002321) · goal 1000046, criterion `proj-visibility-states` / C1, BV1.R02 — a spike whose output is this decision; gates [task 1002327](https://cloudbongos.com/builders#/task/1002327) / R08). **The premise failed first:** a self-hosted instance has no channel to the hub and no credential to open one — `registerClient` has exactly one caller in the tree (the operator's own `provision-net.js`), gated `hosting_shape !== 'co-tenant'`, and no route registers a client. So *federated* ⊆ *operator-provisioned co-tenant* and *self-hosted* holds nothing, deliberately: [ADR 0145](<redacted>.md) says do NOT extend federation to `standalone`, and [ADR 0144](<redacted>.md)'s Known Limitation rests on "every secret-holder is an operator-provisioned, trusted instance … the proper fix must land before federation opens to untrusted third-party clients." [ADR 0182](<redacted>.md) D5's "accepts a report from the authenticated `sso_client`" described a client that, for this population, does not exist. **Decision 1 — the channel is a PULL of the instance's already-public manifest** (`GET <origin>/api/gds/instance` → `branding.project.visibility`, a value every core already serves and whose author anticipated this consumer). It buys five things a push must build: no credential (0144's tripwire untouched — the whole reason), **no replay surface** (a pull reads current state, so the `jti` machinery D5 handed R02 is moot — replay is answered by not having a message), no wire change on another instance's upgrade clock ([ADR 0205](<redacted>.md)'s and [ADR 0201](<redacted>.md) D2.2's rejected-required-field problem simply absent), the hub still authorising nothing (0182 D5 satisfied, [ADR 0238](<redacted>.md)'s rejected `PATCH /projects/:id/visibility` still rejected), and a fetch that already exists behind a real SSRF guard (`project-stats.js` — DNS-resolve-all, `redirect: 'error'`, TTL + negative caches). The pull carries `visibility` and NOTHING else, so the property the map has today — a project owner controls no free-text field that reaches the feed — is not spent. **Decision 2 (the Archon's) — auto-appear:** no human stands between a public project and the map; `featured` keeps only the ranking job [ADR 0182](<redacted>.md) D3 left it. Conditional on Decision 4. **Decision 3 (the Archon's) — trust follows provenance:** both populations auto-appear, and the asymmetry is in the GUARDS, not a gate — a control-plane row's `visibility` is *assigned* by the bridge from an own-scoped audited route (authoritative), a self-hosted row's is *pulled* from a manifest the hub does not control (a cached claim with a recency stamp, able to move its own row and nothing else). An instance is authoritative about its own door and about nothing else. **Absent is a THIRD state and must not purge:** `null` (never read) ≠ `false` (read, negative). 0182 D5's fail-closed default holds only for a self-hosted row that has NEVER answered; a row that answered before and is unreachable now keeps its last state with a recency stamp ([ADR 0199](<redacted>.md), [ADR 0226](<redacted>.md)), because [ADR 0247](<redacted>.md) D4 found the live shape of that trap — every catalog row at `description: null`, so naive fail-closed would have emptied the map at once. Narrowing is free; widening is what costs. **Decision 4 — auto-appear buys a retraction lever, in the same task** ([ADR 0047](<redacted>.md): the straight-through path and its abuse guard are one task, treated as binding): `platform_identity_projects.status` is `(active,hidden)` and **no route writes it** — takedown is hand SQL today, and a one-way door onto a public surface is not a door. R08 ships `PATCH /projects/:id/status` (curate floor, audited), a per-IP limit on the unmetered CORS-open feed read ([ADR 0209](<redacted>.md)), and above all **`hidden` STICKY against the pull** — a retracted project that keeps reporting `public` stays hidden, or the next sweep silently undoes every takedown and the lever is decorative. Also named: `hall_origin` reaches the feed from the remote's own discovery index checked only for *differing* from the project host — the opposite of containment — and gets `shipUrlWithinClientOrigin`'s downward-only rule, filed against R08. Rejected: a pushed credentialed report (the literal R08 — needs the credentials 0144/0145 forbid, plus replay, rate and a wire-compatible field, none of which the pull has); `PATCH /projects/:id/visibility` (0238 already); a one-time admission and ongoing curation (both offered to the Archon, both declined); trusting the pull to move `status`; enforcing "unreported ⇒ dark matter" across all rows; letting the pull carry name/tagline/art. The later ANALYTICS feed is deliberately NOT decided here — it carries per-person data, exactly what [ADR 0205](<redacted>.md) proved cannot ride an unwitnessed self-report, and it needs the 0144 credential work first.](<redacted>.md) | platform-identity / provisioning / federation trust |
|
|
343
|
+
| 0256 | [**The visibility ceiling binds the SIGN-IN gate too, and the knob reader moves to the composer** ([task 1003579](https://cloudbongos.com/builders#/task/1003579) · goal 1000046, criterion `proj-join-policy`). Closes the *KNOWN LIMIT* [ADR 0247](<redacted>.md) wrote down: it composed `visibility × joinGrant × joinability` into one door and enforced it at the public application endpoint, leaving `openEnrollmentEnabled()` reading the `joinability` knob alone — so a project set `private` (or `stealth`, or `joinGrant: view`) while `joinability` stayed `open` **queued applications for review and still let any first-time GitHub account sign itself in at xenos**. Half of [ADR 0182](<redacted>.md) D6’s table — its whole *Self-join* column — was unenforced wherever the owner had explicitly opened enrolment. **Decision 1:** `openEnrollmentEnabled()` becomes `effectiveJoinDoor(deps) === 'open'`, the same SR-19 move [ADR 0194](<redacted>.md) D1 made one level down, so the four sign-in call sites, `firstTimeAdmissionBlocked`, `webAdmissionStatus` and the manifest all follow untouched — and **nothing changes for any existing project**, because `branding.js` normalises an absent visibility to `public` and an absent grant to `full`, the pair that narrows nothing. The ceiling closes **self-enrolment only**: an invited first-timer, an existing builder and the configured `firstAdmin` still get in, or a private project locks out its own founder. **Decision 2:** the manifest’s `admission.policy` now reports the GATE while `brand.project` keeps reporting the SETTING — they may differ, because they answer different questions, and `invite-only` is already what every `apply` project reports, so narrowing into it names no state ([ADR 0247](<redacted>.md) D3). **Decision 3 — the mechanical one:** `joinabilityMode()` moves from `src/bongos/auth-admission.js` into `src/bongos/project-door.js` (re-exported, so no import path and no doorway export changes). The door resolved its own joinability input with a lazy `require('./auth-admission')`; the moment the gate requires the door back the two files point at each other, and the `require_cycle_count` ratchet counts edges from **source text**, so laziness is no escape. Rejected: duplicating the composition in the gate (the drift SR-19 exists to prevent), and making `joinabilityMode` a required argument of `effectiveJoinDoor` (turns the published no-argument call into a footgun that silently answers `invite_only`). The reader was only in the admission file because the gate was its first caller; the composer owns its inputs. **Decision 4:** the gate passes no `publishable` verdict, so `locked` still cannot fire from it — [ADR 0247](<redacted>.md) D4’s finding stands, and a gate reading the hub’s cached column would refuse every first sign-in on the platform. Also rejected: refusal copy that names the private state (the read-out D3 forbids, and a case-specific sentence makes the refusals distinguishable by another route); a `webAdmissionStatus` special case (it reads the same function, and its anti-drift sweep already pins the agreement). Proof: `tests/admission_ceiling_gate.mjs` boots a real instance on `private` + `open` through the production env channel, then sweeps all 75 knob combinations asserting the gate **is** the door and that exactly one combination — `public`/`full`/`open` — self-enrols.](<redacted>.md) | admission / project visibility / kernel layering |
|
|
343
344
|
| 0253 | [**The takedown lever has two writers, and provenance has to be a column** ([task 1002327](https://cloudbongos.com/builders#/task/1002327) · goal 1000046, criterion `proj-visibility-states` / C1, BV1.R08 — the build). Amends [ADR 0252](<redacted>.md) §4/§5: three premises that did not survive contact with the tree. The decisions stand; what changes is what has to be BUILT. **(1) `hidden` is sticky against TWO writers, and 0252 names the wrong one.** 0252 §5 says "no route writes [`status`]" and defends the takedown against the PULL. But `project-catalog.js:upsertOwnedProject` writes `status = EXCLUDED.status` on every conflict, sourced from `catalog-bridge.js:catalogStatusForInstance` — which returns `active` for any instance not torn down — and [ADR 0246](<redacted>.md) re-takes that projection at EVERY provisioning write. So the live failure is: curator hides a project → its owner saves any setting → it is back on the map. Stickiness therefore belongs to the ROW (`status_pinned`, set by the curated PATCH, honoured by the projection), not to one writer's manners; the pull separately writes no `status` at all, asserted against the statement text. `retireOwnedProject` deliberately does NOT honour the pin — it is a safety retraction for an origin the project no longer answers on ([ADR 0181](0181-teardown-state-gate-and-address-retention.md) §2), and narrowing is always free. **(2) Provenance must be a stored column.** 0252 §4 tabulates control-plane vs self-hosted rows as though the catalog knew which was which; it has no `hosting_shape` (that lives on `provisioning_instances`, in a module platform-identity may not import — [ADR 0083](<redacted>.md)) and `owner_github_id` separates nothing (set by the bridge, set by `registerClient`, null on hand-seeded rows). `visibility_source` (`control-plane`|`pull`|NULL) records it on the row, re-asserted in the UPDATE's own WHERE so a mid-sweep projection cannot lose the race — making "the two never write the same row" an invariant the SQL enforces. Rejected a `provisioning.instanceShape` port: a cross-module read in a background loop, for a value that never changes per row. **(3) The tri-state cannot live in `visibility`** (`NOT NULL DEFAULT 'public'`), so the recency stamp IS the null-carrier: `visibility_reported_at IS NULL` is the never-read state, and a failed read writes nothing — which is what makes "keep the last known state" fall out rather than be implemented. The fail-closed feed predicate needed a BACKFILL for [ADR 0247](<redacted>.md) D4's exact reason: both columns are new, so unbackfilled it would have emptied the public map on deploy. Migration 020 stamps every existing row `control-plane`, which is safe because it is TRUE — no instance has ever been asked. Two smaller corrections: the manifest key is **`brand`**, not `branding` (0252 §2 and the task body both misname it; reading only the documented spelling makes every pull a silent no-verdict), and the pull is a **POLLER, not a passenger on the feed's fan-out** — the feed excludes exactly the rows that need pulling (a never-answered row is dark matter, so it is never enriched, so it never answers), stealth rows are excluded from enrichment by design, and a read-path pull would make a DB write a side effect of an anonymous CORS-open read. Honest limit recorded: 0247 D4's `description: null` problem is NOT fixed here — 0252 §7 refuses to let the pull carry name/tagline/description, so the publish gate stays unmet for self-hosted rows. The pull moves the door; it does not publish the project.](<redacted>.md) | platform-identity / provisioning / abuse guard |
|
|
344
345
|
| 0255 | [**A public list's ordering is part of its payload, so an anonymous row must be ordered by nothing** ([task 1003561](https://cloudbongos.com/builders#/task/1003561) · goal 1000046, which absorbed [task 1003560](https://cloudbongos.com/builders#/task/1003560)). The public projects feed redacts a stealth row down to an allow-list of `state` + `orb` and carries **no stable per-project key on purpose** ([ADR 0182](<redacted>.md) D2a) — then sorted `ORDER BY featured DESC, updated_at DESC` and handed the observer one anyway. **Ordering is a covert column:** a position is a comparison result, and enough comparisons reconstruct the key. Three consequences, the third load-bearing — `updated_at` was published despite being deliberately withheld from the projection (it lies as a public "last updated"); a black hole sat BETWEEN NAMED NEIGHBOURS whose order is visible, bracketing its timestamp between two identifiable rows; and it **MOVED when its hidden project was touched**, which is an activity oracle on a public, CORS-open, unauthenticated route, and stable enough between touches to follow one black hole across snapshots. **Decision: a row may be ordered only by what it publishes.** An identified row publishes `created_at` + `origin`, so it sorts by those under `featured` (D3's remaining ranking job) — an order that is a PURE FUNCTION OF THE WIRE PAYLOAD, reproducible by a reader holding only the response. An anonymous row publishes nothing, so it is ordered by nothing about itself: the black holes collate into ONE TRAILING BLOCK permuted by a keyed hash. Four sort terms; the first partitions the blocks so no later term compares across them, and **terms 2-4 are GUARDED** — the "anonymous rows go last" half without the "and are blanked" half is not a fix, since the trailing block would simply re-sort by `created_at`, which narrows *which* project it is for anyone who watched the catalog fill. The identified set is DERIVED from `ORB_BY_STATE` (never re-typed, so SQL and `feedShapeFor()` cannot drift) and is an ALLOW-list, so an unknown fourth enum value sorts anonymous as well as drawing anonymous. The permutation salt is **secret** (origins are guessable domain names — a predictable salt lets an observer hash candidates and match ranks) and **rotates on a 24h epoch** (a permanently stable permutation still makes POSITION a durable handle); random per process, since nothing persists or reads it back. **Pagination is the same decision, not a neighbouring one** — task 1003560's premise was already stale (`<redacted> = 500` shipped with the feed; its own comment called the cap "the cheap half" and pagination "its own task"), and the surviving half edits this exact statement, so it merged in: `?limit=&offset=` per [ADR 0119](0119-api-pagination-contract.md), **no cursor** because the obvious encoding is the last row's timestamp — the very channel this closes — and the **default page IS the maximum (500)** so a static orbs site in ANOTHER REPO is byte-for-byte unchanged, with `page` added beside `projects` rather than wrapping it. Paging cannot aim more fan-out than one request already could: `?limit=` only shrinks, and `?offset=` is bounded by `project-stats.js`'s 10-min per-origin cache, not the request count ([ADR 0252](<redacted>.md) §5.3's per-IP limiter unchanged). Rejected: sorting everything by `created_at` (positions a black hole by a date its row refuses to carry); alphabetical `origin` order for the block (an oracle against a candidate set); `ORDER BY random()` (breaks paging — a row could appear twice or not at all); coarse `updated_at` buckets (a sort key that must be blurred should not be there). **Honest limits, stated rather than implied fixed:** existence and count stay public by design; **the identified set still CHANGES when a project goes stealth**, a LARGER channel than this one and untouched here; and position is stable within one epoch.](<redacted>.md) | platform-identity / privacy boundary / public API |
|
|
345
346
|
| 0254 | [**A relayed link is contained downward, or it is not published** ([task 1003670](https://cloudbongos.com/builders#/task/1003670) · goal 1000046, criterion `proj-visibility-states` / C1) — the gap [ADR 0252](<redacted>.md) §5 named and filed. **The defect:** `hall_origin` reaches the public projects feed from the REMOTE instance’s own discovery index (`GET <origin>/api/bongos` → `index.hall`, `project-stats.js`) and the hub renders it as an `<a href>` under that project’s name — checked only for **differing** from the project’s site host, which is containment turned inside out: it admits every host on the internet and refuses the one that is certainly safe. `{"hall": "https://evil.example/builders"}` was published on the hub’s own domain, attached to the victim. The rule answered a DIFFERENT question correctly ([task 1003257](https://cloudbongos.com/builders#/task/1003257), owner decision (a): *a door only where it opens somewhere else*); nobody had asked it the security one. It stopped being survivable when [ADR 0252](<redacted>.md) §3 replaced “whom the operator provisioned” with “whoever points a domain at the hub”. **Decision:** the same **downward-only** rule a pushed ship link already obeys — a relayed URL must point AT OR BELOW the origin that reported it (own host or a **dot-anchored** subdomain; `http(s)` only; no embedded credentials; **refused whole at the length cap, never truncated**, because slicing a URL changes where it points). Containment runs FIRST and compares **hostnames** (a port is not a trust boundary); the unchanged “different door” test runs second and compares **hosts with the port** (a hall on another port really does open elsewhere). Net: a hall link survives only as a strict subdomain — or another port — of the project’s own origin. **One home:** `shipUrlWithinClientOrigin` (a private helper in `platform-identity.js`) is lifted to `origin-containment.js:urlWithinOrigin` and required by BOTH callers — the defect WAS two surfaces answering one question differently, and leaving the answer where only one could reach it is what let them differ. Its own file for the reason its siblings give: `platform-identity.js` is AT the 1500-line ratchet (the lift shrinks it by 11), and deliberately NOT re-exported there — a re-export is a third name for one answer. **Downward only, and the cost stated:** the mirror rule looks symmetric and is not (on a shared apex it lets `foo.github.io` publish links on bare `github.io`, and vouch for every neighbour), so a project whose hall is a SIBLING of its site (site `www.example.com`, hall `builders.example.com`) loses the glyph — the hall stays reachable, the hub stops vouching for the address. [ADR 0253](<redacted>.md) §2’s asymmetry again: **narrowing is free; widening is what costs**. One guard added on the way in: a hostless origin (`file:`, `data:` — a stored value is not a validated one) is refused outright, because the old suffix test would have matched a legal trailing-dot FQDN like `evil.example.` against the bare `.` it leaves behind. **Rejected:** mirroring the check in the browser ([ADR 0182](<redacted>.md) D2a — the page is not the gate, and a second copy of a containment rule is the drift this ends); a per-project allow-list of hall hosts; copying the rule into `project-stats.js` (the cheapest diff, and it re-creates the cause); truncating at the cap (fine for an `<img src>` display bound, wrong for a containment decision); folding both tests into one hostname comparison (it would silently drop a legitimate same-host, different-port hall).](<redacted>.md) | platform-identity / public feed / link containment |
|
package/docs/file-map.md
CHANGED
|
@@ -245,7 +245,7 @@
|
|
|
245
245
|
│ │ ├── secret-box.js / sensitive-surfaces.js ← at-rest secret encryption + the sensitive-file classifier
|
|
246
246
|
│ │ ├── cascade-dispatch.js ← (dormant System 3, ADR 0079) the model-tier escalation ladder for autonomous dispatch
|
|
247
247
|
│ │ ├── goal-scope-check.js / hierarchy-config.js / module-scope-map.js ← goal-scope protected-path checks, the configurable work-hierarchy tiers, and the module→file-glob map
|
|
248
|
-
│ │ ├── project-door.js ← the project's COMPOSED join/apply door — visibility × join_grant × joinability → open | apply | invite_only | locked (BV1.R12 / task 1002331, ADR 0182 D6 + ADR
|
|
248
|
+
│ │ ├── project-door.js ← the project's COMPOSED join/apply door — visibility × join_grant × joinability → open | apply | invite_only | locked (BV1.R12 / task 1002331, ADR 0182 D6 + ADR 0247). PURE + kernel; published as api.projectJoinDoor() so the public POST /access-requests, the hall landing form, the CLI and the hub's join relay cannot answer one stranger differently — and since task 1003579 (ADR 0256) the SIGN-IN gate reads it too (openEnrollmentEnabled), which is why joinabilityMode() lives here rather than in auth-admission.js
|
|
249
249
|
│ │ ├── path-match.js / repo-info.js / metrics.js / uptime-poller.js / app-pair.js ← touches[] overlap matcher, git-remote parser, Prometheus metrics endpoint, uptime poller, Claude-Desktop pairing store
|
|
250
250
|
│ └── world/
|
|
251
251
|
│ ├── terrain.js ← (Phase D + ADR 0012, CJS): TERRAIN + OBJECT enums + BLOCKING_TERRAINS + BLOCKING_OBJECTS (server twin of public/game/world/terrain.js)
|
|
@@ -1585,5 +1585,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1585
1585
|
landed since 1.19.566 with no explicit bump. run 34057551680. (task 1002620)
|
|
1586
1586
|
1.19.568 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1587
1587
|
landed since 1.19.567 with no explicit bump. run 34058174866. (task 1002620)
|
|
1588
|
+
1.19.569 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1589
|
+
landed since 1.19.568 with no explicit bump. run 34058387853. (task 1002620)
|
|
1590
|
+
1.19.570 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1591
|
+
landed since 1.19.569 with no explicit bump. run 34060442118. (task 1002620)
|
|
1588
1592
|
---------------------------------------------------------------------------
|
|
1589
1593
|
```
|
|
@@ -415,6 +415,10 @@ module.exports = function buildCopyDeskRouter() {
|
|
|
415
415
|
const body = proposals.proposalTaskBody(patch);
|
|
416
416
|
const task = await lifecycle.createTask({
|
|
417
417
|
versionId: building.id,
|
|
418
|
+
// BV1.R11 (task 1003598): no goal to name until the per-version maintenance
|
|
419
|
+
// goal exists (task 1003605, R18). Declared out loud rather than relying on
|
|
420
|
+
// a silent default — see createTask.
|
|
421
|
+
allowCatchAll: true,
|
|
418
422
|
title: proposals.proposalTaskTitle(patch),
|
|
419
423
|
description: flagRef
|
|
420
424
|
? `${body}\n\nRaised from copy-desk flag ${flagRef}.`
|
|
@@ -242,7 +242,18 @@ function cascadeTaskSpec(rule, task) {
|
|
|
242
242
|
+ `the rule rather than only this task.`;
|
|
243
243
|
return {
|
|
244
244
|
versionId: task.version_id,
|
|
245
|
+
// A cascade INHERITS its parent's goal — the follow-up belongs wherever the
|
|
246
|
+
// work that triggered it belongs.
|
|
245
247
|
goalId: task.goal_id != null ? Number(task.goal_id) : null,
|
|
248
|
+
// BV1.R11 (task 1003598): …and when the parent has none, says so. A shipped
|
|
249
|
+
// task can still be goal-less — the 53 legacy catch-all rows, or any task
|
|
250
|
+
// whose version had no general goal to fall into — and under the new guard a
|
|
251
|
+
// null goalId THROWS. That throw is caught by the per-rule try/catch below,
|
|
252
|
+
// so the ship is never at risk, but the cascade task would vanish with only a
|
|
253
|
+
// console line: a silent goal-less gap, which is the exact thing this rule
|
|
254
|
+
// exists to close. Declared only when inheritance actually failed, so a
|
|
255
|
+
// cascade off a normal task still names a real goal.
|
|
256
|
+
allowCatchAll: task.goal_id == null,
|
|
246
257
|
title: String(rule.title(task) || '').trim(),
|
|
247
258
|
description: `${preamble}\n\n${body}`,
|
|
248
259
|
discipline: rule.discipline,
|
|
@@ -116,6 +116,13 @@ async function createTask({
|
|
|
116
116
|
// path with no builder in the request (a seed, a migration backfill) passes
|
|
117
117
|
// nothing and the row honestly reads unattributed, never guessed.
|
|
118
118
|
createdBy = null,
|
|
119
|
+
// BV1.R11 (task 1003598): the ESCAPE HATCH, and it is meant to be loud. A
|
|
120
|
+
// create with no goalId throws unless this is true. It exists for the two
|
|
121
|
+
// in-repo vectors that have no goal to name until the per-version
|
|
122
|
+
// maintenance goal lands (task 1003605, R18) — newcomer-restock and
|
|
123
|
+
// copy-desk. Grep for it before adding a third: the answer is almost always
|
|
124
|
+
// to name a goal instead.
|
|
125
|
+
allowCatchAll = false,
|
|
119
126
|
}, deps = {}) { // deps.client enlists this INSERT in the CALLER'S transaction (task 1003065, BV1.R01); the argument for it is at modules/ideas/inbox.js routeIdeaToGoal
|
|
120
127
|
// task 1498 / ADR 0085: every task carries a deliberate rank floor. Derive it
|
|
121
128
|
// from touches + sensitivity, then let an explicit requiresRank override only
|
|
@@ -140,15 +147,38 @@ async function createTask({
|
|
|
140
147
|
if (rewardMissing(creditsRewardEffective)) {
|
|
141
148
|
creditsRewardEffective = suggestCreditsReward({ est_minutes: estMinutes, kind });
|
|
142
149
|
}
|
|
143
|
-
// task
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
+
// BV1.R11 (task 1003598, goal 1000086, ADR 0250 D4): a goal-less create is no
|
|
151
|
+
// longer SILENT. Task 1763 defaulted any create without a goalId into its
|
|
152
|
+
// version's catch-all "<version> — general" goal, here at the lowest layer, so
|
|
153
|
+
// no caller had to think about it — and that is exactly how 53 open tasks
|
|
154
|
+
// accumulated in two catch-alls that nobody reads. Unscoped work did not sneak
|
|
155
|
+
// past the rule; the rule quietly filed it out of sight.
|
|
156
|
+
//
|
|
157
|
+
// Now a caller either NAMES a goal or says out loud that it cannot, by passing
|
|
158
|
+
// `allowCatchAll: true`. Same landing place, same never-break-a-creator
|
|
159
|
+
// promise — but the exceptions are a one-command grep instead of an absence,
|
|
160
|
+
// and a new create vector added next year gets a thrown error at its first
|
|
161
|
+
// test run rather than a silent home in a bucket.
|
|
162
|
+
//
|
|
163
|
+
// WHY NOT DELETE THE FALLBACK OUTRIGHT, which is what R11 literally asks: two
|
|
164
|
+
// in-repo callers have no goal to name yet. `newcomer-restock` stamps the
|
|
165
|
+
// beginner queue from templates and `copy-desk` files copy proposals; the
|
|
166
|
+
// honest home for both is the per-version MAINTENANCE goal, which is task
|
|
167
|
+
// 1003605 (R18) and still behind R06 and R12. Deleting the fallback today
|
|
168
|
+
// would stop both — the newcomer queue would drain — so the silent half is
|
|
169
|
+
// removed now and the true deletion belongs to R18, which is when the last two
|
|
170
|
+
// opt-ins can name a real goal. Owner's call, recorded here so the remaining
|
|
171
|
+
// gap is not mistaken for an oversight.
|
|
150
172
|
let goalIdEffective = goalId;
|
|
151
173
|
if (goalIdEffective == null) {
|
|
174
|
+
if (allowCatchAll !== true) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
'createTask: goalId is required — every task belongs to a goal (BV1.R11). ' +
|
|
177
|
+
'A vector that genuinely cannot name one yet must pass allowCatchAll: true, ' +
|
|
178
|
+
'which files into the version catch-all and is greppable until R18 gives it ' +
|
|
179
|
+
'a maintenance goal.'
|
|
180
|
+
);
|
|
181
|
+
}
|
|
152
182
|
goalIdEffective = await generalGoalIdForVersion(versionId);
|
|
153
183
|
}
|
|
154
184
|
const { rows } = await (deps.client || pool).query(
|
|
@@ -60,6 +60,11 @@ async function seedKickoffIfEmpty({ builderId = null } = {}, deps = {}) {
|
|
|
60
60
|
discipline: t.discipline || 'engineer',
|
|
61
61
|
source: 'first-sign-in',
|
|
62
62
|
createdBy: builderId,
|
|
63
|
+
// BV1.R11 (task 1003598): this runs on an instance with zero tasks and, in
|
|
64
|
+
// the scaffold's own seed order, zero goals — there is literally nothing to
|
|
65
|
+
// name yet. Declared rather than defaulted; the founder's first board is the
|
|
66
|
+
// one place a catch-all is the honest answer.
|
|
67
|
+
allowCatchAll: true,
|
|
63
68
|
});
|
|
64
69
|
seeded += 1;
|
|
65
70
|
}
|