@bongos/core 1.19.537 → 1.19.539

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 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.537",
6
- "core_contract": "1.19.537",
7
- "source_commit": "171115727a6362835dd549dbed353a8bc817e4ee",
5
+ "core_version": "1.19.539",
6
+ "core_contract": "1.19.539",
7
+ "source_commit": "44dd6770e269dee2457b0314723df7ca150e0a5a",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-04T15:20:04.870Z",
9
+ "built_at": "2026-09-04T16:03:33.267Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 439,
12
+ "docs_redacted": 441,
13
13
  "agent_docs_stubbed": 24,
14
- "functional_verbatim": 1995,
14
+ "functional_verbatim": 1998,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2458,
20
- "tree_sha256": "2db763cfb805a63a9dd4a04cacedb717acd4aab6b9a8f3ce795a57f1bc49a0d4",
19
+ "file_count": 2463,
20
+ "tree_sha256": "32961f5907d555d0b351508f1f1e731b0f2aec030c8b6a3fe9813c057fb31d6a",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -1749,20 +1749,30 @@
1749
1749
  "mode": "0000644",
1750
1750
  "sha256": "f46ee74d7b83ad4422e1832fdba68a8b9ea0deccb425ab3083d4c8fbfd06eb02"
1751
1751
  },
1752
+ {
1753
+ "path": "docs/adr/0248-a-hub-project-s-owner-is-the-provisioning-row-carried-on-a-port.md",
1754
+ "mode": "0000644",
1755
+ "sha256": "cbcac548fc3b683fd7219e312ab1542768c973aca5112d8cbc9ad0b2d09da650"
1756
+ },
1757
+ {
1758
+ "path": "docs/adr/0249-the-invite-step-is-the-done-panel-s-first-act-not-a-rail-step.md",
1759
+ "mode": "0000644",
1760
+ "sha256": "38fa5f4ce3a91eaf3302527a4228ddb1951ea128e83b2ab8e0ad5def658e0a6d"
1761
+ },
1752
1762
  {
1753
1763
  "path": "docs/adr/README.md",
1754
1764
  "mode": "0000644",
1755
- "sha256": "d1bb4772b43b56cf1efa16fa99f255535bd736910c4c0ead94249f19862c7152"
1765
+ "sha256": "464fa796eeaa7d3a2d6f6d7a2c36d01fc9dfbb613f0824f00b9710bf568c0cce"
1756
1766
  },
1757
1767
  {
1758
1768
  "path": "docs/api-reference.md",
1759
1769
  "mode": "0000644",
1760
- "sha256": "88fb38cb095713380c1c70187816c33c930cf962d9e4641ec4bb740da56ba90b"
1770
+ "sha256": "aef38c3ecd8942f0a38c56cbb390fd3590e785634d24e95660900a5140636b5f"
1761
1771
  },
1762
1772
  {
1763
1773
  "path": "docs/api/openapi.json",
1764
1774
  "mode": "0000644",
1765
- "sha256": "8ae264d8c9983ff2841a8309c141b6fb57b395bb4b52894a4b49f58280d8fb1b"
1775
+ "sha256": "923202b89890cc048769c8b05087181fa06ff126aa80aeaa4d9f669f4d96c27e"
1766
1776
  },
