@bongos/core 1.19.563 → 1.19.565

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 (30) hide show
  1. package/.bongos-core.json +65 -30
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +4 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0252-the-self-hosted-visibility-report-is-a-pull-and-auto-appear-buys-a-retraction-lever.md +109 -0
  8. package/docs/adr/0253-the-takedown-lever-has-two-writers-and-provenance-is-a-column.md +144 -0
  9. package/docs/adr/README.md +2 -0
  10. package/docs/api/openapi.json +91 -4
  11. package/docs/api-reference.md +3 -2
  12. package/docs/module-api-changelog.md +4 -0
  13. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  14. package/modules/government/catalog.js +1 -1
  15. package/modules/platform-identity/migrations/platform_identity_020_visibility_pull.sql +106 -0
  16. package/modules/platform-identity/module.json +1 -1
  17. package/modules/platform-identity/pollers/visibility-pull.js +99 -0
  18. package/modules/platform-identity/project-catalog.js +62 -4
  19. package/modules/platform-identity/project-stats.js +31 -0
  20. package/modules/platform-identity/projects-feed.js +17 -1
  21. package/modules/platform-identity/routes/projects.js +48 -1
  22. package/modules/platform-identity/visibility-pull.js +201 -0
  23. package/package-lock.json +2 -2
  24. package/package.json +1 -1
  25. package/src/bongos/middleware/rate-limit.js +44 -2
  26. package/src/module-api.js +12 -1
  27. package/tests/module_api.mjs +1 -0
  28. package/tests/project_takedown_lever.mjs +177 -0
  29. package/tests/visibility_pull.mjs +213 -0
  30. package/tests/visible_projects_feed.mjs +12 -1
@@ -339,6 +339,8 @@ 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 |
343
+ | 0253 | [**The takedown lever has two writers, and provenance has to be a column** ([task 1002327](https://cloudbongos.com/builders#/task/1002327) · goal 1000046, criterion `proj-visibility-states` / C1, BV1.R08 — the build). Amends [ADR 0252](<redacted>.md) §4/§5: three premises that did not survive contact with the tree. The decisions stand; what changes is what has to be BUILT. **(1) `hidden` is sticky against TWO writers, and 0252 names the wrong one.** 0252 §5 says "no route writes [`status`]" and defends the takedown against the PULL. But `project-catalog.js:upsertOwnedProject` writes `status = EXCLUDED.status` on every conflict, sourced from `catalog-bridge.js:catalogStatusForInstance` — which returns `active` for any instance not torn down — and [ADR 0246](<redacted>.md) re-takes that projection at EVERY provisioning write. So the live failure is: curator hides a project → its owner saves any setting → it is back on the map. Stickiness therefore belongs to the ROW (`status_pinned`, set by the curated PATCH, honoured by the projection), not to one writer's manners; the pull separately writes no `status` at all, asserted against the statement text. `retireOwnedProject` deliberately does NOT honour the pin — it is a safety retraction for an origin the project no longer answers on ([ADR 0181](0181-teardown-state-gate-and-address-retention.md) §2), and narrowing is always free. **(2) Provenance must be a stored column.** 0252 §4 tabulates control-plane vs self-hosted rows as though the catalog knew which was which; it has no `hosting_shape` (that lives on `provisioning_instances`, in a module platform-identity may not import — [ADR 0083](<redacted>.md)) and `owner_github_id` separates nothing (set by the bridge, set by `registerClient`, null on hand-seeded rows). `visibility_source` (`control-plane`|`pull`|NULL) records it on the row, re-asserted in the UPDATE's own WHERE so a mid-sweep projection cannot lose the race — making "the two never write the same row" an invariant the SQL enforces. Rejected a `provisioning.instanceShape` port: a cross-module read in a background loop, for a value that never changes per row. **(3) The tri-state cannot live in `visibility`** (`NOT NULL DEFAULT 'public'`), so the recency stamp IS the null-carrier: `visibility_reported_at IS NULL` is the never-read state, and a failed read writes nothing — which is what makes "keep the last known state" fall out rather than be implemented. The fail-closed feed predicate needed a BACKFILL for [ADR 0247](<redacted>.md) D4's exact reason: both columns are new, so unbackfilled it would have emptied the public map on deploy. Migration 020 stamps every existing row `control-plane`, which is safe because it is TRUE — no instance has ever been asked. Two smaller corrections: the manifest key is **`brand`**, not `branding` (0252 §2 and the task body both misname it; reading only the documented spelling makes every pull a silent no-verdict), and the pull is a **POLLER, not a passenger on the feed's fan-out** — the feed excludes exactly the rows that need pulling (a never-answered row is dark matter, so it is never enriched, so it never answers), stealth rows are excluded from enrichment by design, and a read-path pull would make a DB write a side effect of an anonymous CORS-open read. Honest limit recorded: 0247 D4's `description: null` problem is NOT fixed here — 0252 §7 refuses to let the pull carry name/tagline/description, so the publish gate stays unmet for self-hosted rows. The pull moves the door; it does not publish the project.](<redacted>.md) | platform-identity / provisioning / abuse guard |
342
344
 
343
345
  | 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
346
 
@@ -11127,7 +11127,7 @@
11127
11127
  "projects"
11128
11128
  ],
11129
11129
  "summary": "GET /projects/featured",
11130
- "description": "the public map of projects; no auth by design.\n\n**Rank:** `public` — No authentication — any caller.",
11130
+ "description": "the public map of projects; no auth by design. THE PER-IP CEILING IS PART OF THE ROUTE, not a decoration on it (ADR 0252 §5.3). This GET is unauthenticated and CORS-open, and it was unmetered by construction: the two global limiters return early for reads, and the /public/* read limiter is mounted on a prefix this route does not sit under. It is also the platform's most expensive anonymous read — a cold cache turns one request into as many as 500 rows × 4 outbound fetches at OTHER PEOPLE's servers, so leaving it open aims our fan-out, not just our database. Auto-appear removed the old bound (the set of projects the operator had provisioned) and replaced it with \"whoever points a domain at us\", which is what makes the ceiling load-bearing rather than prudent. It runs BEFORE the handler, so a refused read costs no query and no fetch.\n\n**Rank:** `public` — No authentication — any caller.",
11131
11131
  "x-rank": "public",
11132
11132
  "x-source": "modules/platform-identity/routes/projects.js",
11133
11133
  "responses": {
@@ -11259,6 +11259,72 @@
11259
11259
  ]
11260
11260
  }
11261
11261
  },
