@bongos/core 1.19.567 → 1.19.569

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.
@@ -340,7 +340,9 @@ This keeps the decision history honest and traceable.
340
340
  | 0250 | [**Strict versioning — the version boundary is the scope gate** ([task 1003589](https://cloudbongos.com/builders#/task/1003589) · goal 1000086). The work hierarchy ([ADR 0086](<redacted>.md)) has a bottom and no top: tasks close, criteria close, goals close, and a version never does. Measured live — **14 goals reading `achieved`/`archived` while holding 35 unfinished tasks**, 523 open tasks across 37 open goals, and **two versions `building` on one project**; every number was worse than the planning session had recorded two days earlier, which is the argument: the drift is the steady state, not a backlog. The cause is four mechanical gaps, not a social one — `POST /goals` fetches the version row and never reads its `status`; goal achievement is computed from criteria alone and never consults `tasks` (both the `db-goals.js` writer and the `done-when.js` cascade); `POST /goals/:id/archive` checks nothing at all; and no route ships a version, so version status has only ever moved as hand-run SQL on the droplet. **Decision, five rules:** the goal set closes when a version starts `building` (new goals go on the `planning` version — where the next version is scoped in advance) with a named 409 an agent can act on; a goal reaches `achieved` only when its criteria are satisfied **and** no task in it is non-terminal, where terminal is `shipped`|`abandoned` per [task 1003588](https://cloudbongos.com/builders#/task/1003588)'s one vocabulary and the mid-ship `completed`/`confirmed` hold it open; archiving a goal that holds open tasks refuses until the caller dispositions each (abandon with the reason, or move to a named surviving goal); `goal_id` becomes required and the per-version catch-all fallback is deleted; and exactly one version builds per project, auto-closing when its last non-maintenance goal achieves, with the single `planning` version promoting itself so the project is never without a live train. **The escape hatches are deliberate and visible**: an Archon may admit one *named* goal to a building version with a reason, and the overrides are counted on the version (the standard failure of a hard scope gate is a fake hotfix version cut to hold what did not fit — an untraced override is just a slower version of no rule); plus one auto-created maintenance goal per version, exempt from the auto-close count, so bugs never need an override to be filed. **Nothing here can refuse a ship, a claim, or a task** — every rule acts on a *closing* move. Rejected: counting `completed`/`confirmed` as finished (they can fail a grade or strand — the goals **board** buckets them as done, but that is a progress ring, and `task-status.js` keeps the two questions apart); a nightly reconciler that reopens wrongly-closed goals (detection is what you build when prevention is unavailable, and it is available here); a human review gate before a goal may close (the queue [ADR 0183](<redacted>.md) dismantled — the check is a fact the table already holds); concurrent building versions scoped by module (two live trains means every "what is in this version" question has two answers); retiring versions entirely (the version is the unit the owner sets scope in and `limitations/<version>-shipped.md` archives). **Amends [ADR 0157](<redacted>.md) for exactly one key:** `version.create` returns to the Archon floor. 0157's "authoring delegates, disposition does not" was right about what a version *was* — but once the boundary carries the scope gate, cutting a new version is no longer authoring, it is the way *around* the gate, so create joins close as a disposition over scope.](<redacted>.md) | lifecycle / versioning / scope |
341
341
  | 0251 | [**A suggestion of who to invite is a recruiting surface, and owes the recruiting opt-out** ([task 1002347](https://cloudbongos.com/builders#/task/1002347) · goal 1000046, criterion `proj-invite-builders` / C6). BV1.R28 fills the seam [ADR 0239](<redacted>.md) §6 left open: the wizard’s pure rule engine emits a **seek** (`solo` → `null`, `small-team` → 3, `community` → 5) and never a list, because ranking builders is a directory read a pure file may not run and `provisioning` may not import a sibling. Two readers could answer “who are the top builders” and they differ by **one SQL predicate**: `economy`’s `leaderboard()` joins `rollupVisibleSql` and deliberately honours **no** recruiting opt-out, while `platform-identity`’s `listScoutingRollup()` joins `scoutingListableSql`, which carries it. The leaderboard is cheaper in every mechanical sense — already a public route, no port, callable straight from the browser. **Decision: use the recruiting directory anyway**, because `account-visibility.js` says the split in its own words (`hide_stats` suppresses *numbers* everywhere; the recruiting opt-out “removes an account from the RECRUITING SURFACE only”, community spec D3), and a list of people the platform proposes you recruit **is** that surface — sourcing it from the leaderboard would route around the one control built for this exact case and name every builder who asked not to be recruited. The rule for later readers: **the cheapness of a public read is not an argument about who consented to appear in it.** The lookup ships as `listInviteSuggestions` on the existing `platform-identity.hub` port, reached with `resolveOptional` and NOT declared in `consumes` (the `catalog-bridge.js` precedent — platform-identity is hub-only, so its absence is “no suggestions”, never a 500); `github_id` **does not cross the port** (explicit whitelist — login, display name, avatar, disciplines, two counts — so a new rollup column cannot leak by default), and the owner-exclusion is applied inside, over-read by the exclusion count so filtering never returns short. The route `GET /provisioning/instances/:id/invite-suggestions` is own-scoped by the **existing** `instanceAccessDenied` (same 404 as the manage read) and deliberately does NOT reuse `/scouting`’s gate ([ADR 0210](<redacted>.md)): a brand-new owner has no hub-witnessed owner row at the moment the done panel paints, so the correct-looking gate would refuse the exact caller the feature exists for — and reaching the route IS the ownership answer ([ADR 0249](<redacted>.md) §4). On the surface: a **second door in** (`setInviteSuggestions`) beside `setInviteSlug`, so the widget still answers neither “signed in” nor “theirs”; the reason is **always** said including when the answer is nobody (`seeking: null` costs no query — rendering a stated answer as an empty list would make it look like a failed lookup); a picked name fills the field and takes the **same write path** as a typed one, so the refusal vocabulary cannot fork; a filed invite stops being a suggestion; and every failure is silent, because a suggestion is an extra and a just-created project must not look broken over one. Rejected: the browser reading the economy leaderboard (the whole decision — consent, not architecture); reusing `/scouting` (right data, gate that refuses this caller); filling `invites.suggested` inside the engine (I/O in a pure file + a sibling import); a `provisioning → economy` port (legal, still the wrong directory — the boundary was never the objection).](<redacted>.md) | provisioning / platform-identity / privacy |
342
342
  | 0252 | [**The self-hosted visibility report is a PULL, and auto-appear buys a retraction lever** ([task 1002321](https://cloudbongos.com/builders#/task/1002321) · goal 1000046, criterion `proj-visibility-states` / C1, BV1.R02 — a spike whose output is this decision; gates [task 1002327](https://cloudbongos.com/builders#/task/1002327) / R08). **The premise failed first:** a self-hosted instance has no channel to the hub and no credential to open one — `registerClient` has exactly one caller in the tree (the operator's own `provision-net.js`), gated `hosting_shape !== 'co-tenant'`, and no route registers a client. So *federated* ⊆ *operator-provisioned co-tenant* and *self-hosted* holds nothing, deliberately: [ADR 0145](<redacted>.md) says do NOT extend federation to `standalone`, and [ADR 0144](<redacted>.md)'s Known Limitation rests on "every secret-holder is an operator-provisioned, trusted instance … the proper fix must land before federation opens to untrusted third-party clients." [ADR 0182](<redacted>.md) D5's "accepts a report from the authenticated `sso_client`" described a client that, for this population, does not exist. **Decision 1 — the channel is a PULL of the instance's already-public manifest** (`GET <origin>/api/gds/instance` → `branding.project.visibility`, a value every core already serves and whose author anticipated this consumer). It buys five things a push must build: no credential (0144's tripwire untouched — the whole reason), **no replay surface** (a pull reads current state, so the `jti` machinery D5 handed R02 is moot — replay is answered by not having a message), no wire change on another instance's upgrade clock ([ADR 0205](<redacted>.md)'s and [ADR 0201](<redacted>.md) D2.2's rejected-required-field problem simply absent), the hub still authorising nothing (0182 D5 satisfied, [ADR 0238](<redacted>.md)'s rejected `PATCH /projects/:id/visibility` still rejected), and a fetch that already exists behind a real SSRF guard (`project-stats.js` — DNS-resolve-all, `redirect: 'error'`, TTL + negative caches). The pull carries `visibility` and NOTHING else, so the property the map has today — a project owner controls no free-text field that reaches the feed — is not spent. **Decision 2 (the Archon's) — auto-appear:** no human stands between a public project and the map; `featured` keeps only the ranking job [ADR 0182](<redacted>.md) D3 left it. Conditional on Decision 4. **Decision 3 (the Archon's) — trust follows provenance:** both populations auto-appear, and the asymmetry is in the GUARDS, not a gate — a control-plane row's `visibility` is *assigned* by the bridge from an own-scoped audited route (authoritative), a self-hosted row's is *pulled* from a manifest the hub does not control (a cached claim with a recency stamp, able to move its own row and nothing else). An instance is authoritative about its own door and about nothing else. **Absent is a THIRD state and must not purge:** `null` (never read) ≠ `false` (read, negative). 0182 D5's fail-closed default holds only for a self-hosted row that has NEVER answered; a row that answered before and is unreachable now keeps its last state with a recency stamp ([ADR 0199](<redacted>.md), [ADR 0226](<redacted>.md)), because [ADR 0247](<redacted>.md) D4 found the live shape of that trap — every catalog row at `description: null`, so naive fail-closed would have emptied the map at once. Narrowing is free; widening is what costs. **Decision 4 — auto-appear buys a retraction lever, in the same task** ([ADR 0047](<redacted>.md): the straight-through path and its abuse guard are one task, treated as binding): `platform_identity_projects.status` is `(active,hidden)` and **no route writes it** — takedown is hand SQL today, and a one-way door onto a public surface is not a door. R08 ships `PATCH /projects/:id/status` (curate floor, audited), a per-IP limit on the unmetered CORS-open feed read ([ADR 0209](<redacted>.md)), and above all **`hidden` STICKY against the pull** — a retracted project that keeps reporting `public` stays hidden, or the next sweep silently undoes every takedown and the lever is decorative. Also named: `hall_origin` reaches the feed from the remote's own discovery index checked only for *differing* from the project host — the opposite of containment — and gets `shipUrlWithinClientOrigin`'s downward-only rule, filed against R08. Rejected: a pushed credentialed report (the literal R08 — needs the credentials 0144/0145 forbid, plus replay, rate and a wire-compatible field, none of which the pull has); `PATCH /projects/:id/visibility` (0238 already); a one-time admission and ongoing curation (both offered to the Archon, both declined); trusting the pull to move `status`; enforcing "unreported ⇒ dark matter" across all rows; letting the pull carry name/tagline/art. The later ANALYTICS feed is deliberately NOT decided here — it carries per-person data, exactly what [ADR 0205](<redacted>.md) proved cannot ride an unwitnessed self-report, and it needs the 0144 credential work first.](<redacted>.md) | platform-identity / provisioning / federation trust |
343
+ | 0256 | [**The visibility ceiling binds the SIGN-IN gate too, and the knob reader moves to the composer** ([task 1003579](https://cloudbongos.com/builders#/task/1003579) · goal 1000046, criterion `proj-join-policy`). Closes the *KNOWN LIMIT* [ADR 0247](<redacted>.md) wrote down: it composed `visibility × joinGrant × joinability` into one door and enforced it at the public application endpoint, leaving `openEnrollmentEnabled()` reading the `joinability` knob alone — so a project set `private` (or `stealth`, or `joinGrant: view`) while `joinability` stayed `open` **queued applications for review and still let any first-time GitHub account sign itself in at xenos**. Half of [ADR 0182](<redacted>.md) D6’s table — its whole *Self-join* column — was unenforced wherever the owner had explicitly opened enrolment. **Decision 1:** `openEnrollmentEnabled()` becomes `effectiveJoinDoor(deps) === 'open'`, the same SR-19 move [ADR 0194](<redacted>.md) D1 made one level down, so the four sign-in call sites, `firstTimeAdmissionBlocked`, `webAdmissionStatus` and the manifest all follow untouched — and **nothing changes for any existing project**, because `branding.js` normalises an absent visibility to `public` and an absent grant to `full`, the pair that narrows nothing. The ceiling closes **self-enrolment only**: an invited first-timer, an existing builder and the configured `firstAdmin` still get in, or a private project locks out its own founder. **Decision 2:** the manifest’s `admission.policy` now reports the GATE while `brand.project` keeps reporting the SETTING — they may differ, because they answer different questions, and `invite-only` is already what every `apply` project reports, so narrowing into it names no state ([ADR 0247](<redacted>.md) D3). **Decision 3 — the mechanical one:** `joinabilityMode()` moves from `src/bongos/auth-admission.js` into `src/bongos/project-door.js` (re-exported, so no import path and no doorway export changes). The door resolved its own joinability input with a lazy `require('./auth-admission')`; the moment the gate requires the door back the two files point at each other, and the `require_cycle_count` ratchet counts edges from **source text**, so laziness is no escape. Rejected: duplicating the composition in the gate (the drift SR-19 exists to prevent), and making `joinabilityMode` a required argument of `effectiveJoinDoor` (turns the published no-argument call into a footgun that silently answers `invite_only`). The reader was only in the admission file because the gate was its first caller; the composer owns its inputs. **Decision 4:** the gate passes no `publishable` verdict, so `locked` still cannot fire from it — [ADR 0247](<redacted>.md) D4’s finding stands, and a gate reading the hub’s cached column would refuse every first sign-in on the platform. Also rejected: refusal copy that names the private state (the read-out D3 forbids, and a case-specific sentence makes the refusals distinguishable by another route); a `webAdmissionStatus` special case (it reads the same function, and its anti-drift sweep already pins the agreement). Proof: `tests/admission_ceiling_gate.mjs` boots a real instance on `private` + `open` through the production env channel, then sweeps all 75 knob combinations asserting the gate **is** the door and that exactly one combination — `public`/`full`/`open` — self-enrols.](<redacted>.md) | admission / project visibility / kernel layering |
343
344
  | 0253 | [**The takedown lever has two writers, and provenance has to be a column** ([task 1002327](https://cloudbongos.com/builders#/task/1002327) · goal 1000046, criterion `proj-visibility-states` / C1, BV1.R08 — the build). Amends [ADR 0252](<redacted>.md) §4/§5: three premises that did not survive contact with the tree. The decisions stand; what changes is what has to be BUILT. **(1) `hidden` is sticky against TWO writers, and 0252 names the wrong one.** 0252 §5 says "no route writes [`status`]" and defends the takedown against the PULL. But `project-catalog.js:upsertOwnedProject` writes `status = EXCLUDED.status` on every conflict, sourced from `catalog-bridge.js:catalogStatusForInstance` — which returns `active` for any instance not torn down — and [ADR 0246](<redacted>.md) re-takes that projection at EVERY provisioning write. So the live failure is: curator hides a project → its owner saves any setting → it is back on the map. Stickiness therefore belongs to the ROW (`status_pinned`, set by the curated PATCH, honoured by the projection), not to one writer's manners; the pull separately writes no `status` at all, asserted against the statement text. `retireOwnedProject` deliberately does NOT honour the pin — it is a safety retraction for an origin the project no longer answers on ([ADR 0181](0181-teardown-state-gate-and-address-retention.md) §2), and narrowing is always free. **(2) Provenance must be a stored column.** 0252 §4 tabulates control-plane vs self-hosted rows as though the catalog knew which was which; it has no `hosting_shape` (that lives on `provisioning_instances`, in a module platform-identity may not import — [ADR 0083](<redacted>.md)) and `owner_github_id` separates nothing (set by the bridge, set by `registerClient`, null on hand-seeded rows). `visibility_source` (`control-plane`|`pull`|NULL) records it on the row, re-asserted in the UPDATE's own WHERE so a mid-sweep projection cannot lose the race — making "the two never write the same row" an invariant the SQL enforces. Rejected a `provisioning.instanceShape` port: a cross-module read in a background loop, for a value that never changes per row. **(3) The tri-state cannot live in `visibility`** (`NOT NULL DEFAULT 'public'`), so the recency stamp IS the null-carrier: `visibility_reported_at IS NULL` is the never-read state, and a failed read writes nothing — which is what makes "keep the last known state" fall out rather than be implemented. The fail-closed feed predicate needed a BACKFILL for [ADR 0247](<redacted>.md) D4's exact reason: both columns are new, so unbackfilled it would have emptied the public map on deploy. Migration 020 stamps every existing row `control-plane`, which is safe because it is TRUE — no instance has ever been asked. Two smaller corrections: the manifest key is **`brand`**, not `branding` (0252 §2 and the task body both misname it; reading only the documented spelling makes every pull a silent no-verdict), and the pull is a **POLLER, not a passenger on the feed's fan-out** — the feed excludes exactly the rows that need pulling (a never-answered row is dark matter, so it is never enriched, so it never answers), stealth rows are excluded from enrichment by design, and a read-path pull would make a DB write a side effect of an anonymous CORS-open read. Honest limit recorded: 0247 D4's `description: null` problem is NOT fixed here — 0252 §7 refuses to let the pull carry name/tagline/description, so the publish gate stays unmet for self-hosted rows. The pull moves the door; it does not publish the project.](<redacted>.md) | platform-identity / provisioning / abuse guard |
345
+ | 0255 | [**A public list's ordering is part of its payload, so an anonymous row must be ordered by nothing** ([task 1003561](https://cloudbongos.com/builders#/task/1003561) · goal 1000046, which absorbed [task 1003560](https://cloudbongos.com/builders#/task/1003560)). The public projects feed redacts a stealth row down to an allow-list of `state` + `orb` and carries **no stable per-project key on purpose** ([ADR 0182](<redacted>.md) D2a) — then sorted `ORDER BY featured DESC, updated_at DESC` and handed the observer one anyway. **Ordering is a covert column:** a position is a comparison result, and enough comparisons reconstruct the key. Three consequences, the third load-bearing — `updated_at` was published despite being deliberately withheld from the projection (it lies as a public "last updated"); a black hole sat BETWEEN NAMED NEIGHBOURS whose order is visible, bracketing its timestamp between two identifiable rows; and it **MOVED when its hidden project was touched**, which is an activity oracle on a public, CORS-open, unauthenticated route, and stable enough between touches to follow one black hole across snapshots. **Decision: a row may be ordered only by what it publishes.** An identified row publishes `created_at` + `origin`, so it sorts by those under `featured` (D3's remaining ranking job) — an order that is a PURE FUNCTION OF THE WIRE PAYLOAD, reproducible by a reader holding only the response. An anonymous row publishes nothing, so it is ordered by nothing about itself: the black holes collate into ONE TRAILING BLOCK permuted by a keyed hash. Four sort terms; the first partitions the blocks so no later term compares across them, and **terms 2-4 are GUARDED** — the "anonymous rows go last" half without the "and are blanked" half is not a fix, since the trailing block would simply re-sort by `created_at`, which narrows *which* project it is for anyone who watched the catalog fill. The identified set is DERIVED from `ORB_BY_STATE` (never re-typed, so SQL and `feedShapeFor()` cannot drift) and is an ALLOW-list, so an unknown fourth enum value sorts anonymous as well as drawing anonymous. The permutation salt is **secret** (origins are guessable domain names — a predictable salt lets an observer hash candidates and match ranks) and **rotates on a 24h epoch** (a permanently stable permutation still makes POSITION a durable handle); random per process, since nothing persists or reads it back. **Pagination is the same decision, not a neighbouring one** — task 1003560's premise was already stale (`<redacted> = 500` shipped with the feed; its own comment called the cap "the cheap half" and pagination "its own task"), and the surviving half edits this exact statement, so it merged in: `?limit=&offset=` per [ADR 0119](0119-api-pagination-contract.md), **no cursor** because the obvious encoding is the last row's timestamp — the very channel this closes — and the **default page IS the maximum (500)** so a static orbs site in ANOTHER REPO is byte-for-byte unchanged, with `page` added beside `projects` rather than wrapping it. Paging cannot aim more fan-out than one request already could: `?limit=` only shrinks, and `?offset=` is bounded by `project-stats.js`'s 10-min per-origin cache, not the request count ([ADR 0252](<redacted>.md) §5.3's per-IP limiter unchanged). Rejected: sorting everything by `created_at` (positions a black hole by a date its row refuses to carry); alphabetical `origin` order for the block (an oracle against a candidate set); `ORDER BY random()` (breaks paging — a row could appear twice or not at all); coarse `updated_at` buckets (a sort key that must be blurred should not be there). **Honest limits, stated rather than implied fixed:** existence and count stay public by design; **the identified set still CHANGES when a project goes stealth**, a LARGER channel than this one and untouched here; and position is stable within one epoch.](<redacted>.md) | platform-identity / privacy boundary / public API |
344
346
  | 0254 | [**A relayed link is contained downward, or it is not published** ([task 1003670](https://cloudbongos.com/builders#/task/1003670) · goal 1000046, criterion `proj-visibility-states` / C1) — the gap [ADR 0252](<redacted>.md) §5 named and filed. **The defect:** `hall_origin` reaches the public projects feed from the REMOTE instance’s own discovery index (`GET <origin>/api/bongos` → `index.hall`, `project-stats.js`) and the hub renders it as an `<a href>` under that project’s name — checked only for **differing** from the project’s site host, which is containment turned inside out: it admits every host on the internet and refuses the one that is certainly safe. `{"hall": "https://evil.example/builders"}` was published on the hub’s own domain, attached to the victim. The rule answered a DIFFERENT question correctly ([task 1003257](https://cloudbongos.com/builders#/task/1003257), owner decision (a): *a door only where it opens somewhere else*); nobody had asked it the security one. It stopped being survivable when [ADR 0252](<redacted>.md) §3 replaced “whom the operator provisioned” with “whoever points a domain at the hub”. **Decision:** the same **downward-only** rule a pushed ship link already obeys — a relayed URL must point AT OR BELOW the origin that reported it (own host or a **dot-anchored** subdomain; `http(s)` only; no embedded credentials; **refused whole at the length cap, never truncated**, because slicing a URL changes where it points). Containment runs FIRST and compares **hostnames** (a port is not a trust boundary); the unchanged “different door” test runs second and compares **hosts with the port** (a hall on another port really does open elsewhere). Net: a hall link survives only as a strict subdomain — or another port — of the project’s own origin. **One home:** `shipUrlWithinClientOrigin` (a private helper in `platform-identity.js`) is lifted to `origin-containment.js:urlWithinOrigin` and required by BOTH callers — the defect WAS two surfaces answering one question differently, and leaving the answer where only one could reach it is what let them differ. Its own file for the reason its siblings give: `platform-identity.js` is AT the 1500-line ratchet (the lift shrinks it by 11), and deliberately NOT re-exported there — a re-export is a third name for one answer. **Downward only, and the cost stated:** the mirror rule looks symmetric and is not (on a shared apex it lets `foo.github.io` publish links on bare `github.io`, and vouch for every neighbour), so a project whose hall is a SIBLING of its site (site `www.example.com`, hall `builders.example.com`) loses the glyph — the hall stays reachable, the hub stops vouching for the address. [ADR 0253](<redacted>.md) §2’s asymmetry again: **narrowing is free; widening is what costs**. One guard added on the way in: a hostless origin (`file:`, `data:` — a stored value is not a validated one) is refused outright, because the old suffix test would have matched a legal trailing-dot FQDN like `evil.example.` against the bare `.` it leaves behind. **Rejected:** mirroring the check in the browser ([ADR 0182](<redacted>.md) D2a — the page is not the gate, and a second copy of a containment rule is the drift this ends); a per-project allow-list of hall hosts; copying the rule into `project-stats.js` (the cheapest diff, and it re-creates the cause); truncating at the cap (fine for an `<img src>` display bound, wrong for a containment decision); folding both tests into one hostname comparison (it would silently drop a legitimate same-host, different-port hall).](<redacted>.md) | platform-identity / public feed / link containment |
345
347
 
346
348
  | 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 |
@@ -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. 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.",
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. PAGING DOES NOT RAISE THE CEILING IT SITS UNDER (task 1003561, absorbing task 1003560). `?limit=` only ever SHRINKS a page — parsePagination clamps it to the same 500 that used to be a silent hard truncation — so no request can aim more fan-out than one could before. `?offset=` reaches further down the catalog, and the only thing that costs is origins project-stats.js would have cached anyway: its 10-minute per-origin TTL, not the request count, is what bounds total outbound traffic. What paging BUYS is that the 501st project stops being invisible with nothing on the wire saying so.\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": {
@@ -17503,10 +17503,14 @@
17503
17503
  "GetProjectsFeaturedResponse": {
17504
17504
  "type": "object",
17505
17505
  "properties": {
17506
- "projects": {}
17506
+ "projects": {},
17507
+ "page": {
17508
+ "$ref": "#/components/schemas/PageInfo"
17509
+ }
17507
17510
  },
17508
17511
  "required": [
17509
- "projects"
17512
+ "projects",
17513
+ "page"
17510
17514
  ]
17511
17515
  },
17512
17516
  "GetProjectsResponse": {
package/docs/file-map.md CHANGED
@@ -245,7 +245,7 @@
245
245
  │ │ ├── secret-box.js / sensitive-surfaces.js ← at-rest secret encryption + the sensitive-file classifier
246
246
  │ │ ├── cascade-dispatch.js ← (dormant System 3, ADR 0079) the model-tier escalation ladder for autonomous dispatch
247
247
  │ │ ├── goal-scope-check.js / hierarchy-config.js / module-scope-map.js ← goal-scope protected-path checks, the configurable work-hierarchy tiers, and the module→file-glob map
248
- │ │ ├── project-door.js ← the project's COMPOSED join/apply door — visibility × join_grant × joinability → open | apply | invite_only | locked (BV1.R12 / task 1002331, ADR 0182 D6 + ADR 0245). PURE + kernel; published as api.projectJoinDoor() so the public POST /access-requests, the hall landing form, the CLI and the hub's join relay cannot answer one stranger differently
248
+ │ │ ├── project-door.js ← the project's COMPOSED join/apply door — visibility × join_grant × joinability → open | apply | invite_only | locked (BV1.R12 / task 1002331, ADR 0182 D6 + ADR 0247). PURE + kernel; published as api.projectJoinDoor() so the public POST /access-requests, the hall landing form, the CLI and the hub's join relay cannot answer one stranger differently — and since task 1003579 (ADR 0256) the SIGN-IN gate reads it too (openEnrollmentEnabled), which is why joinabilityMode() lives here rather than in auth-admission.js
249
249
  │ │ ├── path-match.js / repo-info.js / metrics.js / uptime-poller.js / app-pair.js ← touches[] overlap matcher, git-remote parser, Prometheus metrics endpoint, uptime poller, Claude-Desktop pairing store
250
250
  │ └── world/
251
251
  │ ├── terrain.js ← (Phase D + ADR 0012, CJS): TERRAIN + OBJECT enums + BLOCKING_TERRAINS + BLOCKING_OBJECTS (server twin of public/game/world/terrain.js)
@@ -1583,5 +1583,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1583
1583
  landed since 1.19.565 with no explicit bump. run 34056809932. (task 1002620)
1584
1584
  1.19.567 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1585
1585
  landed since 1.19.566 with no explicit bump. run 34057551680. (task 1002620)
1586
+ 1.19.568 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1587
+ landed since 1.19.567 with no explicit bump. run 34058174866. (task 1002620)
1588
+ 1.19.569 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1589
+ landed since 1.19.568 with no explicit bump. run 34058387853. (task 1002620)
1586
1590
  ---------------------------------------------------------------------------
1587
1591
  ```
@@ -322,7 +322,7 @@ const {
322
322
  // D1/D2a/D3; R06, task 1002325). Its own file for the publish-gate.js reason: this
323
323
  // file is at the size ratchet. Re-exported below, so the surface is unchanged.
324
324
  const {
325
- ORB_BY_STATE, orbForState, isStealthProject, feedShapeFor, listPublicProjects,
325
+ ORB_BY_STATE, orbForState, isStealthProject, feedShapeFor, listPublicProjects, PUBLIC_FEED_MAX_PAGE_SIZE,
326
326
  } = require('./projects-feed');
327
327
 
328
328
  // Upsert a catalog row (keyed by origin). Called when a client registers (so every
@@ -1426,6 +1426,7 @@ module.exports = {
1426
1426
  PROJECT_SHAPES,
1427
1427
  getProject,
1428
1428
  listPublicProjects,
1429
+ PUBLIC_FEED_MAX_PAGE_SIZE,
1429
1430
  listAllProjects,
1430
1431
  setProjectFeatured,
1431
1432
  ORB_BY_STATE,
@@ -3,7 +3,7 @@
3
3
  // The in-repo lever the orbs page reads. The publish-gate.js precedent — one question,
4
4
  // one file, so no two surfaces can answer it differently.
5
5
  //
6
- // THREE ANSWERS, and they belong together because each is only safe given the others:
6
+ // FOUR ANSWERS, and they belong together because each is only safe given the others:
7
7
  //
8
8
  // • WHO — `listPublicProjects`. A project is on the map iff `status = 'active' AND
9
9
  // publishable`, the derived identity gate, taken from publishableSql() so this feed
@@ -27,6 +27,13 @@
27
27
  // project to anyone who opens the network tab — and that page is a static site
28
28
  // outside this repo, so we could not enforce it there even in principle.
29
29
  //
30
+ // • IN WHAT ORDER — the ORDER BY, which is a covert answer and was leaking one until
31
+ // task 1003561: the feed sorted by `updated_at`, a column this projection
32
+ // deliberately withholds, so a black hole visibly MOVED whenever its hidden project
33
+ // was touched. A row is now ordered only by what it publishes, and a row that
34
+ // publishes nothing sits in a keyed-permuted anonymous block. See the ORDERING
35
+ // section below and ADR 0255.
36
+ //
30
37
  // TWO DELIBERATE FAIL-CLOSED CHOICES, because this is a privacy boundary, not a
31
38
  // rendering detail:
32
39
  // 1. AN UNKNOWN STATE DRAWS AS A BLACK HOLE. A fourth enum value added later and not
@@ -44,6 +51,8 @@
44
51
  // Proof: ../../tests/visible_projects_feed.mjs
45
52
  'use strict';
46
53
 
54
+ const { randomBytes } = require('node:crypto');
55
+
47
56
  const { pool: defaultPool } = require('../../src/module-api');
48
57
  const { publishableSql } = require('./publish-gate');
49
58
 
@@ -103,21 +112,88 @@ function feedShapeFor(project) {
103
112
  return anonymous;
104
113
  }
105
114
 
106
- // The feed's hard ceiling. Until R06 the listing was bounded by an Archon choosing to
107
- // feature a project; the swap to `publishable` replaced that human bound with self-service,
108
- // on a route that is public, CORS-open, unauthenticated AND drives one outbound stats fetch
109
- // per row. A cap is the cheap half of that trade — the honest half is pagination, which is
110
- // its own task; this constant only stops the tail from being unbounded meanwhile. Set far
111
- // above the live catalog, so it truncates nothing today.
112
- const PUBLIC_FEED_MAX_PROJECTS = 500;
113
-
114
- // Every project ON THE MAP, in the public projection (no owner_github_id, no status).
115
- // `visibility` is projected because feedShapeFor() derives each row's state + orb from it;
116
- // it is spent there and never reaches the wire. created_at is the LISTED date — when the
117
- // hub first catalogued the row, not when the project began (task 1002778). updated_at may
118
- // sort but never projects: feature toggles and client re-registration bump it, so as a
119
- // public "last updated" it would lie. `description` is the publish BAR, spent in the
120
- // predicate, never a card field.
115
+ // ── HOW THE FEED IS ORDERED, and why ordering is a privacy question ──────────
116
+ //
117
+ // A public list's ORDER BY is a covert column. Every row's position is the result of a
118
+ // comparison, and enough comparisons reconstruct the key they were made on. Until task
119
+ // 1003561 this feed sorted `featured DESC, updated_at DESC` — and `updated_at` is a
120
+ // column the projection below deliberately WITHHOLDS, because as a public "last updated"
121
+ // it would lie. Sorting by a withheld column publishes it anyway, one comparison at a time.
122
+ //
123
+ // For an identified row that is merely redundant. For a BLACK HOLE it defeats
124
+ // STEALTH_ANONYMOUS_KEEPS above. That allow-list refuses to carry ANY stable per-project
125
+ // key, precisely so an observer who kept yesterday's feed cannot diff it against today's
126
+ // to name the project that went stealth — and then the ordering handed them exactly that.
127
+ // A position is stable enough to follow one black hole across snapshots; it sits among
128
+ // NAMED neighbours whose own order is visible, which brackets the hidden row between two
129
+ // known timestamps; and it MOVES the moment the hidden project is touched. Black holes
130
+ // are supposed to be interchangeable and silent. Sorted by their own timestamps they were
131
+ // individually trackable and audibly alive.
132
+ //
133
+ // THE RULE THIS FILE NOW HOLDS: a row may be ordered only by what it publishes.
134
+ // • An IDENTIFIED row publishes `created_at` and `origin`, so it sorts by those, under
135
+ // `featured` (ADR 0182 D3's one remaining job — ranking). That order is a pure
136
+ // function of the wire payload: a reader holding nothing but the response can
137
+ // reproduce it, which is the strongest available statement that the sort leaks nothing
138
+ // the rows did not already say.
139
+ // • An ANONYMOUS row publishes nothing, so it is ordered by nothing about itself. The
140
+ // black holes are collated into ONE TRAILING BLOCK and permuted by a keyed hash: they
141
+ // neighbour no named row, so position carries no correlation, and the permutation
142
+ // re-deals on a fixed period so position is not a durable handle either.
143
+ //
144
+ // The decision, the alternatives weighed, and the two leaks this does NOT close (existence
145
+ // is public by design, and the identified set still CHANGES when a project goes stealth)
146
+ // are in ADR 0255.
147
+
148
+ // The states that render IDENTIFIED — derived from ORB_BY_STATE rather than re-listed, so
149
+ // the SQL and feedShapeFor() can never disagree about who is anonymous. A disagreement is
150
+ // not cosmetic: it would position a row by a `created_at` that its own wire row, redacted
151
+ // moments later, does not carry. Fail-closed in the SAME direction as orbStateOf() — a
152
+ // state this file does not know is not identified.
153
+ const IDENTIFIED_STATES = Object.freeze(
154
+ Object.keys(ORB_BY_STATE).filter((state) => state !== FALLBACK_PROJECT_STATE),
155
+ );
156
+
157
+ // The same fact, as SQL. Built only from string literals this file owns — never a caller's.
158
+ const IDENTIFIED_SQL = `(visibility IN (${IDENTIFIED_STATES.map((s) => `'${s}'`).join(', ')}))`;
159
+
160
+ // How often the anonymous block is re-dealt. Within one period the permutation is stable,
161
+ // so the pages of one listing agree with each other; across periods the whole block is
162
+ // re-permuted, so a rank held today is not the same black hole's rank tomorrow. That
163
+ // rotation is what stops POSITION from quietly becoming the stable per-project key the
164
+ // payload goes to such lengths to refuse.
165
+ const ANONYMOUS_ORDER_EPOCH_MS = 24 * 60 * 60 * 1000;
166
+
167
+ // The permutation key, and its SECRECY is the load-bearing part. Origins are guessable —
168
+ // they are domain names — so a salt an observer could predict would let them hash their
169
+ // candidate origins, match the ranks and de-anonymise the entire block in one pass.
170
+ // Random per process, because this value only ever ORDERS rows: nothing persists it,
171
+ // compares it across restarts or reads it back, so there is no key to configure,
172
+ // distribute or leak, and a restart simply re-deals early.
173
+ const ANONYMOUS_ORDER_KEY = randomBytes(16).toString('hex');
174
+
175
+ // The salt for one listing: the secret, then the current epoch. Bound as a query
176
+ // PARAMETER, never interpolated into SQL. PURE given `now`.
177
+ function anonymousOrderSalt(now = Date.now()) {
178
+ return `${ANONYMOUS_ORDER_KEY}:${Math.floor(now / ANONYMOUS_ORDER_EPOCH_MS)}`;
179
+ }
180
+
181
+ // The feed's per-request ceiling AND its default page size — deliberately one number.
182
+ //
183
+ // It bounds FAN-OUT, not merely response size: this route is public, CORS-open and
184
+ // unauthenticated, and each COLD row drives up to four outbound fetches at somebody else's
185
+ // server, so the page size is the bound on how much traffic a single request can aim. The
186
+ // other two bounds live elsewhere and both still hold — the per-IP limiter (120 reads/60s,
187
+ // ADR 0252 §5.3) bounds how OFTEN, and project-stats.js's 10-minute per-origin cache bounds
188
+ // the TOTAL. That cache is why paging deeper cannot multiply outbound traffic: a second
189
+ // page reaches origins the cache would have covered anyway, and re-reading any page is free.
190
+ //
191
+ // DEFAULTING TO THE MAXIMUM IS THE COMPATIBILITY GUARANTEE, not laziness. 500 was the old
192
+ // hard LIMIT, so a caller that sends no `?limit=` gets exactly what it got before — and one
193
+ // consumer of this feed is a static orbs site in ANOTHER REPO that cannot be updated by
194
+ // this commit. A smaller default would have silently shrunk somebody else's map.
195
+ const PUBLIC_FEED_MAX_PAGE_SIZE = 500;
196
+
121
197
  // A PULLABLE row is on the map only once it has actually answered (R08, task 1002327;
122
198
  // ADR 0182 D5 via ADR 0252 §4). "Unreported => dark matter" is scoped, deliberately and
123
199
  // narrowly, to rows whose door the hub has never successfully read: a control-plane row
@@ -133,13 +209,39 @@ const PUBLIC_FEED_MAX_PROJECTS = 500;
133
209
  // empty the public map on deploy. Both halves are load-bearing.
134
210
  const DOOR_KNOWN_SQL = "(visibility_source = 'control-plane' OR visibility_reported_at IS NOT NULL)";
135
211
 
136
- async function listPublicProjects({ pool = defaultPool } = {}) {
212
+ // Every project ON THE MAP, in the public projection (no owner_github_id, no status) — ONE
213
+ // PAGE of it (task 1003561, which absorbed task 1003560). `visibility` is projected because
214
+ // feedShapeFor() derives each row's state + orb from it; it is spent there and never reaches
215
+ // the wire. created_at is the LISTED date — when the hub first catalogued the row, not when
216
+ // the project began (task 1002778) — and it is now also the identified block's sort key,
217
+ // which is exactly the point: the feed sorts by something it publishes. `description` is
218
+ // the publish BAR, spent in the predicate, never a card field. `updated_at` no longer
219
+ // appears at all — not in the projection, and since task 1003561 not in the ORDER BY either.
220
+ //
221
+ // PAGING IS `?limit=&offset=` (ADR 0119's contract, which this route was the last public
222
+ // list to hold out on). No cursor, and that is a security property as much as a style one:
223
+ // an opaque cursor over this feed would have to encode a sort position, and the obvious
224
+ // encoding — the last row's timestamp — would reintroduce the very side-channel the ORDER BY
225
+ // above was rewritten to close. An offset encodes nothing about any row.
226
+ //
227
+ // The caller's window is clamped HERE as well as at the route, because this is a
228
+ // module-public function and the ceiling is a fan-out bound: it must not depend on every
229
+ // future caller remembering to pass one.
230
+ async function listPublicProjects({ pool = defaultPool, limit = PUBLIC_FEED_MAX_PAGE_SIZE, offset = 0, now = Date.now() } = {}) {
231
+ const size = Number.isInteger(limit) && limit > 0
232
+ ? Math.min(limit, PUBLIC_FEED_MAX_PAGE_SIZE)
233
+ : PUBLIC_FEED_MAX_PAGE_SIZE;
234
+ const from = Number.isInteger(offset) && offset > 0 ? offset : 0;
137
235
  const { rows } = await pool.query(
138
236
  `SELECT origin, name, tagline, art_url, type, template, created_at, visibility
139
237
  FROM platform_identity_projects
140
238
  WHERE status = 'active' AND ${publishableSql()} AND ${DOOR_KNOWN_SQL}
141
- ORDER BY featured DESC, updated_at DESC
142
- LIMIT ${PUBLIC_FEED_MAX_PROJECTS}`,
239
+ ORDER BY ${IDENTIFIED_SQL} DESC,
240
+ (${IDENTIFIED_SQL} AND featured) DESC,
241
+ (CASE WHEN ${IDENTIFIED_SQL} THEN created_at END) DESC,
242
+ (CASE WHEN ${IDENTIFIED_SQL} THEN origin ELSE md5(origin || $1::text) END) ASC
243
+ LIMIT $2::int OFFSET $3::int`,
244
+ [anonymousOrderSalt(now), size, from],
143
245
  );
144
246
  return rows;
145
247
  }
@@ -148,11 +250,15 @@ module.exports = {
148
250
  ORB_BY_STATE,
149
251
  FALLBACK_PROJECT_STATE,
150
252
  STEALTH_ANONYMOUS_KEEPS,
253
+ IDENTIFIED_STATES,
254
+ IDENTIFIED_SQL,
255
+ ANONYMOUS_ORDER_EPOCH_MS,
256
+ anonymousOrderSalt,
151
257
  orbStateOf,
152
258
  orbForState,
153
259
  isStealthProject,
154
260
  feedShapeFor,
155
- PUBLIC_FEED_MAX_PROJECTS,
261
+ PUBLIC_FEED_MAX_PAGE_SIZE,
156
262
  DOOR_KNOWN_SQL,
157
263
  listPublicProjects,
158
264
  };
@@ -5,6 +5,8 @@
5
5
  // (active + publishable), each carrying its `state`
6
6
  // and `orb` render type, stealth rows anonymised
7
7
  // server-side, dark matter excluded (ADR 0182 D2a/D3).
8
+ // Paged `?limit=&offset=` (ADR 0119), defaulting to the
9
+ // whole 500-row ceiling so pre-paging callers are unchanged.
8
10
  // The PATH keeps the old word: it is a rank:public,
9
11
  // CORS-open URL that a static site and other origins
10
12
  // already fetch, and renaming it would break them for
@@ -30,7 +32,8 @@ const projectCatalog = require('../project-catalog');
30
32
  const projectStats = require('../project-stats');
31
33
  const inviteAuthz = require('../invite-authz');
32
34
 
33
- const { pool, requireBuilder, requirePermission, validateOrRespond, parseId, corsPublicGet } = api;
35
+ const { pool, requireBuilder, requirePermission, validateOrRespond, parseId, corsPublicGet,
36
+ parsePagination, pageMeta } = api;
34
37
  const log = api.logger('platform-identity');
35
38
 
36
39
  // a catalog origin's host, the way the hub's sky joins it to an owned instance
@@ -233,9 +236,24 @@ module.exports = function projectsRoutes() {
233
236
  // provisioned) and replaced it with "whoever points a domain at us", which is what makes
234
237
  // the ceiling load-bearing rather than prudent. It runs BEFORE the handler, so a refused
235
238
  // read costs no query and no fetch.
236
- router.get('/projects/featured', api.publicProjectFeedRateLimit, async (_req, res) => {
239
+ //
240
+ // PAGING DOES NOT RAISE THE CEILING IT SITS UNDER (task 1003561, absorbing task
241
+ // 1003560). `?limit=` only ever SHRINKS a page — parsePagination clamps it to the same
242
+ // 500 that used to be a silent hard truncation — so no request can aim more fan-out
243
+ // than one could before. `?offset=` reaches further down the catalog, and the only
244
+ // thing that costs is origins project-stats.js would have cached anyway: its 10-minute
245
+ // per-origin TTL, not the request count, is what bounds total outbound traffic. What
246
+ // paging BUYS is that the 501st project stops being invisible with nothing on the wire
247
+ // saying so.
248
+ router.get('/projects/featured', api.publicProjectFeedRateLimit, async (req, res) => {
237
249
  try {
238
- const projects = await pi.listPublicProjects({ pool });
250
+ // Default page = the whole ceiling, so a caller that sends no params (the static
251
+ // orbs site in another repo, the apex landing band) gets exactly what it got before.
252
+ const { limit, offset } = parsePagination(req, {
253
+ defaultLimit: pi.PUBLIC_FEED_MAX_PAGE_SIZE,
254
+ maxLimit: pi.PUBLIC_FEED_MAX_PAGE_SIZE,
255
+ });
256
+ const projects = await pi.listPublicProjects({ pool, limit, offset });
239
257
  // A stealth project is on the map ANONYMOUSLY (ADR 0182 D2a), so its origin
240
258
  // is kept out of both enrichments below: asking its own public API for its
241
259
  // builders and their avatars would put identity on the wire, and tell that
@@ -282,6 +300,13 @@ module.exports = function projectsRoutes() {
282
300
  // rebuilds the row from an allow-list of the two keys a black hole may carry
283
301
  // (ADR 0182 D2a). Redacting any earlier would be silently undone by the
284
302
  // spreads above.
303
+ // `page` is ADR 0119's block, added beside the existing `projects` key rather than
304
+ // wrapping it: every consumer destructures `data.projects` (Explore, the projects
305
+ // hub sky, the apex band, the generated client, a static orbs site in another repo),
306
+ // so this is additive on the wire and breaks none of them. A caller learns there is
307
+ // more the standard way — `projects.length === page.limit` means ask for the next
308
+ // offset. No `total`: counting is a second pass over the same predicate for a number
309
+ // nothing reads today, and pageMeta() makes it optional for exactly that reason.
285
310
  res.json({
286
311
  projects: projects.map((p) => pi.feedShapeFor({
287
312
  ...p,
@@ -289,6 +314,7 @@ module.exports = function projectsRoutes() {
289
314
  ...(stats[p.origin] || projectStats.NULL_STATS),
290
315
  ...planetOf(p),
291
316
  })),
317
+ page: pageMeta(limit, offset),
292
318
  });
293
319
  } catch (err) {
294
320
  console.error('[platform-identity] GET /projects/featured', err);
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.567",
3
+ "version": "1.19.569",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.567",
9
+ "version": "1.19.569",
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.567",
3
+ "version": "1.19.569",
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",
@@ -534,9 +534,11 @@ const KERNEL_FILES = [
534
534
  // route-rank-check.js and goal-scope-check.js above (a trust decision the doorway
535
535
  // hands to modules so no module re-derives it). The doorway re-exports it, so
536
536
  // leaving it off this roster reads as a kernel->non-kernel leak; it imports only
537
- // branding.js and auth-admission.js — both on this roster — so joining costs
538
- // nothing and buys the import-direction guarantee on the file that decides who
539
- // may knock.
537
+ // branding.js, instance-config.js and logger.js — all on this roster — so
538
+ // joining costs nothing and buys the import-direction guarantee on the file that
539
+ // decides who may knock. (Task 1003579 removed its require of auth-admission.js
540
+ // and moved joinabilityMode the other way: the gate now reads the composed door,
541
+ // so the reader had to sit with the composer or the two files would have cycled.)
540
542
  'src/bongos/project-door.js',
541
543
  ];
542
544
 
@@ -29,6 +29,10 @@ const seams = require('../module-seams');
29
29
  const ic = require('../instance-config');
30
30
  const { branding, userAgent } = require('../branding');
31
31
  const { loadIdpConfig } = require('./auth-config.js');
32
+ // task 1003579: the sign-in gate reads the project's COMPOSED join door, not the
33
+ // single `joinability` knob it used to. joinabilityMode moved into that file with
34
+ // the two knobs it composes against and is re-exported below, unchanged.
35
+ const { effectiveJoinDoor, joinabilityMode } = require('./project-door.js');
32
36
  // task 1003208: structured logging — was console.*. A core file logs through
33
37
  // the kernel logger directly; only MODULES go via api.logger (the doorway).
34
38
  const log = require('./logger').logger.child({ src: 'auth-admission' });
@@ -201,57 +205,41 @@ async function reportActivityRollupForShippedTask(task) {
201
205
  // already exists for their github_id) ALWAYS passes — the gate never locks out
202
206
  // someone already enrolled.
203
207
  //
204
- // The project's JOINABILITY decides which of those a first-timer gets (task
205
- // 1003044, goal 1000058; the owner's 2026-08-16 decision). The owner picks it on
206
- // the hub's manage page; the platform pushes it into this instance's branding
207
- // pack as project.joinability (ADR 0190) and it is read through branding(),
208
- // like platformVisibility — memoized for the process, applied by a restart:
209
- // open a first-time GitHub sign-in self-enrols at xenos — no request,
210
- // no invite (what OTB_OPEN_XENOS_ENROLLMENT used to switch on)
211
- // apply (the default — today's behaviour) a stranger files an access
212
- // request and an Archon marks it 'invited'; then sign-in admits
213
- // invite_only the public POST /access-requests refuses; only an access
214
- // request an Archon CREATED at 'invited' admits a first-timer
215
- // The legacy OPEN_XENOS_ENROLLMENT flag (ADR 0050) still opens an *apply*
216
- // project, so a self-hosted instance that set it keeps its open door across the
217
- // upgrade — but it does not override an explicit invite_only: the stricter,
218
- // newer choice wins. An unreadable pack fails CLOSED (invite_only): when the
219
- // door's policy is unknown the widest door is never the fallback.
220
- function legacyOpenEnrollmentFlag() {
221
- const v = ic.resolveEnv('OPEN_XENOS_ENROLLMENT');
222
- return v === '1' || v === 'true';
223
- }
224
-
225
- // joinabilityMode — the project's effective membership door policy: 'open' |
226
- // 'apply' | 'invite_only'. The one reader every admission surface uses (both
227
- // sign-in flows, the public access-request route via the doorway, the manifest,
228
- // the refusal pages), so none of them can disagree about which door this is.
229
- // `read` is injectable for the unit tests only (the pack is memoized).
230
- let warnedUnknownJoinability = false;
231
- function joinabilityMode(read = branding) {
232
- let v;
233
- try { v = read().project?.joinability; } catch (err) {
234
- log.error('[auth] joinability: branding pack unreadable — treating the project as invite-only:', err.message);
235
- return 'invite_only';
236
- }
237
- if (v === 'open' || v === 'invite_only') return v;
238
- if (v === undefined || v === null || v === '' || v === 'apply') {
239
- return legacyOpenEnrollmentFlag() ? 'open' : 'apply';
240
- }
241
- // A value this core cannot read ('invite-only' with a hyphen, a truncated
242
- // write) is a policy it does not know — never the wider door.
243
- if (!warnedUnknownJoinability) {
244
- warnedUnknownJoinability = true;
245
- log.error(`[auth] joinability: unrecognised value ${JSON.stringify(v)} — treating the project as invite-only`);
246
- }
247
- return 'invite_only';
248
- }
208
+ // WHICH of those a first-timer gets is the project's JOIN DOOR — and the door is
209
+ // three owner-set knobs composed into one answer, not the `joinability` knob
210
+ // alone (visibility × joinGrant × joinability; ADR 0182 D6, ADR 0247, and
211
+ // src/bongos/project-door.js for the table). The owner picks them on the hub's
212
+ // manage page; the platform pushes them into this instance's branding pack (ADR
213
+ // 0190) and they are read through branding() — memoized for the process, applied
214
+ // by a restart.
215
+ //
216
+ // THE COMPOSITION IS WHY THIS READS THE DOOR AND NOT THE KNOB (task 1003579).
217
+ // ADR 0247 shipped the composed door at the public application endpoint and left
218
+ // this gate reading `joinability` alone, so an owner who set their project
219
+ // `private` (or `stealth`, or joinGrant `view`) while leaving `joinability: open`
220
+ // got a door that queued applications and a sign-in that still self-enrolled
221
+ // strangers — half of D6's table unenforced. Both surfaces now ask the same
222
+ // function, which is the only arrangement in which they cannot answer one
223
+ // stranger differently.
249
224
 
250
- // openEnrollmentEnabled — true when a first-time GitHub account self-enrols
251
- // (joinability 'open'). Kept as the name the four sign-in call sites and the
252
- // manifest read; the decision itself is joinabilityMode().
253
- function openEnrollmentEnabled() {
254
- return joinabilityMode() === 'open';
225
+ // openEnrollmentEnabled — true when a first-time GitHub account self-enrols: the
226
+ // project's composed join door is `open`. Kept as the name the four sign-in call
227
+ // sites, webAdmissionStatus and the public manifest read, so composing the
228
+ // ceiling in changed one expression rather than six surfaces (the same SR-19 move
229
+ // ADR 0194 made when joinabilityMode replaced the raw env flag).
230
+ //
231
+ // A door that is `apply`, `invite_only` or `locked` all answer false here, and
232
+ // deliberately with the SAME false: the gate must not leak which knob closed it
233
+ // (ADR 0247 D3 — stealth's anonymity is a privacy primitive, so a refusal may
234
+ // never be read backwards into a state). It passes NO publish verdict, so the
235
+ // `locked` branch cannot fire from here — this plane holds no trustworthy
236
+ // dark-matter verdict yet (ADR 0247 D4).
237
+ //
238
+ // `deps` is effectiveJoinDoor's own injection seam ({ branding, joinabilityMode,
239
+ // publishable }), forwarded verbatim so a unit test can drive the whole matrix
240
+ // without booting a pack. Production calls it with none.
241
+ function openEnrollmentEnabled(deps = {}) {
242
+ return effectiveJoinDoor(deps) === 'open';
255
243
  }
256
244
 
257
245
  // PURE admission decision shared by both flows so they CANNOT drift (SR-19).
@@ -259,6 +247,12 @@ function openEnrollmentEnabled() {
259
247
  // builder row (or null) from getBuilderByGithubId; `approved` is the result of
260
248
  // hasInvitedAccessRequest. Blocks only when: not already a builder AND open
261
249
  // enrollment is off AND not invite-approved. Exported for direct unit testing.
250
+ //
251
+ // Note what the visibility ceiling does and does not close (task 1003579): it
252
+ // narrows SELF-enrolment only. An invited first-timer, an existing builder and
253
+ // the configured firstAdmin are all admitted to a `private` or `stealth` project
254
+ // exactly as before — an owner who takes their project off the map is closing the
255
+ // front door, not evicting the people they let in.
262
256
  function firstTimeAdmissionBlocked({ existing, approved, login }) {
263
257
  if (existing || openEnrollmentEnabled() || approved) return false;
264
258
  // The configured firstAdmin is always admitted on a fresh instance so the
@@ -403,6 +397,10 @@ async function safeSyncRankRole(builder) {
403
397
  module.exports = {
404
398
  firstAdminLogin,
405
399
  firstTimeAdmissionBlocked,
400
+ // Re-exported, not defined here (task 1003579 moved it to project-door.js with
401
+ // the two knobs it composes against). auth.js, the doorway's api.joinabilityMode
402
+ // and four test files import it by this path, and the read-back it answers —
403
+ // "what did the owner set?" — is still an admission-surface question.
406
404
  joinabilityMode,
407
405
  maybeSeatFirstAdmin,
408
406
  seedKickoffChecklist,