1767
1777
  {
1768
1778
  "path": "docs/architecture.md",
@@ -1787,12 +1797,12 @@
1787
1797
  {
1788
1798
  "path": "docs/copy-inventory.md",
1789
1799
  "mode": "0000644",
1790
- "sha256": "9a1c38c7134017a58f8bcee28ca7b1098a4c654f95908939328fd735e5c26984"
1800
+ "sha256": "7371d3f5a382e9e63cf086f4aa1ec07e275bece3aefc1903596d8bb4acf0a9e1"
1791
1801
  },
1792
1802
  {
1793
1803
  "path": "docs/copy-registry.json",
1794
1804
  "mode": "0000644",
1795
- "sha256": "ded4329d04fdde729da3e8fdf4af7b59d39223da6343096d815472b8d573f1ef"
1805
+ "sha256": "9300544ffa1afaffac41a531958f196904beff4d8a3adb79cf99ea663695beda"
1796
1806
  },
1797
1807
  {
1798
1808
  "path": "docs/design/apex-pass-2-direction.md",
@@ -2637,7 +2647,7 @@
2637
2647
  {
2638
2648
  "path": "docs/module-api-changelog.md",
2639
2649
  "mode": "0000644",
2640
- "sha256": "1b719e47366fff0090cc4f3a18743953191d612e150e59a6c68e5d27c7b71624"
2650
+ "sha256": "caabe486cdebebec74a457c5549831edbf0f837f5ad300b13dafb240f856c0ff"
2641
2651
  },
2642
2652
  {
2643
2653
  "path": "docs/modules-contract.md",
@@ -5824,6 +5834,11 @@
5824
5834
  "mode": "0000644",
5825
5835
  "sha256": "01d30570d828a1f06ccac693cc5c268d00fb091a13412a3f3890f10828a05203"
5826
5836
  },
5837
+ {
5838
+ "path": "modules/platform-identity/invite-authz.js",
5839
+ "mode": "0000644",
5840
+ "sha256": "2eb5577450d3bc1d67b7eab9a13256889aca945c6ac6a6a0ca919e195952b7a1"
5841
+ },
5827
5842
  {
5828
5843
  "path": "modules/platform-identity/migrations/platform_identity_001_tables.sql",
5829
5844
  "mode": "0000644",
@@ -5977,7 +5992,7 @@
5977
5992
  {
5978
5993
  "path": "modules/platform-identity/routes/projects.js",
5979
5994
  "mode": "0000644",
5980
- "sha256": "f90fbe23065310a09769bc46fdb0703e460f402d5a4e0cc490eba70cf67965ea"
5995
+ "sha256": "dac39b920025d696ba34e20aa4a9fbfb70d1cbc9ffe8f3b0da823f333d6a8d7a"
5981
5996
  },
5982
5997
  {
5983
5998
  "path": "modules/platform-identity/routes/public-profile.js",
@@ -6097,7 +6112,7 @@
6097
6112
  {
6098
6113
  "path": "modules/provisioning/module.json",
6099
6114
  "mode": "0000644",
6100
- "sha256": "26a6d752e610ac46f8306aa707f0229bf83ec9b0cb6c128ec723b7cc55b2b977"
6115
+ "sha256": "c451d94736219198d10d1bb8837ff9aecd6d4f90f8bf46790deba38f0d2168ec"
6101
6116
  },
6102
6117
  {
6103
6118
  "path": "modules/provisioning/onboard-plan.js",
@@ -6112,7 +6127,7 @@
6112
6127
  {
6113
6128
  "path": "modules/provisioning/provisioning.js",
6114
6129
  "mode": "0000644",
6115
- "sha256": "b073e5c362797e221f5e0f9ad322ee8e1c3206ea44ed98c527b3c1c7b696879c"
6130
+ "sha256": "39635718015b8fcb0cf5d21f3769e292e129bfe312b5802d073ef63b0d8b7b1e"
6116
6131
  },
6117
6132
  {
6118
6133
  "path": "modules/provisioning/rate-limit.js",
@@ -6132,7 +6147,7 @@
6132
6147
  {
6133
6148
  "path": "modules/provisioning/routes/provisioning.js",
6134
6149
  "mode": "0000644",
6135
- "sha256": "f9231eb18211a2586d2ee1919ff817ac5fa14f1ea5ad1906ad5c58437f6238f6"
6150
+ "sha256": "00585a2de10a05c814fb0c1ad3cd6b564096b70b7729695b54953ab98c75601b"
6136
6151
  },
6137
6152
  {
6138
6153
  "path": "modules/provisioning/starter-bundles.js",
@@ -6387,7 +6402,7 @@
6387
6402
  {
6388
6403
  "path": "modules/public-landing/public/assets/cosmos.css",
6389
6404
  "mode": "0000644",
6390
- "sha256": "8da9101c633ea363802a4ac2831fe55d182308573a923bec50b4ce00e4f8254b"
6405
+ "sha256": "0560e91405dabceb22a898ca73ef1830b28ad3e45b0da36ee4ffef770303c1f2"
6391
6406
  },
6392
6407
  {
6393
6408
  "path": "modules/public-landing/public/assets/verro-medallion.png",
@@ -6647,7 +6662,7 @@
6647
6662
  {
6648
6663
  "path": "modules/public-landing/public/projects.html",
6649
6664
  "mode": "0000644",
6650
- "sha256": "456100c301325c4d303061a00804fa67f7dfc7d4494b15aea190641541db6531"
6665
+ "sha256": "1a7ba9243c257e767520aae6a164bf9730cd7834d6b1c9bc63c8202a2e711599"
6651
6666
  },
6652
6667
  {
6653
6668
  "path": "modules/public-landing/public/projects.probes.json",
@@ -6657,7 +6672,7 @@
6657
6672
  {
6658
6673
  "path": "modules/public-landing/public/projects.states.json",
6659
6674
  "mode": "0000644",
6660
- "sha256": "2cd5e5598d59e8dbf813658c950821bbb9a34b57823bdfb3be7f61d834bf7714"
6675
+ "sha256": "a2d3ecc5e893bcaa5632c2d693d5b642c7041cc285e382d572954284ad64e241"
6661
6676
  },
6662
6677
  {
6663
6678
  "path": "modules/public-landing/public/terms.html",
@@ -7392,12 +7407,12 @@
7392
7407
  {
7393
7408
  "path": "package-lock.json",
7394
7409
  "mode": "0000644",
7395
- "sha256": "b52e9fc284a5e725c64604313074cc5b72653d3158a38ed20556a7a79d6649e8"
7410
+ "sha256": "66f287d8f797723f96f306f5bcf58ca8864df95f32eb5085ed22f10b70cf786b"
7396
7411
  },
7397
7412
  {
7398
7413
  "path": "package.json",
7399
7414
  "mode": "0000644",
7400
- "sha256": "a035e8e7a14e9ebce98dada6fd2bad96f3f9f0028707412394714c9edd538b77"
7415
+ "sha256": "556128c4c27a98a8cc046a8337b6cba32a0d3f2fb69fadd59fe48b52f1f3626f"
7401
7416
  },
7402
7417
  {
7403
7418
  "path": "public-docs/index.html",
@@ -9082,7 +9097,7 @@
9082
9097
  {
9083
9098
  "path": "src/module-api.js",
9084
9099
  "mode": "0000644",
9085
- "sha256": "bbd37b696a4b36e1e5a572719a92e7a187b8653fcf360a658de83c43ef4e8331"
9100
+ "sha256": "9c3ae03a74eec7909f043bd63aef66477f6d771ab18c619d9bad129d9323c537"
9086
9101
  },
9087
9102
  {
9088
9103
  "path": "src/module-loader/catalog.js",
@@ -11307,13 +11322,23 @@
11307
11322
  {
11308
11323
  "path": "tests/project_catalog_routes.mjs",
11309
11324
  "mode": "0000644",
11310
- "sha256": "5918d1ce7c106e5d7baa9cb989d52f3e82ac2aaf45a89f9a9bb154066106dccc"
11325
+ "sha256": "afef4620252bc212b4326863abb021e8cd6033e258345e44cf78e97fc3b28b63"
11311
11326
  },
11312
11327
  {
11313
11328
  "path": "tests/project_door_ui.mjs",
11314
11329
  "mode": "0000644",
11315
11330
  "sha256": "29f59ab64b231a7ec5d17a3e784d451f75d80c19efd4cb6a523d263e8f88b581"
11316
11331
  },
11332
+ {
11333
+ "path": "tests/project_invite_ownership.mjs",
11334
+ "mode": "0000644",
11335
+ "sha256": "743436b1191353bf687d8fb553d423a6003707a25a548367644928478569f171"
11336
+ },
11337
+ {
11338
+ "path": "tests/project_invite_ui.mjs",
11339
+ "mode": "0000644",
11340
+ "sha256": "288ee2d4d736c8481d459a902e1d5ba02f823685ee4c42c8c22cce76dcf94918"
11341
+ },
11317
11342
  {
11318
11343
  "path": "tests/project_join_door.mjs",
11319
11344
  "mode": "0000644",
@@ -11347,7 +11372,7 @@
11347
11372
  {
11348
11373
  "path": "tests/projects_hub.mjs",
11349
11374
  "mode": "0000644",
11350
- "sha256": "2ef099cea46b1ffa0841d1845dedf00de047de4f9c6844baaabdcd317a5eebac"
11375
+ "sha256": "56b1ecd69244798b704b787e13fd9fc74ec07e575a847ef6603df7430b63a51e"
11351
11376
  },
11352
11377
  {
11353
11378
  "path": "tests/projects_hub_module_picker.mjs",
@@ -11357,7 +11382,7 @@
11357
11382
  {
11358
11383
  "path": "tests/projects_hub_pre_uat.mjs",
11359
11384
  "mode": "0000644",
11360
- "sha256": "294ecd72fa9e1c67cba9c2411c57447e459426682586c099adcd93b606530def"
11385
+ "sha256": "e16e0488e1364a09503cec17a85e19ed6045fd8c6430c472c050934e1cad4013"
11361
11386
  },
11362
11387
  {
11363
11388
  "path": "tests/promote_goal_id.mjs",
@@ -0,0 +1,144 @@
1
+ # ADR 0248 — A hub project's owner is the provisioning row, carried on a port and bound to both halves of the catalog projection
2
+
3
+ - **Status:** accepted
4
+ - **Date:** 2026-09-04
5
+ - **Task:** [task 1003580](https://cloudbongos.com/builders#/task/1003580) (BONGOS-V1, goal 1000046 — *Project creation*, criterion `proj-invite-builders` / C6), GAP 2
6
+ - **Deciders:** Claude, under the Archon's standing scope
7
+ - **Implements:** [ADR 0246](<redacted>.md)'s named remainder — *"what remains there is purely the ownership branch… the ownership proof it should carry is `provisioning_instances.owner_builder_id`"*
8
+ - **Related:** [ADR 0210](<redacted>.md) (owner-or-rank as one middleware; the atom stays spelled in the route's own list) · [ADR 0238](<redacted>.md) (the hub's `owner_github_id` columns are attribution, never authority) · [ADR 0083](<redacted>.md) (the one-way module rule — why this travels on a port) · [ADR 0182](<redacted>.md) D2a (a stealth project is anonymous, so a refusal may not identify it) · [ADR 0016](<redacted>.md) (fail-closed authority)
9
+
10
+ ---
11
+
12
+ ## Context
13
+
14
+ `POST /projects/invite` is gated `requirePermission('project.curate')`, floor `metic`
15
+ ([ADR 0157](<redacted>.md)). It is a **curation** gate:
16
+ `requirePermission` is purely grant/rank-based and has no own-scope branch. The creation
17
+ wizard's audience is any signed-in GitHub user — on the hub, typically a `xenos` holding
18
+ nothing at all. So the person who created a project got **403 on inviting anyone into their
19
+ own project**, and BV1.R27 ([task 1002346](https://cloudbongos.com/builders#/task/1002346)),
20
+ R28, R29 and the C6 proof all sat behind it.
21
+
22
+ ADR 0246 closed the other half (the project is now a resolvable catalog row) and named this
23
+ one as its own decision, with the constraint it has to answer to: **ADR 0238 says the hub's
24
+ ownership columns are attribution, not authority.** Lowering `project.curate`'s floor is not
25
+ available either — the same reason ADR 0210 gives: a floor is a rank, ownership is a
26
+ relationship, and no floor low enough to reach a project's registrant excludes anybody.
27
+
28
+ ## Decision
29
+
30
+ **Admit a `project.curate` holder OR the project's own owner, where "owner" means
31
+ `provisioning_instances.owner_builder_id` — asked of provisioning through a kernel port, and
32
+ bound to BOTH halves of the ADR 0246 projection.** Three parts:
33
+
34
+ 1. **The proof is the provisioning row.** That column is the authority `instanceAccessDenied`
35
+ already enforces on every owner-facing provisioning route (the manage page, both settings
36
+ routes, the detail answers). The hub's `sso_clients.owner_github_id` and
37
+ `platform_identity_projects.owner_github_id` stay what their migration comment and ADR 0238
38
+ say they are: attribution. Nothing here promotes them.
39
+
40
+ 2. **It travels on a NEW port, in the direction ADR 0246 did not open.** `project.catalog`
41
+ carries a write hub-ward (provisioning → platform-identity); this question runs the other
42
+ way, so provisioning provides **`provisioning.projectOwnedBy` ({ slug, host, builderId }) →
43
+ boolean**, registered beside `provisioning.instanceState`. platform-identity resolves it
44
+ with `resolveOptional` and does **not** declare it in `consumes` (the
45
+ `provisioning.projectPlanets` precedent): provisioning is `default: false`, and a hub
46
+ without it simply has no control-plane project to own. **A boolean, not the owner's id** —
47
+ the comparison is authority and belongs on the plane holding the row, so no consumer ever
48
+ handles an ownership fact it could mis-compare.
49
+
50
+ 3. **The question names both halves of the projection.** ADR 0246 puts a project on the map as
51
+ `client_id = slug` at `origin = https://<domain>`, and the port answers only when the
52
+ caller owns the instance carrying **both**, in one statement. Matching the slug alone would
53
+ let whoever registers an instance *named after* a client the control plane never created —
54
+ a hand-seeded row, whose origin the catalog's own claim rule (ADR 0246 rule 1) already
55
+ refuses to overwrite — speak for that project. The invite arm answers to the same binding
56
+ the catalog write does.
57
+
58
+ ### The OR is one middleware, and the atom stays spelled in the route
59
+
60
+ `modules/platform-identity/invite-authz.js` is `scouting-authz.js`'s shape, and it is that
61
+ shape for ADR 0210's reasons, restated because they are load-bearing rather than stylistic:
62
+
63
+ - **`requirePermission('project.curate')` is built in the route's own middleware list** and
64
+ handed to the helper. A gate hidden inside a helper is invisible to `route-rank-check.js`
65
+ and `gen-api-docs.js`, which classify a route from its middleware — this route would have
66
+ been recorded `any-builder`, wrong in the dangerous direction, and the published API
67
+ reference would have said so. It still classifies `metic+archon` with
68
+ `x-permissions: ["project.curate"]`; the "or owner" lives in the description, which is the
69
+ same over-strict-but-safe answer ADR 0210 accepted.
70
+ - **Ownership is asked FIRST, and a refusal falls through to the UNMODIFIED gate.** Asking the
71
+ permission gate first is not available: a middleware that has denied has already *sent* its
72
+ response, so there is nothing to fall through from. Paying one extra lookup on the curator
73
+ path is what makes the 403 byte-identical — same code, same `required`/`held`, same floors
74
+ map. **The widening can only ever ADMIT; it cannot change, soften, or swallow a refusal.**
75
+
76
+ ### The refusal is not an existence oracle — in body OR in latency
77
+
78
+ This is why the gate runs **before** the target is resolved rather than after. Resolving first
79
+ and then authorizing would split the answer into 404-for-absent and 403-for-present, handing
80
+ any signed-in stranger a membership oracle over every private project on the platform —
81
+ exactly what ADR 0182 D2a's server-side anonymisation exists to prevent. Because the gate
82
+ delegates to the untouched permission middleware, a stranger's refusal is identical for a real
83
+ project, a stealth project, a project that does not exist, and a malformed body; the gate runs
84
+ before the body validator, exactly as `requirePermission` always did.
85
+
86
+ **A body-identical refusal that is measurably faster is still an oracle**, so every path does
87
+ the same two reads. An unknown `client_id` is asked about anyway, with the empty host, and
88
+ `projectOwnedBy` **queries** it rather than short-circuiting: an empty host names no address
89
+ and matches nothing (`domain` is a hostname or NULL, and `lower(NULL) = ''` is NULL), so it is
90
+ a real round-trip with a guaranteed-empty result rather than a dummy delay. That is the one
91
+ place in this design where the cheap-looking early return is the wrong call, and it is written
92
+ into the function's own doc-comment so a later "optimisation" cannot quietly reopen the
93
+ channel. Equalising the lookup must not DUPLICATE it, so the resolved row rides forward on
94
+ `req.inviteTargetClient` (the `req.builder` / `req.rawPermissionKeys` precedent) and the
95
+ handler reads it instead of fetching the same row again.
96
+
97
+ ## Consequences
98
+
99
+ - **[task 1002346](https://cloudbongos.com/builders#/task/1002346) (BV1.R27) is unblocked**, and
100
+ is the pure wizard-step task it was scoped as. The route path, body and responses are
101
+ unchanged: `POST /api/bongos/projects/invite` with `{ client_id, github_login }` → `201
102
+ { invited: true, row }` on a fresh invite, `200 { invited: false, alreadyMember: true }` when
103
+ the membership already exists.
104
+ - **Owning a project buys exactly one act on the hub** — pre-seeding a `pending` membership on
105
+ your own project. No rank, no curation, no read of anyone else's project, and nothing else
106
+ consults it. The reverse also holds: this reads ownership and grants none.
107
+ - **Fail-closed in three directions.** No provisioning module → the old door, unchanged. A
108
+ throwing ownership check → the old door, unchanged (never a 500, never an admit). A
109
+ non-string `client_id` → not a claim, and neither the port nor the client registry is asked.
110
+ - **Every caller now costs two indexed reads before the permission gate runs**, including one
111
+ who will be refused. That is the deliberate price of equal latency on a cold, authenticated
112
+ route; it is bounded (two primary-key/`LIMIT 1` lookups) and it is why the equalisation is
113
+ affordable here and would not be on a hot public path.
114
+ - **A re-pointed co-tenant project can lose the own arm.** The catalog's claim rule 1 refuses
115
+ to rewrite a *credentialed* client's origin, so a federated project that moves address keeps
116
+ a stale `origin` while its provisioning row carries the new domain, and the binding stops
117
+ matching. That degrades to curator-only — the safe direction — and is named here so the next
118
+ reader recognises it rather than rediscovering it as a bug.
119
+ - **The port is a general capability with one consumer.** Any future hub surface that wants to
120
+ own-scope a control-plane project asks the same question rather than inventing a second
121
+ answer — which is what keeps a hub ownership column from being promoted to authority the
122
+ next time this comes up.
123
+
124
+ ## Alternatives rejected
125
+
126
+ - **Read `sso_clients.owner_github_id` (candidate B as originally scouted).** Rejected: it is
127
+ the exact thing its own migration comment and ADR 0238 forbid — a hub catalog column with no
128
+ instance behind it, writable by hub curation, and NULL on the hand-seeded rows.
129
+ - **Lower `project.curate`'s floor to `xenos`.** Rejected on ADR 0210's grounds: it would admit
130
+ every stranger who ever signed in, to every project on the platform.
131
+ - **Mint a `project.invite.own` ownership-scoped atom** and use `authorizeOwnedOrAny`.
132
+ Rejected: the ownership-scoped axis resolves the *rank* fact from a catalog floor and the
133
+ *owner* fact from a row the handler loaded, and here the owner fact lives on another module's
134
+ plane entirely — so the atom would still need this port, plus a catalog-floor change this
135
+ task is not authorised to make. The recruiting wave's `project.recruit`
136
+ ([spike 1002786](https://cloudbongos.com/builders#/task/1002786) R3) is where a new atom is
137
+ defined; when it lands it replaces the `project.curate` half of this OR and the ownership
138
+ half is unaffected — the same seam ADR 0210 left.
139
+ - **Key the ownership question on the slug alone.** Rejected: see decision 3 — it makes a
140
+ hand-seeded catalog row's name a squattable claim on that project's membership directory.
141
+ - **Reuse the `membership_kind = 'owner'` proof `scouting-authz.js` uses.** Rejected: that row
142
+ is written by a federated instance's `POST /sso/membership/check-in`, which a standalone
143
+ wizard-made project never calls — it would be empty for exactly the audience this task
144
+ exists to admit.
@@ -0,0 +1,115 @@
1
+ # ADR 0249 — The invite step is the done panel's first act, not a rail step — and the same widget is the way back
2
+
3
+ - **Status:** accepted
4
+ - **Date:** 2026-09-04
5
+ - **Task:** [task 1002346](https://cloudbongos.com/builders#/task/1002346) (BONGOS-V1, goal 1000046 — *Project creation*, criterion `proj-invite-builders` / C6), BV1.R27
6
+ - **Deciders:** Claude, under the Archon's standing scope
7
+ - **Depends on:** [ADR 0246](<redacted>.md) (a control-plane project is a catalog row keyed on its slug — what makes `client_id` resolve at all) · [ADR 0248](<redacted>.md) (the project's own owner may invite — what makes the wizard's audience anything but a 403)
8
+ - **Related:** [ADR 0245](<redacted>.md) (an optional step is a default rather than a dead end only if there is a way back) · [ADR 0237](<redacted>.md) / [ADR 0240](<redacted>.md) (the wizard's other skippable steps) · [ADR 0182](<redacted>.md) D2a (a refusal may not identify a stealth project)
9
+
10
+ ---
11
+
12
+ ## Context
13
+
14
+ BV1.R27 asks for a **skippable invite-builders step in creation**, using the existing
15
+ per-individual invite. The wizard is a numbered rail — name · type · about · modules · home ·
16
+ address · create — and every other optional step (About, Modules) is one of those numbered
17
+ panels, answered *before* the project is filed and carried into the create request.
18
+
19
+ An invite cannot be. `POST /projects/invite` takes `{ client_id, github_login }` where
20
+ `client_id` **is the project's slug**, and it resolves that slug against a catalog row. Per
21
+ ADR 0246 that row is a **projection of the provisioning row, written at create** — so before
22
+ the Create step there is no row, and any invite filed on a numbered panel would either 404 or
23
+ have to be queued client-side and replayed after the create, inventing a second, unaudited
24
+ invite path whose failures arrive after the owner has left the screen.
25
+
26
+ The route also has no "invite later" queue and no bulk form: it pre-seeds exactly one
27
+ `pending` membership per call, for a GitHub login that already has a platform account.
28
+
29
+ ## Decision
30
+
31
+ **The invite step is the FIRST act of the done panel, not a step in the rail** — shown the
32
+ moment the created-instance record carries an id and a slug, and skippable there. Four parts:
33
+
34
+ 1. **It is keyed on the created record, not on `instanceId`.** The done panel already
35
+ resumes from `sessionStorage`'s created record on reload, and `startProgress` sets
36
+ `instanceId` *after* `renderDone()` runs. Keying the step on the record is what makes it
37
+ appear on the first paint and again on a resumed watch, with no new state to keep in step.
38
+
39
+ 2. **The rail is not renumbered.** "A wizard step renumbering couples in four places" is a
40
+ captured learning on this exact file, and the draft key has been bumped twice for it. A
41
+ step that can only run after the terminal step is not a step in the rail — putting it there
42
+ would buy the coupling and still have to be re-ordered to sit after Create.
43
+
44
+ 3. **The same widget is mounted twice**, the ADR 0245 argument: an optional step is a default
45
+ rather than a dead end only if there is a way back, so the project's own page carries an
46
+ *Invite a builder* section beside *Its extras*, on the same route with the same behaviour.
47
+ One implementation, two mounts, keyed state per mount — never two copies of the refusal
48
+ vocabulary that could drift apart.
49
+
50
+ 4. **The widget answers neither "is anyone signed in" nor "is this their project."** Each call
51
+ site already knows and hands over a slug or nothing, which is the widget's only door in
52
+ (`setInviteSlug`). The wizard's answer is the created record; the manage page's is that
53
+ the per-id provisioning read is own-scoped and 404s for everyone else, so *reaching*
54
+ `renderManage` IS the ownership answer. A page that re-derived ownership would be a second,
55
+ weaker copy of a server rule ([ADR 0016](<redacted>.md)).
56
+
57
+ ### The 403 is quoted carefully, and that is the point
58
+
59
+ ADR 0248 made the invite refusal **byte-identical and identically slow** whether the project is
60
+ real, stealth, absent, or the body malformed — deliberately, so a stranger cannot use it as a
61
+ membership oracle over private projects (ADR 0182 D2a). A surface is free to undo that in
62
+ copy: *"you don't have permission on this project"* asserts the project exists. So the page
63
+ says only what it knows — **"This account can't invite people here."** — and the pin against
64
+ that wording is a test, not a comment, because the tempting rewrite is the wrong one.
65
+
66
+ The other three refusals are said in the route's own vocabulary: `account_not_found` becomes
67
+ *"that builder hasn't signed in here yet"* (V1 requires an existing account, and the sentence
68
+ says what to do about it); `unknown_project` becomes *"this project isn't on the map yet — give
69
+ it a moment"*, because the catalog row is projected at create, so this is a moment rather than
70
+ a dead end; anything else falls through to the **server's own message** rather than a reason
71
+ the page invented.
72
+
73
+ ### What it publishes of an invitee
74
+
75
+ The 201 row is `{ id, client_id, github_id, membership_kind }`. The pending line is drawn from
76
+ the login the inviter typed plus the row's own `membership_kind` — **never the `github_id`**,
77
+ which identifies someone who has accepted nothing yet, and never a state word this page
78
+ invented: a kind other than `pending` is shown verbatim rather than papered over.
79
+
80
+ ## Consequences
81
+
82
+ - **Creation is never blocked by an invite.** The project is filed before the step is shown at
83
+ all, so every refusal leaves a created project and a re-tryable box. Skip sends nothing and
84
+ hands focus to the human-steps heading rather than stranding it in the box it just hid.
85
+ - **One builder per call, by design.** Bulk invite, an invite queue, and inviting someone with
86
+ no platform account are the route's own follow-ups, not this surface's to simulate.
87
+ - **The pending lines are session-local and per-project.** They are a receipt for what this
88
+ session filed, not a membership directory: switching the mount to another slug clears them,
89
+ because following them across projects would be a lie. The real directory is the project's
90
+ hall, and reading it back here would need a route this task does not add.
91
+ - **The R30 proof ([task 1002349](https://cloudbongos.com/builders#/task/1002349)) can run
92
+ without creating a project**, because the manage mount is the same widget on the same route.
93
+ - **A wizard step that must run after Create now has a home.** Anything else in that shape —
94
+ the first-invite nudge, a post-standup choice — belongs in the done panel beside this one
95
+ rather than in the rail, and the rail's seven numbered steps keep meaning "answered before
96
+ the project is filed".
97
+
98
+ ## Alternatives rejected
99
+
100
+ - **An eighth numbered rail step, before Create.** Rejected: there is no project to invite to
101
+ yet, so the step could only collect names and replay them after the create — a second invite
102
+ path, unaudited, whose failures surface after the owner has moved on. It also buys the
103
+ four-place renumbering coupling for a step that must run last anyway.
104
+ - **A numbered step AFTER Create.** Rejected: the rail's contract is "answered before the
105
+ project is filed" (`paintRail` reads the whole rail as finished once the project lands), and
106
+ a step numbered 8 would have to be exempted from both the rail's back-navigation and its
107
+ finished state — the coupling with none of the meaning.
108
+ - **Only the manage page, with a pointer from the done panel** (the task 1002342 shape).
109
+ Rejected: R27 asks for the step *in creation*, and the moment an owner most wants to invite
110
+ people is the moment their project stands up. The pointer is kept as well, not instead.
111
+ - **Read the project's membership back and render the real directory.** Rejected: no
112
+ own-scoped read of a project's pending memberships exists on the hub, and inventing one is a
113
+ route change this task is not scoped for. The session receipt is honest about being one.
114
+ - **Word the 403 as "you don't have permission on this project."** Rejected: see above — it
115
+ reopens the existence oracle ADR 0248 paid two equalised reads per request to close.
@@ -334,6 +334,9 @@ This keeps the decision history honest and traceable.
334
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
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
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
+ | 0248 | [**A hub project’s owner is the provisioning row, carried on a port and bound to both halves of the catalog projection** ([task 1003580](https://cloudbongos.com/builders#/task/1003580) · goal 1000046, criterion `proj-invite-builders`). [ADR 0246](<redacted>.md) made a wizard-made project resolvable by `POST /projects/invite` and named the remainder: the route is gated `project.curate` (floor metic) with no own-scope arm, so the project’s own owner — a `xenos` on the hub — got 403 on their own project, blocking BV1.R27–R29 and the C6 proof. The arm admits a `project.curate` holder **OR** the owner, where owner means `provisioning_instances.owner_builder_id` — never the hub’s `owner_github_id` columns, which [ADR 0238](<redacted>.md) fixes as attribution. Three things worth carrying: the fact travels on a NEW port `provisioning.projectOwnedBy` answering a **boolean**, so the comparison stays on the plane that holds the row and no consumer handles an ownership id it could mis-compare; the port is asked about **both halves of the ADR 0246 projection** (slug AND origin), because a hand-seeded catalog row’s name is otherwise a squattable claim on that project’s membership directory; and ownership is asked BEFORE the target is resolved, so the refusal stays the untouched `requirePermission` 403 and cannot become an existence oracle over stealth projects ([ADR 0182](<redacted>.md) D2a). The atom stays spelled in the route’s own middleware list ([ADR 0210](<redacted>.md)’s rule), so the route still classifies `metic+archon`.](<redacted>.md) | onboarding / provisioning / project creation |
338
+
339
+ | 0249 | [**The invite step is the done panel’s first act, not a rail step — and the same widget is the way back** ([task 1002346](https://cloudbongos.com/builders#/task/1002346) · goal 1000046, criterion `proj-invite-builders` / C6). BV1.R27 asks for a skippable invite step *in creation*, but an invite cannot be one of the wizard’s numbered panels: `POST /projects/invite` takes `{ client_id, github_login }` where `client_id` **is the slug**, and per [ADR 0246](<redacted>.md) the catalog row that slug resolves is projected **at create** — so before the Create step there is nothing to invite anyone to, and a pre-Create panel could only queue names and replay them, inventing a second unaudited invite path whose failures land after the owner has left. **Decision: the step is the FIRST act of the DONE panel**, keyed on the created record (not `instanceId`, which `startProgress` sets only *after* `renderDone`), so it also returns on a resumed watch; the rail is NOT renumbered (a captured learning on this file: a renumbering couples in four places, and a step that can only run after the terminal step is not a step in the rail). The SAME widget is mounted again on the project’s own page beside *Its extras* — the [ADR 0245](<redacted>.md) argument, one implementation and two mounts, so the refusal vocabulary cannot drift. The widget answers neither “is anyone signed in” nor “is this their project”: each call site hands over a slug or nothing (the created record; the own-scoped manage read that 404s for everyone else), because a page that re-derived ownership would be a second, weaker copy of a server rule. **The 403 is quoted carefully and that is the point** — [ADR 0248](<redacted>.md) paid two equalised reads per request to make the refusal identical for a real, stealth, absent or malformed target ([ADR 0182](<redacted>.md) D2a), and “you lack permission on **this project**” would assert in copy the existence the route refuses to confirm, so the page says only “this account can’t invite people here” and a test pins that wording. `account_not_found` and `unknown_project` are said in the route’s own words (the latter as a moment, not a dead end — the row is projected at create); anything else falls through to the server’s own message. The pending line is drawn from the 201 row’s `membership_kind` and the typed login — **never the `github_id`** it carries, which names someone who has accepted nothing yet. Creation is never blocked: the project is filed before the step is shown, so every refusal leaves it created and the step re-tryable, and Skip sends nothing. Rejected: a rail step before or after Create; the manage page alone (R27 asks for it *in creation*); reading the real membership directory back (no own-scoped route exists, and the session receipt is honest about being one).](<redacted>.md) | provisioning / project creation |
337
340
 
338
341
  | 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 |
339
342
 
@@ -11162,7 +11162,7 @@
11162
11162
  "projects"
11163
11163
  ],
11164
11164
  "summary": "POST /projects/invite",
11165
- "description": "invite a builder into a project (ADR 0141 §5, task #1002115). Pre-seeds a 'pending' membership the invitee accepts from My Projects. V1 invites by github_login and requires the invitee to already have a platform account (they build on some project); inviting someone entirely new to the platform (GitHub-id resolution) is a follow-up.\n\n**Rank:** `metic+archon` — Metic or Archon rank (review/triage powers).\n\n**Permissions:** `project.curate` (all required).",
11165
+ "description": "own-or-metic in effect: invite a builder into a project (ADR 0141 §5, task #1002115), called by a `project.curate` holder OR the project's OWN OWNER (task 1003580; the ownership proof is `provisioning_instances.owner_builder_id`, reached through a port — see invite-authz.js). The wizard's audience is any signed-in GitHub user, typically a xenos who used to get 403 on their own project. A caller who is neither gets the unchanged permission refusal, identical whether or not the project exists. Pre-seeds a 'pending' membership the invitee accepts from My Projects. V1 invites by github_login and requires the invitee to already have a platform account (they build on some project); inviting someone entirely new to the platform (GitHub-id resolution) is a follow-up.\n\n**Rank:** `metic+archon` — Metic or Archon rank (review/triage powers).\n\n**Permissions:** `project.curate` (all required).",
11166
11166
  "x-rank": "metic+archon",
11167
11167
  "x-source": "modules/platform-identity/routes/projects.js",
11168
11168
  "x-permissions": [
@@ -457,7 +457,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
457
457
  | POST | `/api/bongos/projects` | `metic+archon` | `origin`, `name`, `tagline`, `description`, `art_url`, `type`, `template` | create / update a catalog entry (manual seed for hand-wired instances; federated projects auto-catalogue at registration). |
458
458
  | PATCH | `/api/bongos/projects/:id/featured` | `metic+archon` | `featured` | the feature / unfeature control (the ADR 0141 §5 curation gate), BOUNDED BY THE PUBLISH GATE (ADR 0182 D3, task 1002335). |
459
459
  | GET | `/api/bongos/projects/featured` | `public` | — | the public map of projects; no auth by design. |
460
- | POST | `/api/bongos/projects/invite` | `metic+archon` | `client_id`, `github_login` | invite a builder into a project (ADR 0141 §5, task #1002115). |
460
+ | POST | `/api/bongos/projects/invite` | `metic+archon` | `client_id`, `github_login` | own-or-metic in effect: invite a builder into a project (ADR 0141 §5, task #1002115), called by a `project.curate` holder OR the project'… |
461
461
 
462
462
  ## `provisioning` (21)
463
463