11262
+ "/projects/{id}/status": {
11263
+ "patch": {
11264
+ "operationId": "patch_projects_id_status",
11265
+ "tags": [
11266
+ "projects"
11267
+ ],
11268
+ "summary": "PATCH /projects/:id/status",
11269
+ "description": "THE TAKEDOWN LEVER (R08, task 1002327; ADR 0252 §5.1). Until this route, `platform_identity_projects.status` had no writer on the API at all: the column shipped in migration 004, the public feed requires 'active', and removing a project from the map meant hand-written SQL against production. IT SHIPS IN THE SAME TASK AS AUTO-APPEAR, NOT AFTER IT. ADR 0252 §3 lets a self-hosted project put itself on the public map with no human act, and the Archon took that decision explicitly conditional on retraction landing beside it — ADR 0047's rule that a straight-through path and its abuse guard are one piece of work. A one-way door onto a public surface is not a door. Setting 'hidden' PINS the row (project-catalog.js), which is what makes the retraction survive: the ADR 0246 projection re-takes `status` on every provisioning write, so without the pin an owner's next settings save would quietly undo the takedown. PATCHing back to 'active' releases the pin and returns the row to automatic management, so un-hiding is exactly as deliberate an act as hiding.\n\n**Rank:** `metic+archon` — Metic or Archon rank (review/triage powers).\n\n**Permissions:** `project.curate` (all required).",
11270
+ "x-rank": "metic+archon",
11271
+ "x-source": "modules/platform-identity/routes/projects.js",
11272
+ "x-permissions": [
11273
+ "project.curate"
11274
+ ],
11275
+ "parameters": [
11276
+ {
11277
+ "name": "id",
11278
+ "in": "path",
11279
+ "required": true,
11280
+ "schema": {
11281
+ "type": "string"
11282
+ },
11283
+ "description": "Path parameter `id`."
11284
+ }
11285
+ ],
11286
+ "requestBody": {
11287
+ "required": true,
11288
+ "content": {
11289
+ "application/json": {
11290
+ "schema": {
11291
+ "$ref": "#/components/schemas/PatchProjectsIdStatusRequest"
11292
+ }
11293
+ }
11294
+ },
11295
+ "x-validated": true
11296
+ },
11297
+ "responses": {
11298
+ "200": {
11299
+ "description": "Success.",
11300
+ "content": {
11301
+ "application/json": {
11302
+ "schema": {
11303
+ "$ref": "#/components/schemas/PatchProjectsIdStatusResponse"
11304
+ }
11305
+ }
11306
+ }
11307
+ },
11308
+ "400": {
11309
+ "$ref": "#/components/responses/ValidationFailed"
11310
+ },
11311
+ "401": {
11312
+ "$ref": "#/components/responses/Unauthorized"
11313
+ },
11314
+ "403": {
11315
+ "$ref": "#/components/responses/Forbidden"
11316
+ },
11317
+ "404": {
11318
+ "$ref": "#/components/responses/NotFound"
11319
+ }
11320
+ },
11321
+ "security": [
11322
+ {
11323
+ "builderSession": []
11324
+ }
11325
+ ]
11326
+ }
11327
+ },
11262
11328
  "/provisioning/fleet": {
11263
11329
  "get": {
11264
11330
  "operationId": "get_provisioning_fleet",
@@ -18530,6 +18596,27 @@
18530
18596
  "project"
18531
18597
  ]
18532
18598
  },
18599
+ "PatchProjectsIdStatusRequest": {
18600
+ "type": "object",
18601
+ "properties": {
18602
+ "status": {
18603
+ "type": "string"
18604
+ }
18605
+ },
18606
+ "required": [
18607
+ "status"
18608
+ ],
18609
+ "additionalProperties": false
18610
+ },
18611
+ "PatchProjectsIdStatusResponse": {
18612
+ "type": "object",
18613
+ "properties": {
18614
+ "project": {}
18615
+ },
18616
+ "required": [
18617
+ "project"
18618
+ ]
18619
+ },
18533
18620
  "PatchProvisioningInstancesIdDetailRequest": {
18534
18621
  "type": "object",
18535
18622
  "properties": {
@@ -22505,9 +22592,9 @@
22505
22592
  "description": "A required dependency/feature is not configured or is temporarily down."
22506
22593
  }
22507
22594
  },
