@bongos/core 1.19.532 → 1.19.534

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.
Files changed (54) hide show
  1. package/.bongos-core.json +113 -48
  2. package/clients/bongos-client/index.d.ts +1 -1
  3. package/config/branding.neutral.json +3 -2
  4. package/docs/adr/0244-a-catalog-search-is-a-view-over-the-rows-never-an-edit-to-the-selection.md +116 -0
  5. package/docs/adr/0245-the-way-back-into-an-optional-step-is-that-step-s-own-answer-route.md +144 -0
  6. package/docs/adr/0246-catalog-membership-is-a-projection-not-a-federation-side-effect.md +96 -0
  7. package/docs/adr/0247-the-join-door-is-one-composed-answer-and-dark-matter-has-no-live-verdict-yet.md +202 -0
  8. package/docs/adr/README.md +5 -0
  9. package/docs/api/openapi.json +8 -1
  10. package/docs/api-reference.md +1 -1
  11. package/docs/copy-inventory.md +129 -124
  12. package/docs/copy-registry.json +205 -159
  13. package/docs/file-map.md +1 -0
  14. package/docs/module-api-changelog.md +16 -0
  15. package/modules/onboarding/module.json +1 -1
  16. package/modules/onboarding/routes/access-requests.js +21 -6
  17. package/modules/platform-identity/module.json +1 -1
  18. package/modules/platform-identity/project-catalog.js +202 -0
  19. package/modules/platform-identity/routes/my-projects.js +7 -2
  20. package/modules/platform-identity/routes/projects.js +6 -0
  21. package/modules/platform-identity/routes/sso.js +10 -0
  22. package/modules/provisioning/catalog-bridge.js +105 -0
  23. package/modules/provisioning/provisioning.js +28 -2
  24. package/modules/provisioning/routes/provisioning.js +45 -11
  25. package/modules/provisioning/tests/provisioning.mjs +11 -8
  26. package/modules/public-landing/public/assets/cosmos.css +15 -0
  27. package/modules/public-landing/public/projects.html +221 -2
  28. package/modules/public-landing/public/projects.states.json +2 -1
  29. package/modules/ui-design/kit/serve.js +1 -0
  30. package/package-lock.json +2 -2
  31. package/package.json +1 -1
  32. package/scripts/gds/fitness.js +9 -0
  33. package/scripts/gds/provision-net.js +7 -0
  34. package/src/bongos/project-door.js +138 -0
  35. package/src/branding.js +8 -0
  36. package/src/module-api.js +12 -1
  37. package/tests/access_requests_invite.mjs +2 -2
  38. package/tests/application_lifecycle.mjs +1 -0
  39. package/tests/catalog_only_client.mjs +242 -0
  40. package/tests/currency_label.mjs +8 -5
  41. package/tests/module_api.mjs +1 -0
  42. package/tests/project_catalog_bridge.mjs +355 -0
  43. package/tests/project_catalog_routes.mjs +213 -0
  44. package/tests/project_join_door.mjs +166 -0
  45. package/tests/project_join_door_route.mjs +206 -0
  46. package/tests/project_modules_ui.mjs +318 -0
  47. package/tests/projects_hub.mjs +28 -1
  48. package/tests/projects_hub_module_picker.mjs +207 -0
  49. package/tests/projects_hub_pre_uat.mjs +2 -2
  50. package/tests/provision_settings_apply.mjs +6 -5
  51. package/tests/provisioning_module_selection.mjs +164 -3
  52. package/tests/provisioning_settings.mjs +10 -7
  53. package/tests/provisioning_settings_apply.mjs +3 -3
  54. package/tests/provisioning_settings_env.mjs +7 -4
