@bongos/core 1.19.563 → 1.19.564

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.563",
6
- "core_contract": "1.19.563",
7
- "source_commit": "c8dc8ef54a7fd97c2ba21c436f6c1faf0a125f0f",
5
+ "core_version": "1.19.564",
6
+ "core_contract": "1.19.564",
7
+ "source_commit": "d4d0c5049c5c72237a81450221af13702a00ccb7",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-06T17:53:15.785Z",
9
+ "built_at": "2026-09-06T18:43:07.289Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 444,
12
+ "docs_redacted": 445,
13
13
  "agent_docs_stubbed": 24,
14
14
  "functional_verbatim": 2029,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2497,
20
- "tree_sha256": "d705f290c86bc4240b5cd119cb250092b6b2d7af6d8ffc8e870dd1f0ae6cdef3",
19
+ "file_count": 2498,
20
+ "tree_sha256": "478ff6d025680dd25564647bac310bd570d0e58335d46a999cb9e4ed4d527aec",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -1774,10 +1774,15 @@
1774
1774
  "mode": "0000644",
1775
1775
  "sha256": "8ccfe21df7ab80ed872565bc879fb21a1b35ce81da55151035f2dc45cd40e749"
1776
1776
  },
1777
+ {
1778
+ "path": "docs/adr/0252-the-self-hosted-visibility-report-is-a-pull-and-auto-appear-buys-a-retraction-lever.md",
1779
+ "mode": "0000644",
1780
+ "sha256": "a36e7591da769ef46cb4f1c62d5b941278478dbb93e0a94c4f18e35a91866082"
1781
+ },
1777
1782
  {
1778
1783
  "path": "docs/adr/README.md",
1779
1784
  "mode": "0000644",
1780
- "sha256": "6d7f0b67401ca836ed08f5cea31ea578c10987718de48fecc0ac663528e9403d"
1785
+ "sha256": "f3fac1ec223ee6804f1b0a47734f030cc5f97226efc17f7a618cb55099d8e642"
1781
1786
  },