22508
- "x-endpoint-count": 357,
22509
- "x-schema-count": 391,
22595
+ "x-endpoint-count": 358,
22596
+ "x-schema-count": 393,
22510
22597
  "x-undocumented-bodies": 9,
22511
- "x-response-schemas": 265,
22598
+ "x-response-schemas": 266,
22512
22599
  "x-generated-by": "scripts/gds/gen-api-docs.js"
22513
22600
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Bongos API reference
4
4
 
5
- > **Generated from the live route files** — the route file is authoritative. 357 endpoints across 58 route files.
5
+ > **Generated from the live route files** — the route file is authoritative. 358 endpoints across 58 route files.
6
6
  > Machine-readable spec: [`docs/api/openapi.json`](api/openapi.json) (OpenAPI 3.1). Rendered docs site: **`/docs`** (e.g. `cloudbongos.com/docs`).
7
7
 
8
8
  Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>.md)): `public` < `any-builder` < `metic+archon` < `archon`.
@@ -449,13 +449,14 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
449
449
  | GET | `/api/bongos/project-settings` | `metic+archon` | — | GET /project-settings — all five rows in ONE read. |
450
450
  | PATCH | `/api/bongos/project-settings/rot-days` | `metic+archon` | `days` | PATCH /project-settings/rot-days — body { days }. |
451
451
 
452
- ## `projects` (5)
452
+ ## `projects` (6)
453
453
 
454
454
  | Method | Path | Rank | Body | Description |
455
455
  |---|---|---|---|---|
456
456
  | GET | `/api/bongos/projects` | `metic+archon` | — | the full curation list (includes unfeatured + owner attribution). |
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
+ | PATCH | `/api/bongos/projects/:id/status` | `metic+archon` | `status` | THE TAKEDOWN LEVER (R08, task 1002327; ADR 0252 §5.1). |
459
460
  | GET | `/api/bongos/projects/featured` | `public` | — | the public map of projects; no auth by design. |
460
461
  | 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
462
 
@@ -1575,5 +1575,9 @@ 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)
1580
+ 1.19.565 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1581
+ landed since 1.19.564 with no explicit bump. run 34055981223. (task 1002620)
1578
1582
  ---------------------------------------------------------------------------