@@ -0,0 +1,144 @@
1
+ # 0245 — The way back into an optional step is that step's own answer route
2
+
3
+ - **Status:** Accepted
4
+ - **Date:** 2026-09-04
5
+ - **Tasks:** [#1002342](https://cloudbongos.com/builders#/task/1002342) (this decision — BV1.R23)
6
+ - **Goal:** [#1000046](https://cloudbongos.com/builders#/goal/1000046) — Project creation, criterion
7
+ `proj-module-select` ("during setup a default set of modules is pre-selected…")
8
+ - **Related:** [ADR 0240](<redacted>.md)
9
+ (R20 — the nullable `modules` column, the three-valued contract, and §6's "R23 … needs to know whether
10
+ an owner has ever been asked"), [ADR 0237](<redacted>.md)
11
+ (R19 — the bundles and the `OPTIONAL_MODULES` catalog this surface renders),
12
+ [ADR 0243](<redacted>.md) (R22 — the
13
+ team-shape adjustment inside the fallback, and why `PATCH …/detail` already answered with `modules`),
14
+ [ADR 0238](<redacted>.md) (R05 — the manage view, and its
15
+ finding that "the project's own hall settings" is a page that does not exist),
16
+ [ADR 0111](<redacted>.md) (the enqueue-only web tier)
17
+
18
+ ## 1. The problem
19
+
20
+ R20 shipped a module picker as step 4 of the create wizard and made it skippable.
21
+ The route file said out loud what that left behind:
22
+
23
+ > …there is no mutation route yet (R23, task 1002342 is "add modules later"), so
24
+ > the receipt says the answer was kept rather than naming a way back that does
25
+ > not exist.
26
+
27
+ So a project's module set was **write-once at creation**. An owner who skipped the
28
+ picker, or picked wrong, had no way to change it — which is the same dead end
29
+ `PATCH /provisioning/instances/:id/detail` was built to close for the About step
30
+ one panel earlier. An optional step is a supported default only while there is a
31
+ way back; without one, "optional" quietly means "permanent".
32
+
33
+ Two questions had to be answered, and the second follows from the first.
34
+
35
+ ## 2. Not a new route — the same route the step beside it uses
36
+
37
+ The obvious move is a new `PATCH /provisioning/instances/:id/modules`. It is the
38
+ wrong one, and the reason is that the wizard's two optional steps are the same
39
+ kind of thing:
40
+
41
+ | | About (step 3) | Modules (step 4) |
42
+ |---|---|---|
43
+ | asked | optionally, at creation | optionally, at creation |
44
+ | stored | `description` + `detail` on the platform row | `modules` on the platform row |
45
+ | reaches the running instance | no | no |
46
+ | way back | `PATCH …/detail` | **this** |
47
+
48
+ `PATCH …/detail` is already *"answer (or re-answer) the wizard's optional creation
49
+ steps after creation"* — the name is narrower than the job. It already carries the
50
+ ownership gate (`instanceAccessDenied`: own-scoped, Archon override, 404 with no
51
+ existence leak), already records an event with an `api:self` / `api:archon:<id>`
52
+ actor, and — since [ADR 0243](<redacted>.md)
53
+ — **already answers with `effectiveModules(updated)`**, because a `team_shape`
54
+ answer moves a bundle-sourced project's modules. The route was already reporting
55
+ this value; all that was missing was the ability to set it.
56
+
57
+ A second route would have duplicated the gate, the actor rule, the 404 shape and
58
+ the receipt, for one column on a row the first route already writes. Duplicated
59
+ authorization is the kind that drifts.
60
+
61
+ **Not the settings channel**, for [ADR 0240](<redacted>.md)
62
+ §3's reason, restated: a setting is *a policy the running instance obeys* — it
63
+ rides `web.env`, queues a `settings-apply` intent and restarts the service. A
64
+ module selection is a declaration held on the platform row until a catalog row
65
+ materializes. Nothing is pushed and nothing restarts, and the surface says so
66
+ rather than borrowing its neighbours' "your project will restart" receipt.
67
+
68
+ ## 3. The skip state is not writable
69
+
70
+ `modules` is three-valued (ADR 0240 §2): `NULL` = never asked, `[]` = declined
71
+ everything, `[…]` = chosen. A later edit must be able to reach the second and
72
+ third, and **must not be able to reach the first**.
73
+
74
+ So only an **array** is a change. An omitted key, or an explicit `null`, leaves the
75
+ column alone; a body carrying nothing but a null `modules` is still the existing
76
+ `bad_detail` "nothing to change". That is a deliberate asymmetry rather than an
77
+ oversight: `NULL` is a claim about the owner's *history* — that they were never
78
+ asked — and a write that could re-assert it would let an answered project become
79
+ indistinguishable from an unasked one, permanently, which is the exact collapse
80
+ ADR 0240 refused `NOT NULL DEFAULT '[]'` to prevent.
81
+
82
+ `modules` also **replaces** where `detail` merges. The `detail = detail || $n::jsonb`
83
+ precedent exists because a form may send one interview answer without resending
84
+ the rest; a module selection has no keys to merge, and a merging write would make
85
+ *removal* impossible — the half of "add modules later" an owner needs most.
86
+
87
+ The always-on core stays unremovable by construction, not by a guard bolted on
88
+ here: `badModuleSelection` refuses a core key by name (`"memory" is always on for
89
+ every project — it is not one of the modules to choose`), `normalizeModuleSelection`
90
+ filters to the opt-in catalog on the way to storage, and `effectiveModules` prepends
91
+ the whole core on the way out. Three independent places, none of which this route
92
+ had to add.
93
+
94
+ ## 4. The surface is the manage page, because the hall page is still gone
95
+
96
+ ADR 0182 D5 pointed R05 at "the project's own hall settings"; ADR 0238 found that
97
+ page does not exist — `settings.html` is per-*builder*, and the hall's my-projects
98
+ page was deleted by task 1003308. Nothing has changed since, so the extras band
99
+ lands where its three sibling settings already live: `/projects?view=manage&id=N`.
100
+
101
+ Two reads, one of them new. The **current set** rides in on the instance itself —
102
+ `publicInstance` resolves `effectiveModules(row)`, so a skipped picker reads as its
103
+ own starter bundle and this page never re-derives that rule, which is the one way
104
+ two surfaces come to name different modules for one project. The **catalog** is the
105
+ same public `GET /provisioning/starter-bundles` the wizard's panel reads.
106
+
107
+ **A toggle is the save.** Every sibling section on that page has a Save button
108
+ because each is one choice with one value; the extras band is a list of independent
109
+ facts, where a Save button only adds a way to lose one. The write sends the whole
110
+ list in catalog order (what the column stores), and the band repaints from the
111
+ platform's answer rather than from what was sent — so it can never show a set the
112
+ server did not accept, and a refusal puts it back to what the row still holds.
113
+
114
+ ## 5. Consequences
115
+
116
+ - **`modules_ignored` now names the way back.** A create re-request that carries a
117
+ changed `modules` still refuses to apply it, but its receipt points at this route
118
+ — the `detail_ignored` shape, for the `detail_ignored` reason.
119
+ - **The wizard's own `?view=manage` links carry their id now.** The done panel's
120
+ in-page router matched `view=(\w+)` and dropped the query, so every
121
+ `projectPageLink` on that panel landed on a manage page with no project. Fixed in
122
+ passing because criterion B's pointer would have been the fifth link to do it.
123
+ - **Still not an input to the runner** (ADR 0240 §6). Changing the set changes a
124
+ declaration on the platform row; turning it into the child instance's own
125
+ `config/modules.json` remains separate work. The surface's copy says so — "nothing
126
+ restarts" is true today and would be a lie the moment that work lands, so it is
127
+ worth re-reading then.
128
+ - **A module selection is still not a publish precondition** (ADR 0182 D4).
129
+
130
+ ## 6. Rejected
131
+
132
+ - **A new `PATCH …/modules` route** — a second copy of one ownership gate, one actor
133
+ rule and one 404 shape, for a column the existing route's own UPDATE already
134
+ reaches.
135
+ - **The settings channel** — would queue a `settings-apply` and restart a running
136
+ instance to deliver a value the instance does not read (ADR 0240 §3).
137
+ - **Letting `modules: null` clear the column** — reachable, coherent-looking, and it
138
+ destroys the only fact that says whether an owner was ever asked.
139
+ - **Merging the list like `detail`** — makes removing a module impossible.
140
+ - **A Save button under the band** — one more state to hold, and its failure mode is
141
+ an owner who toggles, leaves, and loses the change.
142
+ - **Rendering the always-on core as disabled rows** — ADR 0240's rejection, and it
143
+ holds here for the same reason: a disabled control reads as a choice the owner
144
+ failed to make.
@@ -0,0 +1,96 @@
1
+ # ADR 0246 — Catalog membership is a projection of the provisioning row, not a side effect of federation
2
+
3
+ - **Status:** Accepted
4
+ - **Date:** 2026-09-04
5
+ - **Task:** [task 1003576](https://cloudbongos.com/builders#/task/1003576) (BONGOS-V1, goal 1000046 — *Project creation*, criterion `proj-detail-interview` / C3)
6
+ - **Deciders:** jaxri (Archon), Claude
7
+ - **Related:** [ADR 0182](<redacted>.md) (the two axes; D4 is the publish gate, D5 makes the hub column a cache) · [ADR 0238](<redacted>.md) (the join door is a provisioning setting; the hub's owner column is attribution, never authority) · [ADR 0141](<redacted>.md) §2/§4 (the SSO client registry and what a client secret is for) · [ADR 0083](<redacted>.md) (the module one-way rule — why this travels on a port) · [ADR 0181](0181-teardown-state-gate-and-address-retention.md) §2 (an abandoned address left pointing at platform infra)
8
+
9
+ ---
10
+
11
+ ## Context
12
+
13
+ Criterion C3 promises that skipping the "tell us about your project" step "leaves the project dark-matter until identity is added, **then it publishes**." The BV1.R18 proof ([task 1002337](https://cloudbongos.com/builders#/task/1002337)) demonstrated the whole criterion against live and found the last clause does not happen. Its §3 evidence, on core 1.19.524:
14
+
15
+ - `PATCH /provisioning/instances/18/detail` with a description answered `publish: { publishable: true, missing: [] }`, settings `visibility: "public"`, instance `status: active`.
16
+ - `GET /projects/featured` answered `{ "projects": [] }`, and no route existed that could change that.
17
+
18
+ The two answers come from two different tables and nothing bridged them:
19
+
20
+ | Question | Read from |
21
+ |---|---|
22
+ | "am I on the map?" — the owner's verdict | `provisioning_instances` (slug + description), through the `project.publishGate` port |
23
+ | "who is on the map?" — the public feed | `platform_identity_projects` (`status = 'active' AND publishable`) |
24
+
25
+ The **only** automatic write from the first into the second was `federateInstance → registerClient → upsertProject` in `scripts/gds/provision-net.js`. It has two properties that together are the bug:
26
+
27
+ 1. **`shouldFederate` refuses any instance whose `hosting_shape` is not `co-tenant`** — and the creation wizard hard-codes `standalone`. So every project the wizard makes was never catalogued at all.
28
+ 2. **It runs ONCE, at provision time,** off `inst.description`, and `upsertProject` COALESCEs `description`. So a project that answered *later* re-federated never, and the catalog kept its NULL.
29
+
30
+ The same missing row is [task 1003580](https://cloudbongos.com/builders#/task/1003580)'s GAP 1 seen from the invite door: `POST /projects/invite` resolves its target with `getClient()` against `platform_identity_sso_clients`, whose only writer is that same `registerClient` — so the invite could only ever 404 for a wizard-made project, which blocks R27 ([task 1002346](https://cloudbongos.com/builders#/task/1002346)), R28, R29 and the C6 proof.
31
+
32
+ Underneath both is one conflation: **federation mints CREDENTIALS and it was also, incidentally, the only thing that minted MEMBERSHIP.** A secret and a redirect-URI allow-list exist so an instance can run the sign-in handoff. Being nameable on the map is a different question, and ADR 0182 D4 already answered it — the gate is IDENTITY (a name and a description), which has nothing to do with where a project is hosted.
33
+
34
+ ## Decision
35
+
36
+ **The catalog row is a PROJECTION of the provisioning row, re-derived at every write that can change its inputs.** Two halves, and neither works alone:
37
+
38
+ 1. **THE ROW SHAPE — a catalog-only client.** Every control-plane project gets a `platform_identity_sso_clients` row *and* a `platform_identity_projects` row, whatever its hosting shape, so identity, membership and the map can name it. The client row carries **no credential**: `secret_hash = ''` and `redirect_uris = '{}'`.
39
+ 2. **THE TIMING — the publish moment, every time.** The projection is re-taken on create, on `PATCH …/detail`, on `PATCH …/settings` when the door changes, and on `POST …/domain` (which changes the origin the row is *keyed* on). A once-at-provision write is exactly what R18 caught; nothing that writes only at standup can be correct.
40
+
41
+ Shape (1) alone leaves the catalog stale the moment the owner edits anything; timing (2) alone gives the map a row but leaves `POST /projects/invite` with nothing to resolve. The decision is both.
42
+
43
+ Mechanically: `modules/platform-identity/project-catalog.js` owns the two writes and publishes them as the kernel port **`project.catalog`** (the `project.publishGate` precedent — provisioning owns these projects and may not import that module); `modules/provisioning/catalog-bridge.js` owns the projection and calls the port. **No migration** — `secret_hash` is `NOT NULL`, and `''` satisfies `NOT NULL` while hashing to nothing.
44
+
45
+ ### A catalog-only client can do nothing but be named
46
+
47
+ The row shape *is* the enforcement, rather than a flag a future caller could forget:
48
+
49
+ | SSO leg | Refuses because |
50
+ |---|---|
51
+ | `GET /sso/authorize` | `redirectUriAllowed` — an empty allow-list fails every exact match, so no code is minted and no redirect happens |
52
+ | `POST /sso/token` | `clientSecretValid` — it hashes the presented secret and constant-time-compares against a decoy, then fails closed because there was no real secret |
53
+ | `POST /sso/membership/check-in` | the same `clientSecretValid` |
54
+ | `POST /sso/activity/rollup` | the same `clientSecretValid` |
55
+ | `POST /sso/device/start` | **new guard.** This is the one leg that checked neither: it resolves a client from a user-typed ORIGIN and looked only at `status`. `clientIsCredentialed(client)` is now asked explicitly |
56
+
57
+ Four of those five were already true before this task, which is exactly why `tests/catalog_only_client.mjs` asserts them rather than assuming them: they are load-bearing for a decision made after they were written. The one that was not is the one that grew four lines of code.
58
+
59
+ ### It grants NOTHING — two claim rules keep it that way
60
+
61
+ The catalog row is **attribution**, per ADR 0238: ownership stays `provisioning_instances.owner_builder_id`, which `instanceAccessDenied` already enforces on every route that triggers the projection. But the projection writes rows keyed on values an owner supplies — a slug and a bring-your-own domain — and neither hub table proves who owns an origin. So each write carries its claim rule in the `ON CONFLICT` action's own `WHERE`, one statement, no read-then-write window:
62
+
63
+ - **The client** is refreshed only `WHERE secret_hash = ''`. A slug colliding with a real federated client can therefore neither have its `origin` rewritten (that origin is what binds the CLI device-mint audience — the [task 1002108](https://cloudbongos.com/builders#/task/1002108) fix) nor its secret blanked. It is simply not claimed.
64
+ - **The project** is refreshed only `WHERE owner_github_id = EXCLUDED.owner_github_id`. `NULL = anything` is NULL, i.e. not true, so the hand-seeded catalog rows (owner `NULL`) cannot be claimed by someone creating an instance at their address. First INSERT wins and stamps the owner; every later write must match it. An instance whose owner has no GitHub id is refused outright rather than written un-refreshable.
65
+
66
+ ### What is authoritative, and what the hub still does not decide
67
+
68
+ `description` and `visibility` are **assigned**, not COALESCEd. Clearing a description must take the project off the map, and the door must be able to change — the COALESCE is precisely why the catalog kept a NULL while the owner read `publishable: true`. `featured`, `tagline`, `art_url` and `template` are curation and are never touched here, so ADR 0182 D3 still holds: the bridge cannot feature anything.
69
+
70
+ Writing `visibility` looks like it contradicts ADR 0182 D5 ("the hub row is a CACHE; the hub never AUTHORIZES a door change — it accepts a report from the authenticated instance"). It does not, and the distinction is worth stating because the next reader will trip on it. **The hub is not authorizing anything: the authority already ran, on the provisioning plane.** ADR 0238 put the door on `PATCH /provisioning/instances/:id/settings`, own-scoped, vocabulary-validated, audited, and pushed to the instance through `web.env`. For a project the control plane owns, that row *is* upstream of the instance — the projection carries a decision the instance has not even received yet. A project the control plane does **not** own has no `provisioning_instances` row, the bridge never runs for it, and D5's instance report (R08, [task 1002327](https://cloudbongos.com/builders#/task/1002327)) remains the only channel. The two never write the same row.
71
+
72
+ ### Lifecycle, and what is deliberately left out
73
+
74
+ `status` is projected: `tearing_down` / `torn_down` → `'hidden'`, everything else → `'active'`. `requested`, `provisioning` and `error` are all "the owner is standing this up"; the map is a directory of projects, not an uptime board (an unreachable `active` instance already stays listed — liveness is an overlay, never a state). An instance with **no address** (`no_address: true`) is not catalogued at all: the catalog is keyed on `origin`, and opting out of a public address means opting out of the public map.
75
+
76
+ Federation is left exactly as it is. `shouldFederate` still gates the SSO credential on `co-tenant`, because that is the shape whose `web.env` the platform writes — and that is now the *only* thing it decides.
77
+
78
+ ## Consequences
79
+
80
+ - **C3's last clause is true.** A wizard-made standalone project that answers its description appears on `GET /projects/featured` in its door's orb — planet / star / black hole per ADR 0182 D1, stealth anonymised server-side by D2a, dark matter excluded in SQL. The C1 proof (R10, [task 1002329](https://cloudbongos.com/builders#/task/1002329)) and the C2 proof (R14) now have a wizard-created project that can be on the map.
81
+ - **[task 1003580](https://cloudbongos.com/builders#/task/1003580) shrinks to its GAP 2.** `POST /projects/invite` resolves a wizard-made project instead of 404ing. What remains there is purely the ownership branch: the route's `project.curate` floor is `metic` and has no own-scope arm, so the typical xenos owner still gets a 403. This ADR deliberately does not touch it — widening a permission is its own decision, and ADR 0238's "the hub's owner column is not an authority" is the constraint it has to answer to (the ownership proof it should carry is `provisioning_instances.owner_builder_id`, the authority the manage view already trusts).
82
+ - **Many more `sso_clients` rows exist than before** — one per control-plane project rather than one per co-tenant. Every one of them is uncredentialed, and `tests/catalog_only_client.mjs` is the standing proof; a future SSO leg that resolves a client must ask `clientIsCredentialed` if it does not already check a secret or a redirect URI.
83
+ - **A re-pointed address retires its old catalog row** (`status = 'hidden'`, owner-scoped). Without it a project that corrected its address would leave an orb on the map pointing at a hostname it no longer answers on — ADR 0181 §2's leak, in the catalog instead of in DNS.
84
+ - **The projection is best-effort and runs after the durable write.** A hub hiccup must never turn a saved description into a 500; the bridge swallows its own errors and each call site carries `.catch(() => {})` as the second belt.
85
+ - **The three existing catalogued rows are untouched by this change.** All three are `active, featured = true, description = null`, so the map has been empty since R06 ([task 1002325](https://cloudbongos.com/builders#/task/1002325)) swapped the predicate from `featured` to `publishable`. They are hand-seeded, carry `owner_github_id NULL`, and have no provisioning row, so no code path here reaches them — by design, since that NULL owner is what makes them unclaimable. Putting them back on the map is a **one-off data decision for the owner, not a code change**: for each, decide whether it is still a real project, then either `UPDATE platform_identity_projects SET description = '…', owner_github_id = <the owner's github id> WHERE origin = '…'` under a claimed task, or `status = 'hidden'` to retire it. Nothing in this ADR backfills them, and nothing should: a description invented by the platform is exactly the husk the publish gate exists to keep off the map.
86
+ - **Teardown does not currently re-take the projection.** The `hidden` mapping is in the projection, so it applies whenever the bridge next runs — but the teardown route itself is not a call site. A torn-down project therefore stays listed until something else writes. That is today's behaviour unchanged (nothing ever un-catalogued a project), it is a small follow-up, and it is named here so the next reader does not read the mapping and assume it fires.
87
+
88
+ ## Alternatives rejected
89
+
90
+ - **Extend `shouldFederate` to every hosting shape** (the R18 proof's second suggestion). Rejected: it fixes the wrong half. It would hand a real client secret and a redirect allow-list to instances whose `web.env` the platform does not write — a credential nobody can use, on the trust-boundary-sensitive half of the row — and it would still run once, at provision time, so the stale-description bug survives untouched.
91
+ - **Bridge ONLY at the publish moment, with no client row** (candidate B as scouted). Rejected: it puts the project on the map and leaves `POST /projects/invite` with nothing to resolve, so 1003580 stays blocked and R27–R29 stay behind it. The map and the invite are the same missing membership.
92
+ - **Register the catalog row ONLY at standup** (candidate A as scouted). Rejected by the R18 evidence itself: a once-at-provision write is the bug. It would fix "standalone is never catalogued" and leave "answered later never reaches the map" exactly where it was.
93
+ - **A `catalog_only` boolean column on `sso_clients`.** Rejected: it needs a migration this task is not flagged for, and a flag is weaker than the shape — a row with a real secret and `catalog_only = true` is representable and would be a live credential one forgotten check away from working. `secret_hash = ''` cannot be that.
94
+ - **Let the bridge write `featured`.** Rejected: ADR 0182 D3 — featuring may rank among projects already on the map, never put one there.
95
+ - **Backfill the three existing catalog rows from code.** Rejected: it invents a description nobody wrote, on rows whose real owner the platform cannot identify. See the consequence above — it is an owner decision, executed once, under a claim.
96
+ - **Have provisioning write `platform_identity_projects` directly.** Rejected by the one-way rule (ADR 0083): a module may not import a sibling. The port is the same seam `project.publishGate` already travels on, in the other direction.
@@ -0,0 +1,202 @@
1
+ # ADR 0247 — The join door is ONE composed answer, and dark matter has no live verdict to compose with yet
2
+
3
+ - **Status:** accepted
4
+ - **Date:** 2026-09-04
5
+ - **Task:** [task 1002331](https://cloudbongos.com/builders#/task/1002331) (BONGOS-V1, goal 1000046 — *Project creation*, criterion `proj-join-policy`)
6
+ - **Deciders:** Claude, under the Archon's standing scope
7
+ - **Implements:** [ADR 0182](<redacted>.md) D6 (*"visibility sets the ceiling; join policy may only narrow it"*) — which stated the rule and left it unimplemented
8
+ - **Related:** [ADR 0194](<redacted>.md) (`joinabilityMode`, the one-reader precedent this extends) · [ADR 0238](<redacted>.md) (the settings channel the new knob rides) · [ADR 0116](<redacted>.md) (the refusal envelope) · [ADR 0083](<redacted>.md) (the doorway)
9
+
10
+ ---
11
+
12
+ ## Context
13
+
14
+ ADR 0182 D6 gave the project plane a table — public self-joins, private queues an
15
+ application, stealth refuses at the door, dark matter has no door — and one composition
16
+ rule: *the stricter of the two always wins, and neither can widen the other*. R11
17
+ (task 1002330) and R13 (task 1002332) then built the queue and its review UI. Neither
18
+ built the door: R13's own handoff says it plainly, *"R13 renders and decides, it enforces
19
+ nothing."*
20
+
21
+ So at claim time the public `POST /access-requests` — the one unauthenticated write, the
22
+ endpoint the hall's landing form, the `bongos login` CLI **and** the hub's join relay all
23
+ funnel into — read exactly one knob:
24
+
25
+ ```js
26
+ const refusal = DOOR_REFUSAL[api.joinabilityMode()];
27
+ ```
28
+
29
+ An owner could set their project `private` or `stealth` on the platform's map (task
30
+ 1002324 shipped that control) and the application endpoint would behave exactly as
31
+ before. The policy was stored, validated, audited, pushed into the instance's environment
32
+ and read back in the manifest — and nothing consulted it.
33
+
34
+ R12 is that door. It also adds the third knob D6 anticipated: **what a public join
35
+ grants** — a look, a request, or membership.
36
+
37
+ ## Decision 1 — The grant is one line of settings vocabulary, not a new route
38
+
39
+ `join_grant ∈ (view, apply, full)` joins `provisioning.SETTINGS_VOCAB` +
40
+ `DEFAULT_SETTINGS`, exactly as `visibility` did in task 1002324. This is not laziness
41
+ about the "owner mutation route" the task asked for — in this module the vocabulary **is**
42
+ the route. One line buys: the own-scoped `PATCH /provisioning/instances/:id/settings`
43
+ with its ownership wall (`instance_not_found` 404, so a wrong owner learns nothing an id
44
+ existed), the Archon override with `api:archon:<id>` on the audit row, server-side enum
45
+ validation (400 on an off-vocabulary value), the `<PREFIX>_JOIN_GRANT` line the runner
46
+ patches into `web.env`, the restart that applies it, and the `GET …/settings` options a
47
+ form renders from. A bespoke route would have re-implemented every one of those and could
48
+ drift from all of them.
49
+
50
+ It rides `src/branding.js` onto `project.joinGrant` and into the public manifest, like its
51
+ three neighbours — the read-back that lets a client say what a door does before knocking.
52
+
53
+ **The default is `full`, and that is load-bearing.** `DEFAULT_SETTINGS` must be *today's
54
+ behaviour*, and today a public project runs whatever door its `joinability` runs. `full`
55
+ is the value that narrows nothing, so **every existing project — all of them `public` —
56
+ is byte-identical after this lands**. `apply` as the default would have quietly turned
57
+ every open-enrolment project into an application queue.
58
+
59
+ ## Decision 2 — One composed answer, in one pure file
60
+
61
+ `src/bongos/project-door.js` — `projectJoinDoor({ visibility, joinGrant, joinability,
62
+ publishable })` → `'open' | 'apply' | 'invite_only' | 'locked'`.
63
+
64
+ The door vocabulary is a **list ordered widest → strictest**, and that order *is* D6's
65
+ composition rule: the two knobs never negotiate, the higher index simply wins.
66
+
67
+ | stored state | ceiling | why |
68
+ |---|---|---|
69
+ | `public` + grant `full` | `open` | the grant narrows nothing |
70
+ | `public` + grant `apply` | `apply` | a self-join becomes a request |
71
+ | `public` + grant `view` | `invite_only` | on the map to be looked at; joining is by invite |
72
+ | `private` | `apply` | visible-but-closed: the application queues for the owner |
73
+ | `stealth` | `invite_only` | refused at the door; nothing is stored |
74
+ | not publishable | `locked` | dark matter — there is no door to knock on |
75
+
76
+ then `door = stricter(ceiling, joinability)`. D6's own two examples fall out: a `closed`
77
+ public project accepts no self-joins; an `open` stealth project still refuses everything.
78
+
79
+ **The grant governs `public` alone.** `private` and `stealth` set their own ceilings and
80
+ never consult it — "what a **public** join grants" is the question the owner was asked, and
81
+ letting a `full` grant reach a private project would be the widening D6 forbids.
82
+
83
+ It is published through the doorway as `api.projectJoinDoor()`, beside `joinabilityMode`
84
+ and for the same SR-19 reason: a module that read one knob would re-open a door the other
85
+ two had closed.
86
+
87
+ ## Decision 3 — The refusal never names which knob closed the door
88
+
89
+ `stealth`, `public`+`view`, and an explicit `invite_only` all answer with the **same**
90
+ sentence and the same code. This is the reason the vocabulary has four values and not six.
91
+
92
+ ADR 0182 D2a makes stealth's anonymity a real privacy primitive — R06 redacts a black
93
+ hole's name, tagline, art and origin server-side, and gives it no stable per-project key
94
+ precisely so yesterday's feed cannot be diffed against today's to name the project that
95
+ went dark. A door that answered *"this project is stealth"* would hand that back for the
96
+ price of one POST. So the door's answer is a **behaviour**, never a state read-out, and
97
+ `tests/project_join_door_route.mjs` asserts the three refusals are indistinguishable —
98
+ byte for byte, including the query count.
99
+
100
+ The refusal is also answered **before** the body is validated and before any lookup, so it
101
+ costs no DB work, reveals nothing about who already exists, and — per D6 — stores nothing:
102
+ a closed project accumulates no queue to leak.
103
+
104
+ **Fail closed, but honestly.** An unreadable pack, a truncated env write, or a fourth enum
105
+ value a later release adds resolves to `invite_only` — never a wider door (ADR 0194 D2's
106
+ rule, and the direction R06 took for the feed). It does **not** resolve to `locked`:
107
+ `locked` is a specific *claim* — "this project is not published" — and an unknown policy
108
+ is no evidence for it. Refusing honestly beats refusing hardest.
109
+
110
+ ## Decision 4 — `locked` is specified and tested, and deliberately NOT wired, because neither plane holds a trustworthy publish verdict today
111
+
112
+ This is the finding of the session, and it is written down rather than papered over.
113
+
114
+ Dark matter is **derived**, never stored (ADR 0182 D1/D4): a project with no `name` or no
115
+ `description` is off the map. To lock the door on it, the door needs that verdict. Both
116
+ candidate sources were checked and **both are wrong today**:
117
+
118
+ 1. **The instance cannot derive it.** The publish gate is `name` + `description`; the
119
+ instance's branding pack carries neither. `description` is never pushed — `settingsEnvVars`
120
+ emits `SETTINGS_VOCAB` keys only, and D4 explicitly forbids repurposing `copy.tagline`
121
+ ("editing your card would unpublish your project"). Pushing a *derived* `publishable`
122
+ was rejected on staleness: publishability changes when the owner edits their
123
+ description, which fires no settings push, so a project would sit `locked` after the
124
+ owner had already fixed it — fail-closed in the one direction with no cure.
125
+
126
+ 2. **The hub's copy says every live project is dark matter.** `platform_identity_projects`
127
+ is a **cache** (D5) and the report that fills it is R08 (task 1002327), unshipped. The
128
+ live catalog was read while writing this ADR: **every** row — including the two featured
129
+ ones — has `description: null`, so `publishable: false` on all of them. Enforcing
130
+ `locked` at the hub's join relay off that column would have refused **every join on the
131
+ platform**, immediately.
132
+
133
+ So `publishable` is a deliberate **tri-state**: `false` locks, `true` opens nothing on its
134
+ own, and `null`/undefined means **no verdict** — the same word `provisioning.publishVerdict`
135
+ already uses when the `project.publishGate` port is absent. The instance door passes no
136
+ verdict and is answered on the three stored knobs alone. The full four-state matrix is
137
+ implemented and tested at both the pure and route layers, so wiring the verdict later is
138
+ one argument, not a redesign.
139
+
140
+ What it needs is a verdict that is **live and correct on the plane the door runs on** —
141
+ `provisioning_instances.description` is exactly that (the platform holds it, and
142
+ `publishVerdict()` already reads it through the kernel port), reachable as a provisioning
143
+ seam the way `provisioning.projectPlanets` already is. That is filed, not smuggled in here.
144
+
145
+ ## Decision 5 — The hub relay learns the new code, so it cannot lie about someone else's door
146
+
147
+ `joinRelayOutcome` maps a project's answer into the hub's own words, and anything it does
148
+ not recognise becomes a **502 "the project could not be reached"** — the precise lie ADR
149
+ 0194 D5 forbids. `not_published` is therefore added to its map and to `RELAY_SENTENCE`
150
+ now, with the code, rather than when the verdict is wired.
151
+
152
+ ## What was deliberately not built
153
+
154
+ - **No manage-page control for the grant.** The setting is API-complete and the vocabulary
155
+ drives `GET …/settings` `options`, so the section is a rendering task; R14 (task
156
+ 1002333) proves the behaviour through the API. Adding a fourth fold to a page whose
157
+ three existing ones already answer three confusable questions is a design decision, not
158
+ a side effect of this one.
159
+ - **No change to the sign-in gate.** See Consequences — this is the known limit, not an
160
+ oversight.
161
+ - **No new column, no migration.** The jsonb settings store is a dumb store with room, and
162
+ D1's whole point is that a derived state must never be stored.
163
+
164
+ ## Consequences
165
+
166
+ - The join/apply door is enforced, from one function, on every surface that funnels into
167
+ the public `POST /access-requests` — the hall's landing form, the CLI, and the hub's
168
+ join relay all inherit it without changing.
169
+ - **Nothing changes for any existing project.** Every live project is `visibility=public`,
170
+ and the new knob defaults to the value that narrows nothing.
171
+ - **KNOWN LIMIT — the door narrows, the sign-in gate does not.** `openEnrollmentEnabled()`
172
+ is still `joinabilityMode() === 'open'`, so an owner who sets `private` *and* leaves
173
+ `joinability: open` gets a door that queues applications while a first-time sign-in
174
+ still self-enrols. D6's *self-join* column therefore holds only where the owner has not
175
+ explicitly opened enrolment. Closing it means composing the ceiling into
176
+ `src/bongos/auth-admission.js`, a kernel admission surface this task was scoped out of
177
+ touching; it is filed with the dark-matter verdict above. The direction of the gap is
178
+ worth naming: the door is **stricter** than the gate, never looser, so nothing is
179
+ admitted that the owner's own `joinability` did not already admit.
180
+ - `src/bongos/project-door.js` joins `KERNEL_FILES` — the doorway re-exports it, so
181
+ leaving it off would read as a kernel→non-kernel leak, and being on the roster holds it
182
+ to the kernel import rule, which is the point for a file that decides who may knock.
183
+ - The doorway gains one additive export. Per ADR 0194 D7 it rides the next CI patch rather
184
+ than a hand-bumped MINOR (the publish lane's LIVE test refuses a version its own carrier
185
+ commits never announced); `modules/onboarding/module.json` floors at the newest announced
186
+ version, which is the closest honest floor that prohibition allows.
187
+
188
+ ## Alternatives rejected
189
+
190
+ - **A fifth door value for stealth, with its own copy.** Rejected: two doors that behave
191
+ identically and *say* different things are a state read-out, and stealth's is the one
192
+ state that may not be read out (D2a).
193
+ - **Composing the ceiling into the pushed `joinability` at the platform, so the instance
194
+ needs no new reader.** Tempting — every surface would follow for free — but it makes the
195
+ stored setting and the pushed value disagree, breaks the manage page's "did my save take
196
+ effect?" read-back (ADR 0194 D1), cannot express `locked`, and would have to compute a
197
+ publish verdict at push time, which is Decision 4's staleness trap.
198
+ - **Enforcing dark matter at the hub relay off `platform_identity_projects`.** Rejected on
199
+ the live data: it refuses every join on the platform today (Decision 4).
200
+ - **Making the composed door fail to `locked` when the pack is unreadable.** Rejected: it
201
+ contradicts ADR 0194 D2 for no safety gain, and it tells an applicant a specific untruth
202
+ about the project's publication state.
@@ -331,5 +331,10 @@ This keeps the decision history honest and traceable.
331
331
  | 0241 | [**The artist gate holds the DEPLOY, not the ship, and it reads a state rather than a judgement** ([task 1003116](https://cloudbongos.com/builders#/task/1003116) · goal 1000074, criterion `artist-blocking-is-per-project` — R05, the DECISION task the goal placed before R04 so no code would be wired to an unsettled gate). Goal 1000074 carries two rules on a collision course: the owner's "blocking strictness is PER PROJECT, not global", and [ADR 0162](<redacted>.md), which retired review gates on `main` after a code-owner requirement stranded two graded-PASS ships in a day. **0162 is usually cited by a paraphrase — "a verdict is advisory, never self-executing" — and the paraphrase is wrong as a blanket claim:** 0162's own table of compensating controls marks three of four as *fails closed*, the subagent grader among them. What it actually retired was a gate GitHub could only express as ACCOUNTS — identity-pinned against [ADR 0016](<redacted>.md), and stranding with no escape hatch. So the surviving test is: **a gate is admissible when it is evaluated automatically from readable state, consistently for every builder, with a named escape hatch; inadmissible when a particular person must act and it stalls when they do not.** Decision: (1) the gate reads a **STATE** — "is there an unresolved artist review task?" — never an opinion, so the artist's judgement is what they write INTO the task and stays advisory in exactly 0162's sense, while what the system reads is ordinary GDS status; (2) strictness is a project setting `artist_gate ∈ off | advisory | strict` and at `strict` it holds the **DEPLOY, not the ship** (owner ruling) — the builder's work is graded, merged, credited and landed on CI alone exactly as today, so 0162's strand class cannot recur, and the gate attaches to the boundary the artist's ABI mandate is actually about (users see the deployed instance, not `main`; [ADR 0137](<redacted>.md) already splits land/release/deploy); (3) the escape hatch is the **project OWNER only** — not the builder, not any Metic+, not the artist — via the existing `POST /tasks/:id/override-request`, because a person can be both artist and builder and could pass their own work; a self-release is permitted but never quiet (required rationale, audit-logged as a self-override) so a solo instance cannot deadlock; (4) the default is **`strict`** (owner ruling, against the author's `advisory` recommendation), which is safe only because §2 holds the deploy — the worst case is a pin that waits, and this instance is included once R04 ships; a project with no artist must still be able to deploy, and the refusal must name what holds it and which control releases it; (5) tightening is **not retroactive** — re-gating history would strand the next deploy behind reviews for work nobody remembers and make the setting frightening to raise; R02's flag queue is the right tool for already-shipped copy. Rejected: blocking the ship (recreates 0162's incident exactly, with credits unpaid — [ADR 0120](<redacted>.md)); advisory at every level (configures nothing); any Metic+ as the hatch (makes `strict` indistinguishable from `advisory`); making the artist's VERDICT the gate (the one thing 0162 genuinely forbids); retroactive tightening; a global constant (ruled out by the owner's constraint by name).](<redacted>.md) | goal 1000074 / the artist's loop |
332
332
  | 0242 | [**A cross-role cascade is a declaration table on the event that already exists** ([task 1003549](https://cloudbongos.com/builders#/task/1003549) · goal 1000074, criterion `artist-review-task-on-ship` — the split R04 invited in its own brief and [ADR 0241](<redacted>.md)'s dependency). R04 needs "every ship with copy or visuals GENERATES one", and its brief said the infrastructure did not exist, naming the spark queue as the prototype. Reading both surfaces shows the prototype is the wrong half: the spark queue ([task 1002921](https://cloudbongos.com/builders#/task/1002921)) and the copy-desk flag queue ([task 1003113](https://cloudbongos.com/builders#/task/1003113)) are both **PULL** — filtered listings of rows another role already created — and nothing anywhere generated work on a state transition, which is the **PUSH** R04 asks for. (`cascade-dispatch.js` is model-cost routing; named only because its name has misled more than one reader.) **Decision: no new bus.** The kernel seam registry's own header uses this exact scenario as its worked example, `task.shipped` is already emitted post-commit by both ship paths and already has two consumers (discord's broadcast, sessions' deferred reward) — so a cascade is ONE MORE LISTENER on it, registered in `routes/lifecycle.js` beside the port because that mount is the one boot hook tests also run. A cascade is a frozen entry `{ key, discipline, when(task), title(task), body(task) }` — **a declaration table, not a rules engine**: no condition language, no stored rules, no per-project config, no priority ordering; adding one appends an entry and the runner does not change (the [ADR 0237](<redacted>.md)/[0239](<redacted>.md) shape, for their reason — the generalisations worth building are the ones two or three real entries have shown they share). **`CASCADES` ships EMPTY and R04 writes the first rule**, asserted so the emptiness is a decision on the record; the mechanism is proved against the tests' own declarations rather than against its first consumer's shape. Four load-bearing properties, all pinned: (1) **it can never fail the ship it reacts to** — post-commit event, isolated listeners, and a runner that catches everything and returns a report, because ADR 0241 settled that an artist may hold a DEPLOY and never a builder's SHIP and a generator that could fail a ship would smuggle that gate back in; verified against a real failure (no database reachable → one log line, resolves); (2) **idempotent on machinery that already exists** — `source: 'cascade'` + `source_ref: cascade-<key>-<taskId>` through `createTask`'s `(source, source_ref)` read, the same one a copy proposal uses ([ADR 0233](<redacted>.md)), so a reconciler re-sweep or a double emit is a no-op; (3) **it says where it came from** — inherits `goal_id`/`version_id`, carries the origin id in the ref and the prose, but NOT `parent_task_id`, which models a SUBTASK, and cascaded work is not part of the ship that triggered it; (4) **files at `backlog`, never `ready`** — generated work is a request, and auto-filing claimable work would let a rule nobody re-read mint claims. A rule key is PERMANENT (half the source_ref; renaming re-files everything it ever generated), pinned by a duplicate-key check. The mechanism is discipline-agnostic — an ideator or engineer cascade is the same entry with a different `discipline`, which is the generalisation that made this its own task. Accepted limit: cascades are declared in code, not configured per project. Rejected: extending the spark queue (a read-side filter that generates nothing), a new event or poller, a rules engine or per-project config, filing at `ready`, `parent_task_id` as the link, and running the cascade inside the ship transaction (which would make a generation failure a ship failure — the exact coupling ADR 0241 rules out).](<redacted>.md) | goal 1000074 / the artist's loop |
333
333
  | 0243 | [**A bundle adjustment belongs to the bundle, not to the advice about it** ([task 1002341](https://cloudbongos.com/builders#/task/1002341) · goal 1000046, criterion `proj-module-select`). R17 ([ADR 0239](<redacted>.md)) built a rule engine and R20 ([ADR 0240](<redacted>.md)) built a picker, and nothing connected them: the picker preselected the type's RAW preset while `GET /provisioning/recommendations` separately applied `TEAM_SHAPE_RULES` to that same preset and returned a better answer no surface rendered. Joining them the obvious way — let the picker apply the rules — breaks the property R20 was built on ([ADR 0240](<redacted>.md) §2: the preselect and the fallback are "one function, called twice"), because an owner who answers the interview, watches the panel preselect `discord`, and then SKIPS the picker would get a project without it. **Decision: `TEAM_SHAPE_RULES` moves from `recommendations.js` down to `starter-bundles.js`** (re-exported, so the public surface is unchanged), `effectiveModules` resolves through the new `bundleForAnswers(type, teamShape)`, and the picker reads the same table the fallback does. The distinction is not a preference: a bundle adjustment is not advice ABOUT the bundle — it IS the bundle once the owner has answered, and it is the only one of R17's three tables that is ever APPLIED (governance is a preview carrying `applied: false`, invites are a seam for R28), so it is the only one that has to live where the resolution happens. `effectiveModules` reads `detail.team_shape` straight off the row rather than through `normalizeProjectDetail` — importing it would point the bundle layer UP at the module that requires it ([ADR 0083](<redacted>.md)) to buy an answer it already has, since an off-vocabulary shape fires no rule and adjusts nothing. `GET /provisioning/starter-bundles` gains `byTeamShape`: every bundle re-resolved for every ruled answer, with the `adjustments` that got it there — twenty small rows, the whole preselect table in ONE read, for the reason `optional` rides the same payload (the owner flips type and team-shape on two EARLIER panels, and a refetch could land after they moved on or paint a key the panel has no row for). `PATCH /provisioning/instances/:id/detail` returns `modules: effectiveModules(updated)` beside its `publish` verdict and for the same reason — changing `team_shape` MOVES the modules of a `source: 'bundle'` project, so the effect comes back with the cause. The panel renders one reason line per adjustment that ACTUALLY fired, and only while the owner has not answered for themselves (a reason handed back for a toggle they flipped reads as the panel arguing with them). An adjusted bundle returns in CATALOG order, never append order, or the same set of modules would read as a different list depending on whether the picker was answered or skipped; a payload without `byTeamShape` degrades to the R20 answer, which is never wrong, only untailored. No new storage — the adjustment is resolved, never written. Rejected: leaving the rules up in `recommendations.js` and having the picker call `/recommendations` (two lists kept in agreement by hand, plus a round trip per keystroke); importing `normalizeProjectDetail`; storing the adjusted bundle on a skip (it writes down an answer nobody gave); moving governance and invites down too (they are never applied, so there is nothing for them to stay in agreement with); and a reason line per matched condition rather than per actual change.](<redacted>.md) | provisioning / project creation |
334
+ | 0245 | [**The way back into an optional step is that step’s own answer route** ([task 1002342](https://cloudbongos.com/builders#/task/1002342) · goal 1000046, criterion `proj-module-select`). R20 ([ADR 0240](<redacted>.md)) made the create wizard’s module picker skippable and left a project’s module set write-once — the route file said so out loud (“there is no mutation route yet”). R23 closes it WITHOUT a new endpoint: `modules` joins `description` and `detail` on `PATCH /provisioning/instances/:id/detail`, because the wizard’s two optional steps are the same kind of thing — asked optionally at creation, stored on the platform row, reaching no running instance — and that route already carried the ownership gate, the `api:self`/`api:archon:<id>` actor rule, the 404-no-existence-leak shape, and (since [ADR 0243](<redacted>.md)) an `effectiveModules` answer it could report but not set. Deliberately NOT the settings channel: a setting is a policy the running instance obeys and would queue a restart to deliver a value the instance does not read. Only an ARRAY is a change — an omitted or null key leaves the column alone — so the three-valued contract’s `NULL` (“never asked”) stays unreachable by a later edit, and `modules` REPLACES where `detail` merges, because a merging write would make removal impossible. The surface is the manage view’s extras band (`/projects?view=manage&id=N`), where a toggle IS the save and the band repaints from the platform’s answer rather than from what was sent.](<redacted>.md) | provisioning / project creation |
335
+ | 0244 | [**A catalog search is a view over the rows, never an edit to the selection** ([task 1002340](https://cloudbongos.com/builders#/task/1002340) · goal 1000046, criterion `proj-module-select`). The creation picker ([ADR 0240](<redacted>.md)) paints one toggle per optional module, and the catalog is meant to grow — so R21 adds a keyword search over it. A search over a list of links is trivial; a search over a list of TOGGLES is not, because the list is also an answer. Two failure modes follow from letting a row be hidden: the filter **edits** the answer (narrow the rendered array and the read-back array together, and typing `discord` then pressing Next files one module instead of three — silently, with every screen looking right), or the filter **lies** about it (a switched-on module off screen reads as switched off, which is [ADR 0237](<redacted>.md)’s "a preselect they have to audit" in its harsher form — one they cannot see at all). **Decision: the search is a VIEW and `state.modules` is the answer; the view may not write to it.** `moduleQuery` is a plain variable, never part of the draft, so `saveDraft` is not called from the input handler and a search can never turn an untouched picker into an answered one (ADR 0240’s `null` survives); `visibleModules()` derives the shown rows per render while `pickedModules()`, `modulesForCreate()` and `extrasRow()` keep reading the whole catalog; and the panel STATES what the filter is hiding — one line under the field counting what is **on**, not what is visible. Matching is `moduleMatches(mod, terms)`, a pure function: lower-cased **label + summary + key** as one haystack, every whitespace-separated term an AND’d substring. AND because adding a word means the list is still too long; substring because a filter that shows nothing until you finish typing reads as broken; the key because that is what a returning owner names a module by. No ranking (reordering toggles under a typing hand is worse than not), and deliberately **not semantic** — by-meaning search is deferred with the rest of the creation AI layer (idea 1000552). Rejected: filtering the read-back array (failure mode 1), CSS-hiding non-matches (leaves invisible `aria-pressed` toggles in the a11y tree), pinning switched-on rows above the query (a filter that lies the other way), persisting the query in the draft, and debouncing a pass over already-loaded rows.](<redacted>.md) |
336
+ | 0247 | [**The join door is ONE composed answer, and dark matter has no live verdict to compose with yet** ([task 1002331](https://cloudbongos.com/builders#/task/1002331) · goal 1000046, criterion `proj-join-policy`). [ADR 0182](<redacted>.md) D6 wrote the rule — visibility is the ceiling, join policy may only narrow it — and nothing implemented it: the public `POST /access-requests` read `joinability` alone, so an owner could set their project private or stealth on the map and the application endpoint behaved exactly as before. R12 adds the third knob (`join_grant` = view | apply | full, one line of settings vocabulary, default `full` so nothing existing changes) and composes all three in one pure kernel file, `src/bongos/project-door.js`, published through the doorway so no module can re-open a door the others closed. Three things worth carrying: the door vocabulary is a list ordered widest→strictest, so “the stricter wins” is the data structure rather than a branch; the refusal never names WHICH knob closed the door, because a stealth project that could be identified by its 403 gives back exactly what R06’s server-side redaction protects; and `locked` (dark matter) is specified, tested and deliberately UNWIRED — the instance cannot derive publishability (no description in its pack, and a pushed verdict goes stale the moment the owner writes one), while the hub’s cached copy says every live project is dark matter today, so enforcing off it would refuse every join on the platform.](<redacted>.md) | onboarding / provisioning / project creation |
337
+
338
+ | 0246 | [**Catalog membership is a projection of the provisioning row, not a side effect of federation** ([task 1003576](https://cloudbongos.com/builders#/task/1003576) · goal 1000046, criterion `proj-detail-interview` / C3). The BV1.R18 proof ([task 1002337](https://cloudbongos.com/builders#/task/1002337)) demonstrated C3 against live and found its last clause — “then it publishes” — structurally false: the owner’s verdict reads `provisioning_instances` while the public map reads `platform_identity_projects`, and the ONLY bridge was `federateInstance → registerClient → upsertProject`, which `shouldFederate` refuses for anything but `co-tenant` (the wizard hard-codes `standalone`) and which runs ONCE, at provision time, through a COALESCE. So a wizard-made project was never catalogued at all and a co-tenant one that answered later never updated — `publishable: true`, `{"projects": []}`, forever. The same missing row is [task 1003580](https://cloudbongos.com/builders#/task/1003580)’s GAP 1: `POST /projects/invite` resolves against `platform_identity_sso_clients`, whose only writer is that same `registerClient`. Underneath is one conflation — federation mints CREDENTIALS and was incidentally the only thing that minted MEMBERSHIP, while [ADR 0182](<redacted>.md) D4 already fixed the map’s gate at IDENTITY, which has nothing to do with hosting topology. **Decision: the catalog row is a PROJECTION of the provisioning row, re-derived at every write that can change its inputs** — two halves, neither sufficient alone. (1) THE ROW SHAPE: every control-plane project gets both rows whatever its hosting shape, and the client row carries NO credential (`secret_hash = ''`, `redirect_uris = '{}'`), so **the shape is the enforcement** rather than a flag a caller could forget — `redirectUriAllowed` refuses `/sso/authorize`, `clientSecretValid` refuses `/sso/token`, `/sso/membership/check-in` and `/sso/activity/rollup`, and the one leg that checked NEITHER (`/sso/device/start`, which resolves a client from a user-typed origin) grew an explicit `clientIsCredentialed` guard. (2) THE TIMING: re-taken on create, on `PATCH …/detail`, on `PATCH …/settings` when the door changes, and on `POST …/domain` (which re-keys the row, retiring the abandoned origin — [ADR 0181](0181-teardown-state-gate-and-address-retention.md) §2’s leak, in the catalog instead of DNS). **NO MIGRATION** — `secret_hash` is `NOT NULL` and `''` satisfies it while hashing to nothing. `description` and `visibility` are ASSIGNED, not COALESCEd (clearing a description must UN-publish); `featured`/`tagline`/`art_url`/`template` are curation and stay untouched, so [ADR 0182](<redacted>.md) D3 holds. It grants nothing — the row is attribution per [ADR 0238](<redacted>.md), ownership stays `provisioning_instances.owner_builder_id` — and because it writes rows keyed on owner-supplied values (a slug, a BYO domain) each write carries its **claim rule in the ON CONFLICT action’s own WHERE**: the client is refreshed only `WHERE secret_hash = ''` (a real client’s origin, which binds the CLI mint audience, can never be rewritten) and the project only `WHERE owner_github_id = EXCLUDED.owner_github_id` (`NULL = anything` is not true, so the hand-seeded rows cannot be claimed). Writing `visibility` does not contradict [ADR 0182](<redacted>.md) D5: the hub authorizes nothing — the authority already ran on the provisioning plane ([ADR 0238](<redacted>.md)’s own-scoped, audited PATCH), and a project the control plane does NOT own has no provisioning row, so D5’s instance report (R08) stays the only channel for those. `tearing_down`/`torn_down` project `hidden`; a `no_address` instance is not catalogued at all. Travels on the kernel port `project.catalog`, the `project.publishGate` seam in the other direction ([ADR 0083](<redacted>.md) forbids the import). Rejected: extending `shouldFederate` to every shape (hands a real secret to instances whose web.env the platform does not write, and keeps the once-at-standup bug); bridging only at the publish moment (leaves the invite with nothing to resolve); registering only at standup (the bug itself); a `catalog_only` boolean column (needs a migration and is weaker than the shape); letting the bridge write `featured`; and backfilling the three existing catalog rows from code.](<redacted>.md) | provisioning / project creation |
334
339
 
335
340
  > ⚠️ **Numbering collisions are now machine-enforced, not narrated.** Twenty numbers are shared by two ADRs each — assigned in parallel sessions before anything checked. The files keep their filenames (renumbering would break every existing citation, and a number once assigned is never reused), and this table disambiguates each pair as `NNNN-a` / `NNNN-b`. The authoritative list is `LEGACY_DUPLICATE_ADRS` in [`scripts/gds/adr-namespace.js`](../../scripts/gds/adr-namespace.js), frozen by exact filename: a **new** collision — or a third file joining a legacy number, or a rename of either half — hard-fails the `unit` CI gate, as does an index row that points at no file or an ADR with no row. The list may only shrink. This paragraph used to narrate the collisions one by one and had fallen eight behind reality; see [ADR 0195](<redacted>.md) for why the check exists and why the pairs are grandfathered rather than renumbered.
@@ -11458,7 +11458,7 @@
11458
11458
  "provisioning"
11459
11459
  ],
11460
11460
  "summary": "PATCH /provisioning/instances/:id/detail",
11461
- "description": "── The optional project detail (task 1002334, goal 1000046) ────────────── PATCH /provisioning/instances/:id/detail — answer (or re-answer) the \"tell us about your project\" step after creation. Body: any subset of { description, detail }. rank: any authenticated builder (own resource; archon may change any — same gate as settings). THIS ROUTE IS WHAT MAKES THE STEP SKIPPABLE. Skipping at creation leaves the project dark-matter — off the map and un-joinable (ADR 0182 D4) — and that is a supported default only because there is a way back. Without this route \"optional\" would mean \"permanently unpublishable\", i.e. a dead end, which is precisely what criterion `proj-detail-interview` refuses. Deliberately NOT a settings-apply: nothing here is a policy the running instance obeys, so there is no web.env to patch and no restart to queue. The answers live on the platform row until a catalog row materializes (the provisioning_007 `type` precedent), and R16 (task 1002335) is the surface that reads `description` to compute publishability and name what is still missing.\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
11461
+ "description": "── The optional project detail (task 1002334, goal 1000046) ────────────── PATCH /provisioning/instances/:id/detail — answer (or re-answer) the wizard's OPTIONAL creation steps after creation. Body: any subset of { description, detail, modules }. rank: any authenticated builder (own resource; archon may change any — same gate as settings). THIS ROUTE IS WHAT MAKES THE STEP SKIPPABLE. Skipping at creation leaves the project dark-matter — off the map and un-joinable (ADR 0182 D4) — and that is a supported default only because there is a way back. Without this route \"optional\" would mean \"permanently unpublishable\", i.e. a dead end, which is precisely what criterion `proj-detail-interview` refuses. Deliberately NOT a settings-apply: nothing here is a policy the running instance obeys, so there is no web.env to patch and no restart to queue. The answers live on the platform row until a catalog row materializes (the provisioning_007 `type` precedent), and R16 (task 1002335) is the surface that reads `description` to compute publishability and name what is still missing. `modules` rides the SAME route for the same reason (BV1.R23, task 1002342, ADR 0245): step 4 of the wizard is skippable exactly as step 3 is, so it needs the same way back, and it lands on the same platform row under the same ownership gate. Only an ARRAY is a change — an omitted or null key leaves the column alone, so the \"never asked\" NULL (ADR 0240 §2) can never be written by a later edit. The always-on core stays unremovable by construction: badModuleSelection refuses its keys and `effectiveModules` re-adds it on read.\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
11462
11462
  "x-rank": "any-builder",
11463
11463
  "x-source": "modules/provisioning/routes/provisioning.js",
11464
11464
  "parameters": [
@@ -18237,6 +18237,13 @@
18237
18237
  },
18238
18238
  "detail": {
18239
18239
  "type": "object"
18240
+ },
18241
+ "modules": {
18242
+ "type": "array",
18243
+ "items": {
18244
+ "type": "string"
18245
+ },
18246
+ "maxItems": 50
18240
18247
  }
18241
18248
  },
18242
18249
  "additionalProperties": false
@@ -464,7 +464,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
464
464
  | GET | `/api/bongos/provisioning/instances` | `any-builder` | — | GET /provisioning/instances — the caller's own instances + each open intent. |
465
465
  | POST | `/api/bongos/provisioning/instances` | `any-builder` | `slug`, `target_ref`, `hosting_shape`, `domain`, `no_address`, `tier`, `onboard_mode`, `type`, `description`, `detail`, `modules` | POST /provisioning/instances — request a new instance. |
466
466
  | GET | `/api/bongos/provisioning/instances/:id` | `any-builder` | — | GET /provisioning/instances/:id — read one instance (the manage page's read). |
467
- | PATCH | `/api/bongos/provisioning/instances/:id/detail` | `any-builder` | `description`, `detail` | ── The optional project detail (task 1002334, goal 1000046) ────────────── PATCH /provisioning/instances/:id/detail — answer (or re-answe… |
467
+ | PATCH | `/api/bongos/provisioning/instances/:id/detail` | `any-builder` | `description`, `detail`, `modules` | ── The optional project detail (task 1002334, goal 1000046) ────────────── PATCH /provisioning/instances/:id/detail — answer (or re-answe… |
468
468
  | POST | `/api/bongos/provisioning/instances/:id/domain` | `any-builder` | `domain` | POST /provisioning/instances/:id/domain — give the caller's OWN instance its address (task 1002692, gap 5: no path existed to add a domai… |
469
469
  | POST | `/api/bongos/provisioning/instances/:id/force-teardown` | `metic+archon` | — | POST /provisioning/instances/:id/force-teardown — Archon tears down ANY instance. |
470
470
  | POST | `/api/bongos/provisioning/instances/:id/github-app` | `any-builder` | `account` | POST /provisioning/instances/:id/github-app — mint a GitHub-App-Manifest onboarding link for the caller's OWN instance (task 2080, ADR 01… |