1782
1787
  {
1783
1788
  "path": "docs/api-reference.md",
@@ -2662,7 +2667,7 @@
2662
2667
  {
2663
2668
  "path": "docs/module-api-changelog.md",
2664
2669
  "mode": "0000644",
2665
- "sha256": "f13bd014e6a89aefd27dbfbaf639b7c3f3e8b90c3adb69cedfb6a0b11b0d3daf"
2670
+ "sha256": "726abfc14ae798baa3c7b55513db2ac74471ec78cc079efcf401b7ba409d25be"
2666
2671
  },
2667
2672
  {
2668
2673
  "path": "docs/modules-contract.md",
@@ -7492,12 +7497,12 @@
7492
7497
  {
7493
7498
  "path": "package-lock.json",
7494
7499
  "mode": "0000644",
7495
- "sha256": "feb9b3919e27637f41f78010f3a31d662da752e2c3ea56555f919fae02758cff"
7500
+ "sha256": "5c872f20ae1fd789c219dab32e15b3a7ca446d0451dc518709be7091f2d7e255"
7496
7501
  },
7497
7502
  {
7498
7503
  "path": "package.json",
7499
7504
  "mode": "0000644",
7500
- "sha256": "29c0dd946891112260ed3da3ce17b036afa8019a9ca8a89defc4c291b7638d2e"
7505
+ "sha256": "cd8cd65764759527985342aae0c228171fb47bba1164fd66a3282b1774c56aa8"
7501
7506
  },
7502
7507
  {
7503
7508
  "path": "public-docs/index.html",
@@ -9197,7 +9202,7 @@
9197
9202
  {
9198
9203
  "path": "src/module-api.js",
9199
9204
  "mode": "0000644",
9200
- "sha256": "51c39233a591b55aa092d440634e57ef51840118b5e7d9d93784b9a6de502b29"
9205
+ "sha256": "9cdb44aae69870c64cfb5ae2121af4b87bb09b2aeb77cb48827a029482b26862"
9201
9206
  },
9202
9207
  {
9203
9208
  "path": "src/module-loader/catalog.js",
@@ -0,0 +1,109 @@
1
+ # ADR 0252 — The self-hosted visibility report is a PULL, and auto-appear buys a retraction lever
2
+
3
+ - **Status:** accepted
4
+ - **Date:** 2026-09-06
5
+ - **Task:** [task 1002321](https://cloudbongos.com/builders#/task/1002321) (BONGOS-V1, goal 1000046 — *Project creation*, criterion `proj-visibility-states` / C1), BV1.R02 — a spike whose output is this decision
6
+ - **Gates:** [task 1002327](https://cloudbongos.com/builders#/task/1002327) (BV1.R08 — the build)
7
+ - **Deciders:** the Archon (the two product calls in §3 and §5), Claude (the mechanism)
8
+ - **Depends on:** [ADR 0182](<redacted>.md) D3/D5 (the two axes, and the hand-off that named this ADR's scope) · [ADR 0238](<redacted>.md) (the owner's door is a provisioning settings key; the hub column stays the cache) · [ADR 0246](<redacted>.md) (the control-plane projection, and the line this ADR sits on)
9
+ - **Related:** [ADR 0144](<redacted>.md) (the tripwire this route avoids) · [ADR 0145](<redacted>.md) (why `standalone` holds no credential) · [ADR 0205](<redacted>.md) (what a self-report may and may not buy) · [ADR 0047](<redacted>.md) (the straight-through path and its abuse guard are one task) · [ADR 0199](<redacted>.md) / [ADR 0226](<redacted>.md) (a degraded report is not a state change)
10
+
11
+ ---
12
+
13
+ ## 1. The problem, and a premise that turned out to be false
14
+
15
+ BV1.R02 was written to design "how a self-hosted instance reports its visibility to the hub, and how far the hub trusts it," and to settle the **auto-appear vs. Archon-curate** question the planning interview deferred.
16
+
17
+ Reading the tree first changed the question. **A self-hosted instance has no channel to the hub at all, and no credential with which to open one.** `registerClient` has exactly one caller in the repository — `scripts/gds/provision-net.js:84`, inside the operator's own control-plane `federateInstance` — and that call is gated:
18
+
19
+ ```js
20
+ if (inst.hosting_shape !== 'co-tenant') return { federate: false, reason: … };
21
+ ```
22
+
23
+ Every writer of `sso_clients` is control-plane; no route registers a client. So *federated* ⊆ *operator-provisioned co-tenant*, and *self-hosted* is a disjoint set holding nothing. That is deliberate on two counts: [ADR 0145](<redacted>.md) says in as many words **do not** extend federation to `standalone` as it exists today, and [ADR 0144](<redacted>.md)'s Known Limitation rests on it — *"today every secret-holder is an operator-provisioned, trusted instance … the proper fix must land before federation opens to untrusted third-party clients."*
24
+
25
+ So the literal reading of R08 — "a self-hosted instance authenticates and pushes a report" — cannot be built without first minting credentials for untrusted third parties, which walks straight into that tripwire. [ADR 0182](<redacted>.md) D5's phrasing ("accepts a report from the authenticated `sso_client`") described a client that, for this population, does not exist.
26
+
27
+ ## 2. The channel is a PULL of the instance's own public manifest
28
+
29
+ **Decision.** The hub does not receive a pushed report. It **reads the instance's already-public declaration**: `GET <origin>/api/gds/instance` → `branding.project.visibility`.
30
+
31
+ That value is already there and already public. The owner's pick lives at `provisioning_instances.settings.visibility`, is handed to the instance as `<PREFIX>_VISIBILITY`, and the instance re-serves it on a CORS-open route (`src/branding.js`, `src/bongos/routes/instance.js`). The comment that put it there already anticipated this consumer: *"which is what lets a later task enforce the door there and report the value to the hub catalog (R08)."*
32
+
33
+ Five things a pull gets for free, each of which a push would have had to build:
34
+
35
+ 1. **No credential.** Nothing is minted for anyone, so ADR 0144's tripwire is untouched and ADR 0145's rule holds. This is the whole reason the decision goes this way.
36
+ 2. **No replay surface.** ADR 0182 D5 handed R02 "replay, rate" as its scope. A pull reads *current state*, so there is no message to capture and resend — the `jti`-style machinery [ADR 0144](<redacted>.md) D6 needed for the push direction is moot here. Replay is answered by not having a message.
37
+ 3. **No wire change on anyone else's upgrade clock.** [ADR 0205](<redacted>.md) rejected adding a required body field precisely because "the callers are other instances on their own upgrade schedule," and [ADR 0201](<redacted>.md) D2.2 solved the same problem with a header. Here the field already ships in every core; nothing has to be added at all.
38
+ 4. **The hub still authorizes nothing.** It reads a declaration and caches it, which is exactly what [ADR 0182](<redacted>.md) D5 requires and what [ADR 0238](<redacted>.md) rejected a `PATCH /projects/:id/visibility` for failing.
39
+ 5. **The fetch is already written.** `modules/platform-identity/project-stats.js` fans out to project origins today behind a real SSRF guard — DNS-resolve-all with a public-address assertion, `redirect: 'error'` (a curated origin is trusted, a redirect target is not), per-request timeouts, a TTL cache, a negative cache and inflight collapsing. The visibility read rides that machinery rather than opening a second, weaker outbound path.
40
+
41
+ **The pull carries `visibility` and nothing else.** It is a three-value closed enum; an absent or off-vocabulary answer yields *no verdict* (§4), never a default. No free text, no name, no tagline, no art reaches the catalog through this channel — those stay curated fields, so the property the map has today (a project owner controls no free-text field that reaches the feed) is not spent here.
42
+
43
+ ## 3. Auto-appear: no human stands between a public project and the map
44
+
45
+ **Decision (the Archon's).** A self-hosted project reporting `public` appears on the map with **no human act**. `featured` keeps the only job [ADR 0182](<redacted>.md) D3 left it — ranking among rows already there — and curation stays editorial.
46
+
47
+ The alternative (a one-time admission, or ongoing curation) was offered and declined. What was declined with it is the friction ADR 0182 D3 had already removed once: an owner setting their project public and seeing nothing happen until someone else acted.
48
+
49
+ **This decision is conditional, and the condition is §5.** Auto-appear is only defensible because retraction ships beside it; [ADR 0047](<redacted>.md) is explicit that the straight-through path and the abuse guard are the *same* task, and this ADR treats that as binding rather than advisory.
50
+
51
+ ## 4. Trust follows provenance — the asymmetry is in the guards, not in a gate
52
+
53
+ **Decision (the Archon's).** Hosted and self-hosted projects are **not** held to the same bar. Both auto-appear; what differs is what each is trusted to *say*, because the evidence behind each differs:
54
+
55
+ | | control-plane / co-tenant | self-hosted |
56
+ |---|---|---|
57
+ | how `visibility` arrives | **assigned** by the bridge from `provisioning_instances.settings` | **pulled** from a manifest the hub does not control |
58
+ | evidence | an own-scoped, vocab-validated, audited route the operator's own plane ran | someone pointed a domain at the hub |
59
+ | status of the value | authoritative | a **cached claim, with a recency stamp** |
60
+ | may move | its own row | its own row, and only its own row |
61
+
62
+ Stated once, so it is not re-litigated: **an instance is authoritative about its own door and about nothing else.** That is [ADR 0016](<redacted>.md) and [ADR 0141](<redacted>.md)'s line held in a new place — identity and self-description federate, authority does not.
63
+
64
+ ### Absent is a third state, and it must not purge
65
+
66
+ [ADR 0182](<redacted>.md) D5 says the absent-report default is fail-closed: unreported ⇒ dark matter. That stands, **scoped to a self-hosted row that has never once answered** — such a project is off the map rather than exposed by omission.
67
+
68
+ It must never be evaluated against control-plane rows, and it must never be read as "answered before, silent now." [ADR 0247](<redacted>.md) D4 found the live shape of this trap: at the time of writing, *every* catalog row carried `description: null`, so a naive fail-closed enforcement wired that day would have refused every join on the platform at once. The same hazard applies here.
69
+
70
+ So the tri-state is explicit — `null` (never read) ≠ `false` (read, and negative) ≠ `true`:
71
+
72
+ - **never successfully read** → dark matter (D5's rule, doing its job)
73
+ - **read before, unreachable now** → **keep the last known state, stamped with its recency.** A project does not leave the map because its server was down for a sweep. [ADR 0199](<redacted>.md): liveness is an overlay, never a lifecycle state. [ADR 0226](<redacted>.md): reporting may degrade where enforcement must refuse — and this is reporting.
74
+ - **read, and narrower than before** → applied immediately and unconditionally. A report that *reduces* exposure needs no trust at all: the worst a lying instance achieves is hiding itself.
75
+
76
+ That last line is the shape of the whole trust model. **Narrowing is free; widening is what costs.**
77
+
78
+ ## 5. Auto-appear buys a retraction lever — and `hidden` is sticky
79
+
80
+ `platform_identity_projects.status` is `('active','hidden')` and the public feed requires `active`. **No route writes it.** The only `status` write in the module is on `sso_clients`. Today, removing a project from the map is hand-written SQL on production.
81
+
82
+ Under control-plane provisioning that was survivable, because the set of rows was bounded by who the operator had provisioned. Auto-appear replaces that bound with *whoever points a domain at us*, and a one-way door onto a public surface is not a door. So R08 ships, in the same task:
83
+
84
+ 1. **`PATCH /projects/:id/status`** — `active|hidden`, `project.curate` floor (metic, per [ADR 0157](<redacted>.md)), audited.
85
+ 2. **`hidden` is STICKY against the pull.** A retracted project that keeps reporting `public` **stays hidden**. This is the single most important line in this ADR: without it the next sweep silently undoes every takedown, and the lever is decorative. Retraction is a hub decision about the hub's own surface, and the pull may not overwrite it — the pull moves `visibility`, never `status`. The two columns answer different questions and this is where that separation earns its keep.
86
+ 3. **A per-IP read limit on the public feed** (the [ADR 0209](<redacted>.md) precedent). `GET /projects/featured` is unauthenticated, CORS-open and currently unmetered, and each cold sweep can drive up to 500 rows × 4 outbound fetches.
87
+
88
+ ### One gap this decision widens, named rather than left
89
+
90
+ `hall_origin` reaches the public feed from the remote instance's own discovery index, and the hub relays it into an `<a href>`. Its only check is that it *differs* from the project's site host — the opposite of a containment rule. Under curated provenance that was survivable; under auto-appear it is a phishing hop anyone can mint.
91
+
92
+ The corpus already has the right rule and applies it one field over: `shipUrlWithinClientOrigin` requires a pushed ship link to point **at or below** the reporting instance, and explains why the mirror rule is not symmetric (on a shared apex, `foo.github.io` must not publish links on bare `github.io`). **`hall_origin` gets the same downward-only containment**, filed as its own task against R08.
93
+
94
+ ## 6. Consequences
95
+
96
+ - **A self-hosted project can put itself on the public map, and that is now a supported, un-gated path.** The hub will also, once the SEO work lands, serve it an indexable `cloudbongos.com/<slug>` page — so auto-appear is also auto-issue of a page on the hub's own domain. Accepted knowingly; §5 is the counterweight.
97
+ - **The hub reaches out to more origins.** The fan-out ceiling is unchanged (500 rows, TTL + negative caches), but the *population* is no longer bounded by provisioning. The feed read limit exists for this.
98
+ - **Two populations, one column, different provenance** — readers of `platform_identity_projects.visibility` cannot assume how it got there. The recency stamp is what tells them.
99
+ - **A self-hosted instance that never upgrades never appears.** It already serves `branding.project.visibility`, so in practice this is only the very old.
100
+ - **Nothing here federates a self-hosted instance.** It holds no credential, mints no assertion, writes no membership, and reports no analytics. The later analytics feed R02 was asked to anticipate is deliberately *not* decided here: it carries per-person data, which is exactly what [ADR 0205](<redacted>.md) proved cannot ride an unwitnessed self-report. It needs the ADR 0144 credential work first, and it is a separate decision.
101
+
102
+ ## 7. Rejected
103
+
104
+ - **A pushed report authenticated by a client credential.** The literal reading of R08. Rejected: self-hosted instances hold no credential, and minting them is the exact move [ADR 0144](<redacted>.md) and [ADR 0145](<redacted>.md) say must not happen yet. It would also have owed replay protection, a rate budget and a wire-compatible field — three problems the pull does not have.
105
+ - **`PATCH /projects/:id/visibility` on the hub.** Already rejected by [ADR 0238](<redacted>.md) for making the hub authorize a door change; restated here because a pull could be mistaken for a licence to add one.
106
+ - **A one-time admission, and ongoing curation.** Both offered to the Archon; both declined in favour of auto-appear (§3).
107
+ - **Trusting the pull to move `status`.** The retraction lever would be decorative (§5.2).
108
+ - **Enforcing "unreported ⇒ dark matter" across all rows.** [ADR 0247](<redacted>.md) D4's live finding: it empties the map (§4).
109
+ - **Letting the pull carry name, tagline or art.** Today no project owner controls a free-text field that reaches the feed. That is a property worth keeping, and a visibility report is not the place to spend it.
@@ -339,6 +339,7 @@ This keeps the decision history honest and traceable.
339
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 |
340
340
  | 0250 | [**Strict versioning — the version boundary is the scope gate** ([task 1003589](https://cloudbongos.com/builders#/task/1003589) · goal 1000086). The work hierarchy ([ADR 0086](<redacted>.md)) has a bottom and no top: tasks close, criteria close, goals close, and a version never does. Measured live — **14 goals reading `achieved`/`archived` while holding 35 unfinished tasks**, 523 open tasks across 37 open goals, and **two versions `building` on one project**; every number was worse than the planning session had recorded two days earlier, which is the argument: the drift is the steady state, not a backlog. The cause is four mechanical gaps, not a social one — `POST /goals` fetches the version row and never reads its `status`; goal achievement is computed from criteria alone and never consults `tasks` (both the `db-goals.js` writer and the `done-when.js` cascade); `POST /goals/:id/archive` checks nothing at all; and no route ships a version, so version status has only ever moved as hand-run SQL on the droplet. **Decision, five rules:** the goal set closes when a version starts `building` (new goals go on the `planning` version — where the next version is scoped in advance) with a named 409 an agent can act on; a goal reaches `achieved` only when its criteria are satisfied **and** no task in it is non-terminal, where terminal is `shipped`|`abandoned` per [task 1003588](https://cloudbongos.com/builders#/task/1003588)'s one vocabulary and the mid-ship `completed`/`confirmed` hold it open; archiving a goal that holds open tasks refuses until the caller dispositions each (abandon with the reason, or move to a named surviving goal); `goal_id` becomes required and the per-version catch-all fallback is deleted; and exactly one version builds per project, auto-closing when its last non-maintenance goal achieves, with the single `planning` version promoting itself so the project is never without a live train. **The escape hatches are deliberate and visible**: an Archon may admit one *named* goal to a building version with a reason, and the overrides are counted on the version (the standard failure of a hard scope gate is a fake hotfix version cut to hold what did not fit — an untraced override is just a slower version of no rule); plus one auto-created maintenance goal per version, exempt from the auto-close count, so bugs never need an override to be filed. **Nothing here can refuse a ship, a claim, or a task** — every rule acts on a *closing* move. Rejected: counting `completed`/`confirmed` as finished (they can fail a grade or strand — the goals **board** buckets them as done, but that is a progress ring, and `task-status.js` keeps the two questions apart); a nightly reconciler that reopens wrongly-closed goals (detection is what you build when prevention is unavailable, and it is available here); a human review gate before a goal may close (the queue [ADR 0183](<redacted>.md) dismantled — the check is a fact the table already holds); concurrent building versions scoped by module (two live trains means every "what is in this version" question has two answers); retiring versions entirely (the version is the unit the owner sets scope in and `limitations/<version>-shipped.md` archives). **Amends [ADR 0157](<redacted>.md) for exactly one key:** `version.create` returns to the Archon floor. 0157's "authoring delegates, disposition does not" was right about what a version *was* — but once the boundary carries the scope gate, cutting a new version is no longer authoring, it is the way *around* the gate, so create joins close as a disposition over scope.](<redacted>.md) | lifecycle / versioning / scope |
341
341
  | 0251 | [**A suggestion of who to invite is a recruiting surface, and owes the recruiting opt-out** ([task 1002347](https://cloudbongos.com/builders#/task/1002347) · goal 1000046, criterion `proj-invite-builders` / C6). BV1.R28 fills the seam [ADR 0239](<redacted>.md) §6 left open: the wizard’s pure rule engine emits a **seek** (`solo` → `null`, `small-team` → 3, `community` → 5) and never a list, because ranking builders is a directory read a pure file may not run and `provisioning` may not import a sibling. Two readers could answer “who are the top builders” and they differ by **one SQL predicate**: `economy`’s `leaderboard()` joins `rollupVisibleSql` and deliberately honours **no** recruiting opt-out, while `platform-identity`’s `listScoutingRollup()` joins `scoutingListableSql`, which carries it. The leaderboard is cheaper in every mechanical sense — already a public route, no port, callable straight from the browser. **Decision: use the recruiting directory anyway**, because `account-visibility.js` says the split in its own words (`hide_stats` suppresses *numbers* everywhere; the recruiting opt-out “removes an account from the RECRUITING SURFACE only”, community spec D3), and a list of people the platform proposes you recruit **is** that surface — sourcing it from the leaderboard would route around the one control built for this exact case and name every builder who asked not to be recruited. The rule for later readers: **the cheapness of a public read is not an argument about who consented to appear in it.** The lookup ships as `listInviteSuggestions` on the existing `platform-identity.hub` port, reached with `resolveOptional` and NOT declared in `consumes` (the `catalog-bridge.js` precedent — platform-identity is hub-only, so its absence is “no suggestions”, never a 500); `github_id` **does not cross the port** (explicit whitelist — login, display name, avatar, disciplines, two counts — so a new rollup column cannot leak by default), and the owner-exclusion is applied inside, over-read by the exclusion count so filtering never returns short. The route `GET /provisioning/instances/:id/invite-suggestions` is own-scoped by the **existing** `instanceAccessDenied` (same 404 as the manage read) and deliberately does NOT reuse `/scouting`’s gate ([ADR 0210](<redacted>.md)): a brand-new owner has no hub-witnessed owner row at the moment the done panel paints, so the correct-looking gate would refuse the exact caller the feature exists for — and reaching the route IS the ownership answer ([ADR 0249](<redacted>.md) §4). On the surface: a **second door in** (`setInviteSuggestions`) beside `setInviteSlug`, so the widget still answers neither “signed in” nor “theirs”; the reason is **always** said including when the answer is nobody (`seeking: null` costs no query — rendering a stated answer as an empty list would make it look like a failed lookup); a picked name fills the field and takes the **same write path** as a typed one, so the refusal vocabulary cannot fork; a filed invite stops being a suggestion; and every failure is silent, because a suggestion is an extra and a just-created project must not look broken over one. Rejected: the browser reading the economy leaderboard (the whole decision — consent, not architecture); reusing `/scouting` (right data, gate that refuses this caller); filling `invites.suggested` inside the engine (I/O in a pure file + a sibling import); a `provisioning → economy` port (legal, still the wrong directory — the boundary was never the objection).](<redacted>.md) | provisioning / platform-identity / privacy |
342
+ | 0252 | [**The self-hosted visibility report is a PULL, and auto-appear buys a retraction lever** ([task 1002321](https://cloudbongos.com/builders#/task/1002321) · goal 1000046, criterion `proj-visibility-states` / C1, BV1.R02 — a spike whose output is this decision; gates [task 1002327](https://cloudbongos.com/builders#/task/1002327) / R08). **The premise failed first:** a self-hosted instance has no channel to the hub and no credential to open one — `registerClient` has exactly one caller in the tree (the operator's own `provision-net.js`), gated `hosting_shape !== 'co-tenant'`, and no route registers a client. So *federated* ⊆ *operator-provisioned co-tenant* and *self-hosted* holds nothing, deliberately: [ADR 0145](<redacted>.md) says do NOT extend federation to `standalone`, and [ADR 0144](<redacted>.md)'s Known Limitation rests on "every secret-holder is an operator-provisioned, trusted instance … the proper fix must land before federation opens to untrusted third-party clients." [ADR 0182](<redacted>.md) D5's "accepts a report from the authenticated `sso_client`" described a client that, for this population, does not exist. **Decision 1 — the channel is a PULL of the instance's already-public manifest** (`GET <origin>/api/gds/instance` → `branding.project.visibility`, a value every core already serves and whose author anticipated this consumer). It buys five things a push must build: no credential (0144's tripwire untouched — the whole reason), **no replay surface** (a pull reads current state, so the `jti` machinery D5 handed R02 is moot — replay is answered by not having a message), no wire change on another instance's upgrade clock ([ADR 0205](<redacted>.md)'s and [ADR 0201](<redacted>.md) D2.2's rejected-required-field problem simply absent), the hub still authorising nothing (0182 D5 satisfied, [ADR 0238](<redacted>.md)'s rejected `PATCH /projects/:id/visibility` still rejected), and a fetch that already exists behind a real SSRF guard (`project-stats.js` — DNS-resolve-all, `redirect: 'error'`, TTL + negative caches). The pull carries `visibility` and NOTHING else, so the property the map has today — a project owner controls no free-text field that reaches the feed — is not spent. **Decision 2 (the Archon's) — auto-appear:** no human stands between a public project and the map; `featured` keeps only the ranking job [ADR 0182](<redacted>.md) D3 left it. Conditional on Decision 4. **Decision 3 (the Archon's) — trust follows provenance:** both populations auto-appear, and the asymmetry is in the GUARDS, not a gate — a control-plane row's `visibility` is *assigned* by the bridge from an own-scoped audited route (authoritative), a self-hosted row's is *pulled* from a manifest the hub does not control (a cached claim with a recency stamp, able to move its own row and nothing else). An instance is authoritative about its own door and about nothing else. **Absent is a THIRD state and must not purge:** `null` (never read) ≠ `false` (read, negative). 0182 D5's fail-closed default holds only for a self-hosted row that has NEVER answered; a row that answered before and is unreachable now keeps its last state with a recency stamp ([ADR 0199](<redacted>.md), [ADR 0226](<redacted>.md)), because [ADR 0247](<redacted>.md) D4 found the live shape of that trap — every catalog row at `description: null`, so naive fail-closed would have emptied the map at once. Narrowing is free; widening is what costs. **Decision 4 — auto-appear buys a retraction lever, in the same task** ([ADR 0047](<redacted>.md): the straight-through path and its abuse guard are one task, treated as binding): `platform_identity_projects.status` is `(active,hidden)` and **no route writes it** — takedown is hand SQL today, and a one-way door onto a public surface is not a door. R08 ships `PATCH /projects/:id/status` (curate floor, audited), a per-IP limit on the unmetered CORS-open feed read ([ADR 0209](<redacted>.md)), and above all **`hidden` STICKY against the pull** — a retracted project that keeps reporting `public` stays hidden, or the next sweep silently undoes every takedown and the lever is decorative. Also named: `hall_origin` reaches the feed from the remote's own discovery index checked only for *differing* from the project host — the opposite of containment — and gets `shipUrlWithinClientOrigin`'s downward-only rule, filed against R08. Rejected: a pushed credentialed report (the literal R08 — needs the credentials 0144/0145 forbid, plus replay, rate and a wire-compatible field, none of which the pull has); `PATCH /projects/:id/visibility` (0238 already); a one-time admission and ongoing curation (both offered to the Archon, both declined); trusting the pull to move `status`; enforcing "unreported ⇒ dark matter" across all rows; letting the pull carry name/tagline/art. The later ANALYTICS feed is deliberately NOT decided here — it carries per-person data, exactly what [ADR 0205](<redacted>.md) proved cannot ride an unwitnessed self-report, and it needs the 0144 credential work first.](<redacted>.md) | platform-identity / provisioning / federation trust |
342
343
 
343
344
  | 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 |
344
345
 
@@ -1575,5 +1575,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
1575
1575
  landed since 1.19.561 with no explicit bump. run 34048961786. (task 1002620)
1576
1576
  1.19.563 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1577
1577
  landed since 1.19.562 with no explicit bump. run 34049959536. (task 1002620)
1578
+ 1.19.564 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1579
+ landed since 1.19.563 with no explicit bump. run 34052578659. (task 1002620)
1578
1580
  ---------------------------------------------------------------------------
1579
1581
  ```
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.563",
3
+ "version": "1.19.564",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.563",
9
+ "version": "1.19.564",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.563",
3
+ "version": "1.19.564",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
package/src/module-api.js CHANGED
@@ -49,7 +49,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
49
49
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
50
50
  // the entry to that file. Look for a version's history there, not here.
51
51
  // ---------------------------------------------------------------------------
52
- const CORE_VERSION = '1.19.563'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
52
+ const CORE_VERSION = '1.19.564'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
53
53
 
54
54
  // A namespaced logger so a module's log lines are attributable + consistent.
55
55
  // Usage: const log = api.logger('dev-box'); log.info('mounted');