1579
1583
  ```
@@ -646,6 +646,8 @@ function createClient(opts = {}) {
646
646
  postProjects: (args) => request("POST", "/projects", { hasBody: true }, args),
647
647
  // PATCH /projects/{id}/featured — rank: metic+archon — PATCH /projects/:id/featured
648
648
  patchProjectsIdFeatured: (args) => request("PATCH", "/projects/{id}/featured", { hasBody: true }, args),
649
+ // PATCH /projects/{id}/status — rank: metic+archon — PATCH /projects/:id/status
650
+ patchProjectsIdStatus: (args) => request("PATCH", "/projects/{id}/status", { hasBody: true }, args),
649
651
  // GET /projects/featured — rank: public — GET /projects/featured
650
652
  getProjectsFeatured: (args) => request("GET", "/projects/featured", { hasBody: false }, args),
651
653
  // POST /projects/invite — rank: metic+archon — POST /projects/invite
@@ -202,7 +202,7 @@ const PERMISSIONS = [
202
202
  // anyone in a room with a stranger on its own.
203
203
  { key: 'mingle.manage', system: false, floor: 'metic', guards: 'PUT /mingle/project (the project half of mingle consent), POST /mingle/round (run a pairing round) — task 1003177, goal 1000075' },
204
204
  { key: 'project.settings.manage', system: false, floor: 'metic', guards: "PATCH /project-settings/* — the project's own live knobs (the rot timer today) and the work-category set's shape (PATCH /work-categories/:id). Metic, matching goal.create's reasoning that extending the project's vocabulary is a planning act rather than an administrative one — task 1003280, goal 1000072" },
205
- { key: 'project.curate', system: false, floor: 'metic', guards: 'platform-identity: GET/POST /projects, /projects/invite, PATCH /projects/:id/featured, GET /scouting (ADR 0157, was archon)' },
205
+ { key: 'project.curate', system: false, floor: 'metic', guards: 'platform-identity: GET/POST /projects, /projects/invite, PATCH /projects/:id/featured, PATCH /projects/:id/status (the takedown lever — ADR 0252 §5.1), GET /scouting (ADR 0157, was archon)' },
206
206
  { key: 'override_request.decide', system: false, floor: 'metic', guards: 'GET /override-requests, POST .../:id/decide (ADR 0157, was archon)' },
207
207
  { key: 'claim.release.any', system: false, floor: 'metic', scope: 'any', resource: 'claim', guards: 'POST /claims/:id/release-on-behalf (ADR 0157, was archon)' },
208
208
  { key: 'idea.ratify', system: false, floor: 'metic', guards: 'POST /inbox/:id/ratify (ADR 0157, was archon)' },
@@ -0,0 +1,106 @@
1
+ -- platform_identity_020_visibility_pull.sql — the three columns the self-hosted
2
+ -- visibility PULL needs (BV1.R08, task 1002327; ADR 0252, corrected by ADR 0253).
3
+ --
4
+ -- ADR 0252 §2 makes the hub READ a self-hosted instance's already-public manifest
5
+ -- rather than accept a pushed report. Two properties that decision needs cannot be
6
+ -- expressed by the columns migration 011 left behind, and this migration adds them.
7
+ --
8
+ -- 1. THE TRI-STATE HAS NOWHERE TO LIVE. `visibility` is `NOT NULL DEFAULT 'public'`,
9
+ -- so it cannot distinguish "reported public" from "never asked". ADR 0252 §4 is
10
+ -- explicit that `null` (never read) ≠ `false` (read, negative) and that a project
11
+ -- read before but unreachable now KEEPS its last state — a distinction the door
12
+ -- column physically cannot carry. `visibility_reported_at` is the null-carrier:
13
+ -- IS NULL means no successful read has ever happened, which is the ONLY condition
14
+ -- under which ADR 0182 D5's fail-closed default applies. A row that answered once
15
+ -- and is down today has a stamp, keeps its value, and stays on the map.
16
+ --
17
+ -- 2. "THE TWO NEVER WRITE THE SAME ROW" WAS A CONVENTION WITH NOTHING ENFORCING IT.
18
+ -- The task body requires the pull to touch self-hosted rows only, leaving
19
+ -- control-plane rows on the bridge's assigned value (ADR 0246). But the catalog
20
+ -- row carries no `hosting_shape` — that column lives on `provisioning_instances`,
21
+ -- in a module platform-identity may not import (ADR 0083's one-way rule), and
22
+ -- opening a seam just to ask would put a cross-module read on every sweep.
23
+ -- `visibility_source` records the provenance ON the row instead, stamped by
24
+ -- whichever writer touched it. The pull's predicate is then a local column read:
25
+ -- it may write a row the control plane has never claimed, and no other.
26
+ --
27
+ -- 3. `hidden` IS STICKY, AND AGAINST TWO WRITERS — not the one ADR 0252 §5.2 names.
28
+ -- The ADR defends a takedown against the pull. The pull is not the writer that
29
+ -- actually undoes takedowns today: `upsertOwnedProject` assigns `status =
30
+ -- EXCLUDED.status` unconditionally, and the ADR 0246 bridge re-takes that
31
+ -- projection on EVERY provisioning write, computing 'active' for any instance
32
+ -- that is not torn down. So an Archon hides a project, its owner saves any
33
+ -- setting, and it is back on the map — the lever is decorative for precisely the
34
+ -- reason ADR 0252 §5.2 gives, one writer over. `status_pinned` is the latch a
35
+ -- curator sets; both writers honour it. ADR 0253 records the correction.
36
+ --
37
+ -- Additive + `platform_identity_`-namespaced (ADR 0083 §5). Forward-safe: ADD COLUMN
38
+ -- only, so the previous code release still runs against this schema — a release that
39
+ -- has never heard of these columns keeps its current behaviour exactly, because an
40
+ -- unpinned row and a NULL source are what every existing row backfills to.
41
+ -- Idempotent (IF NOT EXISTS + the drop-then-add CHECK pattern from migration 006).
42
+
43
+ BEGIN;
44
+
45
+ -- 1. The tri-state's null. NOT backfilled to now(): every existing row genuinely has
46
+ -- never been pulled, and claiming otherwise would assert a reading that never
47
+ -- happened. Control-plane rows never acquire a stamp at all — they are not pulled,
48
+ -- and `visibility_source` is what says so.
49
+ ALTER TABLE platform_identity_projects
50
+ ADD COLUMN IF NOT EXISTS visibility_reported_at timestamptz;
51
+
52
+ -- 2. Which writer owns this row's door. NULL = neither has claimed it yet, which is
53
+ -- the state every pre-existing row starts in and the state the pull may write.
54
+ ALTER TABLE platform_identity_projects
55
+ ADD COLUMN IF NOT EXISTS visibility_source text;
56
+
57
+ ALTER TABLE platform_identity_projects DROP CONSTRAINT IF EXISTS platform_identity_projects_visibility_source_check;
58
+ ALTER TABLE platform_identity_projects ADD CONSTRAINT platform_identity_projects_visibility_source_check
59
+ CHECK (visibility_source IS NULL OR visibility_source IN ('control-plane', 'pull'));
60
+
61
+ -- 3. The takedown latch. DEFAULT false so every existing row is unpinned, i.e. today's
62
+ -- behaviour unchanged: nothing is newly frozen by this migration running.
63
+ ALTER TABLE platform_identity_projects
64
+ ADD COLUMN IF NOT EXISTS status_pinned boolean NOT NULL DEFAULT false;
65
+
66
+ -- THE BACKFILL, AND IT IS THE SAFETY PROPERTY OF THIS MIGRATION.
67
+ --
68
+ -- R08 makes the feed exclude a pullable row that has never answered — ADR 0182 D5's
69
+ -- "unreported => dark matter". Shipped without this UPDATE that predicate would empty
70
+ -- the public map the moment it deployed, because the column it reads is brand new and
71
+ -- therefore NULL on every row. That is not a hypothetical: ADR 0247 D4 found the exact
72
+ -- shape live, on this table, one column over — every catalog row carried
73
+ -- `description: null`, so a fail-closed join gate wired that day would have refused
74
+ -- every join on the platform at once.
75
+ --
76
+ -- The backfill is also simply TRUE, which is why it is safe to make. Every row that
77
+ -- exists at this moment got its door from the hub: either the ADR 0246 bridge assigned
78
+ -- it from `provisioning_instances.settings`, or an Archon typed the row in. Neither is
79
+ -- a pull — no instance has ever been asked, because this migration is what makes asking
80
+ -- possible. Stamping them 'control-plane' records what already happened; it does not
81
+ -- grant anything, and it leaves today's map exactly as it is.
82
+ --
83
+ -- What stays NULL is therefore precisely the new population: rows catalogued AFTER this
84
+ -- lands that the control plane has not claimed. Those are the ones the sweep may read,
85
+ -- and the ones D5's default is scoped to.
86
+ UPDATE platform_identity_projects
87
+ SET visibility_source = 'control-plane'
88
+ WHERE visibility_source IS NULL;
89
+
90
+ -- The pull's working set: rows the control plane has never claimed. Partial, because
91
+ -- the sweep asks this question and no other, and the control-plane rows it excludes
92
+ -- are the majority of the table.
93
+ CREATE INDEX IF NOT EXISTS platform_identity_projects_pullable
94
+ ON platform_identity_projects (origin)
95
+ WHERE visibility_source IS DISTINCT FROM 'control-plane';
96
+
97
+ COMMENT ON COLUMN platform_identity_projects.visibility_reported_at IS
98
+ 'When the hub last SUCCESSFULLY read this project''s own manifest (ADR 0252 §2). NULL is load-bearing: it means no read has ever succeeded, and is the ONLY state in which ADR 0182 D5''s fail-closed "unreported => dark matter" applies. A row that answered once and is unreachable today keeps both its `visibility` and this stamp — reporting may degrade where enforcement must refuse (ADR 0226), and liveness is an overlay, never a lifecycle state (ADR 0199). Never set on a control-plane row; those are assigned, not read.';
99
+
100
+ COMMENT ON COLUMN platform_identity_projects.visibility_source IS
101
+ 'WHICH writer owns this row''s join door: ''control-plane'' (assigned by the ADR 0246 bridge from provisioning_instances.settings — authoritative) or ''pull'' (read from the instance''s own public manifest — a cached CLAIM, and only as fresh as visibility_reported_at). NULL means neither has claimed the row yet. This column is what makes "the two never write the same row" an invariant the SQL enforces rather than a convention: the pull''s predicate is `visibility_source IS DISTINCT FROM ''control-plane''`. It exists because the catalog row carries no hosting_shape and may not import the module that does (ADR 0083).';
102
+
103
+ COMMENT ON COLUMN platform_identity_projects.status_pinned IS
104
+ 'A curator has set `status` by hand (PATCH /projects/:id/status), so no automatic writer may move it. ADR 0252 §5.2 requires a retracted project that keeps reporting `public` to STAY hidden; ADR 0253 found the pull is not the writer that undoes takedowns today — upsertOwnedProject is, assigning status on every re-take of the ADR 0246 projection. Both honour this latch. Cleared only by another curated PATCH, so un-hiding is exactly as deliberate as hiding.';
105
+
106
+ COMMIT;
@@ -12,7 +12,7 @@
12
12
  "contributes": {
13
13
  "routes": ["sso", "projects", "my-projects", "scouting", "profile", "public-profile", "connections"],
14
14
  "migrations": true,
15
- "pollers": ["code-sweep"]
15
+ "pollers": ["code-sweep", "visibility-pull"]
16
16
  },
17
17
  "provides": ["platform-identity.hub", "project.publishGate", "project.catalog"],
18
18
  "consumes": []
@@ -0,0 +1,99 @@
1
+ 'use strict';
2
+
3
+ // modules/platform-identity/pollers/visibility-pull.js — the loader poller entry that
4
+ // keeps self-hosted projects' join doors current (R08, task 1002327; ADR 0252 §2).
5
+ // The loader's startModulePollers requires THIS file and calls start(deps) at boot /
6
+ // stop() at shutdown for the ENABLED module only (the hub) — non-blocking and
7
+ // failure-isolated, so a sweep hiccup never touches sign-in. Mirrors code-sweep.js.
8
+ //
9
+ // WHY A POLLER AND NOT THE FEED'S EXISTING FAN-OUT. The obvious cheap move — pull the
10
+ // manifest alongside the four stats fetches the public feed already makes — cannot work,
11
+ // and the reason is worth writing down because it is invisible until you try it:
12
+ //
13
+ // 1. THE FEED EXCLUDES EXACTLY THE ROWS THAT NEED PULLING. A pullable project that has
14
+ // never answered is dark matter, so `listPublicProjects` does not return it, so the
15
+ // enrichment never fetches it, so it never answers, so it stays dark. A read-path
16
+ // pull can only ever refresh projects that are ALREADY visible — which is precisely
17
+ // backwards from what R08 is for. It cannot bootstrap.
18
+ // 2. STEALTH ROWS ARE SKIPPED TOO. `routes/projects.js` filters them out of enrichment
19
+ // on purpose (asking a stealth project's API for its builders would put identity on
20
+ // the wire and tell it it was being enumerated), so a project that went stealth
21
+ // could never be seen coming back.
22
+ // 3. IT WOULD MAKE A DATABASE WRITE A SIDE EFFECT OF AN ANONYMOUS READ, on a route that
23
+ // is unauthenticated and CORS-open — and add a fifth outbound fetch to the platform's
24
+ // most expensive public request while doing it.
25
+ //
26
+ // So the sweep runs on its own clock over its own working set, and the feed keeps reading
27
+ // the cached column exactly as it does today.
28
+ //
29
+ // Proof: ../../../tests/visibility_pull.mjs
30
+
31
+ const api = require('../../../src/module-api');
32
+ const visibilityPull = require('../visibility-pull');
33
+
34
+ const log = api.logger('platform-identity');
35
+
36
+ // Every 15 minutes. A join door is not a liveness signal — it changes when an owner
37
+ // decides something, which is rare — so the cadence is set by how long a stale door may
38
+ // linger, not by how fast anything moves. It is also deliberately slower than the stats
39
+ // TTL (10 min): this sweep reaches out to every catalogued third-party instance whether
40
+ // or not anyone is looking at the map, and being a polite client to other people's
41
+ // servers matters more here than freshness measured in minutes.
42
+ const SWEEP_INTERVAL_MS = 15 * 60 * 1000;
43
+
44
+ // Rows per sweep. A ceiling rather than a page: at 15-minute cadence this is far above
45
+ // any plausible catalog, and `pullableProjects` orders oldest-reading-first, so if the
46
+ // population ever did exceed it the sweep still converges — it just takes more passes,
47
+ // and it spends them on the stalest rows.
48
+ const SWEEP_LIMIT = 200;
49
+
50
+ // Wait before the FIRST sweep so boot is never competing with a fan-out to every
51
+ // catalogued origin. The catalog-backfill poller's precedent.
52
+ const FIRST_SWEEP_DELAY_MS = 60 * 1000;
53
+
54
+ let timer = null;
55
+ let firstTimer = null;
56
+
57
+ async function sweepOnce(deps = {}) {
58
+ try {
59
+ const out = await visibilityPull.sweepVisibilityOnce({
60
+ ...(deps.pool ? { pool: deps.pool } : {}),
61
+ limit: SWEEP_LIMIT,
62
+ });
63
+ // One disposition line per sweep, and only when something actually moved — a
64
+ // quiet catalog should not write a log line every quarter hour forever.
65
+ if (out.updated > 0) {
66
+ log.info({ updated: out.updated, scanned: out.scanned }, 'visibility-pull: project door(s) updated');
67
+ }
68
+ return out;
69
+ } catch (err) {
70
+ // Best-effort: a sweep failure must never crash the process. Individual projects
71
+ // already fail closed inside pullOne(), so reaching here means the DB read itself
72
+ // failed, not that some instance was down.
73
+ log.error({ err: err && err.message }, 'visibility-pull sweep failed (non-fatal)');
74
+ return null;
75
+ }
76
+ }
77
+
78
+ module.exports = {
79
+ start: (deps = {}) => {
80
+ if (timer || firstTimer) return; // idempotent
81
+ firstTimer = setTimeout(() => {
82
+ firstTimer = null;
83
+ sweepOnce(deps);
84
+ timer = setInterval(() => { sweepOnce(deps); }, SWEEP_INTERVAL_MS);
85
+ if (timer.unref) timer.unref();
86
+ }, FIRST_SWEEP_DELAY_MS);
87
+ // unref so neither timer keeps the process alive on its own.
88
+ if (firstTimer.unref) firstTimer.unref();
89
+ },
90
+ stop: () => {
91
+ if (timer) { clearInterval(timer); timer = null; }
92
+ if (firstTimer) { clearTimeout(firstTimer); firstTimer = null; }
93
+ },
94
+ // exported for tests
95
+ sweepOnce,
96
+ SWEEP_INTERVAL_MS,
97
+ SWEEP_LIMIT,
98
+ FIRST_SWEEP_DELAY_MS,
99
+ };
@@ -127,26 +127,83 @@ async function upsertCatalogOnlyClient(entry, { pool = defaultPool } = {}) {
127
127
  // WHERE is claim rule 2 (the owner must already match), so an origin someone else's row
128
128
  // occupies is refused rather than overwritten. Returns the row, or null when the claim
129
129
  // refused. `featured`, `tagline`, `art_url` and `template` are curation and stay untouched.
130
+ //
131
+ // TWO THINGS R08 CHANGED HERE (task 1002327; ADR 0253):
132
+ //
133
+ // 1. `status` NOW HONOURS THE PIN, and this is the takedown lever actually working.
134
+ // ADR 0252 §5.2 requires a retracted project to stay retracted, and defends that
135
+ // against the visibility pull — but the pull is not the writer that undoes takedowns.
136
+ // THIS one is. The ADR 0246 projection is re-taken at every provisioning write, and
137
+ // `catalogStatusForInstance` computes 'active' for any instance that is not torn down,
138
+ // so before this line an Archon could hide a project and the owner's very next
139
+ // settings save would put it back on the public map. The pin is set only by a curated
140
+ // PATCH /projects/:id/status, so what it freezes is a human decision, and only against
141
+ // automatic writers. Un-pinning is another curated PATCH — un-hiding stays exactly as
142
+ // deliberate as hiding.
143
+ //
144
+ // `retireOwnedProject` deliberately does NOT honour the pin: it is a safety retraction
145
+ // for an address the project no longer answers on (the ADR 0181 §2 stale-orb leak), and
146
+ // a curator's 'active' pin must not be able to keep a dead origin drawing an orb.
147
+ // Narrowing is free; widening is what costs.
148
+ //
149
+ // 2. `visibility_source` is stamped 'control-plane'. This row's door is ASSIGNED from
150
+ // `provisioning_instances.settings`, so the pull must never touch it — and the stamp is
151
+ // what makes "the two writers never share a row" an invariant the SQL enforces rather
152
+ // than a convention (the catalog carries no `hosting_shape`, and may not import the
153
+ // module that does — ADR 0083). Written on the UPDATE branch too: a row that predates
154
+ // the bridge, or a hand-seeded one this projection later claims, becomes control-plane
155
+ // the moment the control plane actually writes it.
130
156
  async function upsertOwnedProject(entry, { pool = defaultPool } = {}) {
131
157
  const e = normalizeEntry(entry);
132
158
  const { rows } = await pool.query(
133
159
  `INSERT INTO platform_identity_projects
134
- (origin, name, description, owner_github_id, type, visibility, status)
135
- VALUES ($1, $2, $3, $4, $5, $6, $7)
160
+ (origin, name, description, owner_github_id, type, visibility, status, visibility_source)
161
+ VALUES ($1, $2, $3, $4, $5, $6, $7, 'control-plane')
136
162
  ON CONFLICT (origin) DO UPDATE SET
137
163
  name = EXCLUDED.name,
138
164
  description = EXCLUDED.description,
139
165
  visibility = EXCLUDED.visibility,
140
- status = EXCLUDED.status,
166
+ status = CASE WHEN platform_identity_projects.status_pinned
167
+ THEN platform_identity_projects.status
168
+ ELSE EXCLUDED.status END,
169
+ visibility_source = 'control-plane',
141
170
  type = COALESCE(EXCLUDED.type, platform_identity_projects.type),
142
171
  updated_at = now()
143
172
  WHERE platform_identity_projects.owner_github_id = EXCLUDED.owner_github_id
144
- RETURNING id, origin, name, description, owner_github_id, type, status, featured, visibility`,
173
+ RETURNING id, origin, name, description, owner_github_id, type, status, featured, visibility, status_pinned`,
145
174
  [e.origin, e.name, e.description, e.ownerGithubId, e.type, e.visibility, e.status],
146
175
  );
147
176
  return rows[0] || null;
148
177
  }
149
178
 
179
+ // setProjectStatus — THE TAKEDOWN LEVER (R08, task 1002327; ADR 0252 §5.1).
180
+ //
181
+ // Before this, `platform_identity_projects.status` had NO route that wrote it: the column
182
+ // existed from migration 004, the public feed required 'active', and removing a project
183
+ // from the map meant hand-written SQL on production. Under operator-provisioned curation
184
+ // that was survivable, because the set of rows was bounded by who the operator had
185
+ // provisioned. Auto-appear (ADR 0252 §3) replaces that bound with whoever points a domain
186
+ // at us, and a one-way door onto a public surface is not a door — so the Archon's
187
+ // auto-appear decision was explicitly conditional on this shipping in the SAME task
188
+ // (ADR 0047's rule that a straight-through path and its abuse guard are one piece of work).
189
+ //
190
+ // SETTING THE PIN IS THE POINT, not a side effect: `status_pinned` is what stops the ADR
191
+ // 0246 projection from reverting the decision on the owner's next settings save. A PATCH
192
+ // back to 'active' clears it, returning the row to automatic management.
193
+ async function setProjectStatus(id, status, { pool = defaultPool } = {}) {
194
+ if (!CATALOG_STATUSES.includes(status)) {
195
+ throw new Error(`project-catalog: unknown status ${JSON.stringify(status)}`);
196
+ }
197
+ const { rows } = await pool.query(
198
+ `UPDATE platform_identity_projects
199
+ SET status = $2, status_pinned = ($2 = 'hidden'), updated_at = now()
200
+ WHERE id = $1
201
+ RETURNING id, origin, name, status, status_pinned, featured, visibility`,
202
+ [id, status],
203
+ );
204
+ return rows[0] || null;
205
+ }
206
+
150
207
  // Take a catalog row OFF the map when its project moved to a different address (the domain
151
208
  // re-point, task 1003237): an abandoned origin left `active` would keep drawing an orb that
152
209
  // points at a hostname the project no longer answers on — the ADR 0181 §2 leak, in the
@@ -222,6 +279,7 @@ module.exports = {
222
279
  normalizeEntry,
223
280
  upsertCatalogOnlyClient,
224
281
  upsertOwnedProject,
282
+ setProjectStatus,
225
283
  retireOwnedProject,
226
284
  catalogedOrigins,
227
285
  publishControlPlaneProject,
@@ -301,10 +301,41 @@ async function getProjectStats(origins = []) {
301
301
  return out;
302
302
  }
303
303
 
304
+ // fetchInstanceVisibility — ONE origin's public manifest, for the visibility pull (R08,
305
+ // task 1002327; ADR 0252 §2). Lives here, not in visibility-pull.js, for the reason this
306
+ // file's SSRF header gives: this is "the one place the server makes an outbound request
307
+ // to an address that came out of the database", and a pull that opened its own fetch
308
+ // path would be a second, weaker one. It reuses the SAME guard the stats fan-out runs —
309
+ // resolve-all with a public-address assertion, `redirect: 'error'` so the remote never
310
+ // chooses our final address, and the per-request timeout.
311
+ //
312
+ // DELIBERATELY OUTSIDE THE STATS CACHE. The stats cache is warmed by public feed reads
313
+ // and holds only the rows the feed enriches; the pull's working set is the opposite one
314
+ // — rows that are dark, or stealth, and therefore excluded from that enrichment
315
+ // entirely. Sharing the cache would mean the sweep could only ever refresh projects that
316
+ // were already visible, which is exactly backwards. The sweep has its own cadence and
317
+ // carries its own staleness stamp in the database, so a second in-memory TTL would be a
318
+ // third opinion about freshness with nothing to settle it.
319
+ //
320
+ // THE PREFIX IS THE CANONICAL ONE, not the one ADR 0252 §2 wrote. The API router is
321
+ // dual-mounted at `/api/bongos` (canonical) and at a permanent legacy alias — see the
322
+ // header of `src/bongos/routes.js` — so both resolve on every instance and the choice
323
+ // is free.
324
+ // `/api/bongos` matches the discovery fetch four lines up in `fetchStats`, so this file
325
+ // asks a remote instance for everything under ONE prefix.
326
+ //
327
+ // Throws on an unreachable, refused or non-JSON response; the caller reads a throw as
328
+ // "no verdict", never as a value.
329
+ async function fetchInstanceVisibility(origin) {
330
+ await assertPublicHost(siteUrl(origin).hostname);
331
+ return getJson(apiUrl(origin, '/api/bongos/instance'));
332
+ }
333
+
304
334
  // tests
305
335
  function _reset() { _cache.clear(); _inflight.clear(); _coreModules = null; }
306
336
 
307
337
  module.exports = {
308
338
  getProjectStats, isSystemAccount, coreModules, blockedReason, assertPublicHost, httpUrl,
339
+ fetchInstanceVisibility,
309
340
  NULL_STATS, SHIPPED_WINDOW_DAYS, FACES_MAX, _reset, TTL_MS,
310
341
  };
@@ -118,11 +118,26 @@ const PUBLIC_FEED_MAX_PROJECTS = 500;
118
118
  // sort but never projects: feature toggles and client re-registration bump it, so as a
119
119
  // public "last updated" it would lie. `description` is the publish BAR, spent in the
120
120
  // predicate, never a card field.
121
+ // A PULLABLE row is on the map only once it has actually answered (R08, task 1002327;
122
+ // ADR 0182 D5 via ADR 0252 §4). "Unreported => dark matter" is scoped, deliberately and
123
+ // narrowly, to rows whose door the hub has never successfully read: a control-plane row
124
+ // was ASSIGNED its door by the ADR 0246 bridge and is on the map the moment that lands,
125
+ // and a row that answered once and is unreachable today KEEPS its last state — a project
126
+ // does not leave the map because its server was down for a sweep (ADR 0199 / 0226).
127
+ //
128
+ // THE SCOPING IS THE SAFETY PROPERTY, not a nicety. ADR 0247 D4 found the unscoped
129
+ // version live on this very table: every catalog row carried `description: null`, so a
130
+ // fail-closed gate wired that day would have refused every join on the platform at once.
131
+ // The same predicate written without the `visibility_source` half — or shipped without
132
+ // migration 020's backfill, which stamps every pre-existing row 'control-plane' — would
133
+ // empty the public map on deploy. Both halves are load-bearing.
134
+ const DOOR_KNOWN_SQL = "(visibility_source = 'control-plane' OR visibility_reported_at IS NOT NULL)";
135
+
121
136
  async function listPublicProjects({ pool = defaultPool } = {}) {
122
137
  const { rows } = await pool.query(
123
138
  `SELECT origin, name, tagline, art_url, type, template, created_at, visibility
124
139
  FROM platform_identity_projects
125
- WHERE status = 'active' AND ${publishableSql()}
140
+ WHERE status = 'active' AND ${publishableSql()} AND ${DOOR_KNOWN_SQL}
126
141
  ORDER BY featured DESC, updated_at DESC
127
142
  LIMIT ${PUBLIC_FEED_MAX_PROJECTS}`,
128
143
  );
@@ -138,5 +153,6 @@ module.exports = {
138
153
  isStealthProject,
139
154
  feedShapeFor,
140
155
  PUBLIC_FEED_MAX_PROJECTS,
156
+ DOOR_KNOWN_SQL,
141
157
  listPublicProjects,
142
158
  };