@bongos/core 1.19.586 → 1.19.588

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.
@@ -353,3 +353,5 @@ This keeps the decision history honest and traceable.
353
353
  | 0259 | [**A project’s departure from the public list is public, and the copy says so** ([task 1003672](https://cloudbongos.com/builders#/task/1003672) · goal 1000046 — *Project creation*). Decides the leak [ADR 0255](<redacted>.md) §6.2 deliberately filed rather than implied fixed. The public projects feed publishes a named list plus a count of anonymous rows, so when a project flips to stealth its named row LEAVES the identified block while the count RISES BY ONE — *“Mercury is gone, and there is one more black hole”* names it, and the moment, from a single diff of two snapshots. **Everything [ADR 0182](<redacted>.md) D2a does is defeated by it**, because the identity was never carried ON the anonymous row (`STEALTH_ANONYMOUS_KEEPS` is an allow-list precisely so a column added next year is anonymous by default) — it is carried by the ABSENCE of the named row that used to sit beside it. **Larger than the channel 0255 closed**, which needed repeated sampling and correlation; this one is exact and needs two reads. No `ORDER BY` change touches it: it is inherent in publishing a named list at all, so every fix is a product decision with a visible cost and the owner’s call, not a builder’s. **Decision: accept it, and make the copy say so.** Two reasons it is the right end of the trade and not merely the cheapest — (1) it is ALREADY the honest reading of the existing promise, since D2a made existence and count public *by design*, and a count that is public and truthful is a count whose CHANGES are public; (2) both alternatives damage the control they defend. A **delay window** means the owner clicks the privacy setting and is not private yet *without knowing it*, converting an information leak into a false belief — strictly worse, and the wrong direction for a privacy control. **Decoys** spend the count’s truthfulness (it is truthful today) on a guarantee that is still only probabilistic, and a decoy scheme needs some way to tell padding from real rows — the exact stable per-project key D2a forbids. The promise stealth actually keeps is about the project’s CONTENTS, not the TRANSITION: an anonymous row stays anonymous (no name, tagline, art, origin, or key surviving to the next snapshot); what is not private is stepping off a published list readers may keep copies of. So the copy — which read *“its name, and everything else about it, stay yours”*, every clause true and the paragraph as a whole overselling, because a reader takes it to cover the switch — now names the boundary in BOTH places an owner meets the setting (the choice’s blurb and the what-changes fold), in the plain register 0182’s interview fixed for this card. **A privacy control that oversells is worse than one that admits a limit**, the same standard 0255 §6 set for itself. **No behaviour changes** in `projects-feed.js` — the feed, the redaction allow-list and the keyed rotating permutation are decided FINISHED, not insufficient. `tests/projects_hub.mjs` pins the sentence in both surfaces (the copy IS the fix, so a future edit tightening the blurb must not quietly drop it) and re-applies the no-space-metaphor ban to it. Carries 0255 §6.3’s smaller residual so both live in one place: within one 24h epoch the permutation is stable, so same-day snapshots tell black holes apart (not name them) — accepted on the same reasoning, since the fix is a per-request permutation, which breaks pagination exactly as `ORDER BY random()` was rejected for. Guards recorded for any future attempt: no stable per-project key on an anonymous row, and no anonymous ordering an observer can predict or correlate. Rejected: the delay; decoys; dropping the named list (protects a privacy nobody asked for at the cost of the feature); and saying nothing, which is the failure mode this closes.](<redacted>.md) | platform-identity / privacy boundary / copy honesty |
354
354
  | 0260 | [**An application IS the consent, and the hub’s own echo is the gate** ([task 1002972](https://cloudbongos.com/builders#/task/1002972) · goal 1000045, criterion C4). Privacy spec **D7** asks that a reviewer see what the applicant’s OWN profile rules would already show, evaluated LIVE at review time. The rule is ONE port composing the two EXISTING views — `getPublicProfileExtras` for a public account, `recruiter-sliver`’s own `sliverShapeFor` for a private one — because a third five-key lookalike **is** the new disclosure class D7 forbids. **The one deliberate difference from the D3 sliver:** `getRecruiterSliver` floors on recruiting REACH, and reusing it verbatim would be wrong in the direction that looks safe — `recruiter_discoverable` defaults to FALSE for a private account, so a private builder who deliberately applied would show their reviewer NOTHING and C4 would be satisfied by an empty box. D7 settles it: *“applying is an explicit act”*. The swap relaxes REACH only; `accountActiveSql` (active **and** terms accepted) and `hide_stats` still floor it, and the proof asserts both directions on the same account so the tempting refactor cannot pass quietly. **The federated route’s gate is the whole security story** ([ADR 0205](<redacted>.md) / security report 1000027): client credentials name a PROJECT and never a person, so `POST /sso/applicant-profile` authenticated alone is a bulk disclosure oracle over every account on the platform, private ones included. `applicationBacksProfileRead` demands a HUB-WRITTEN echo for exactly (this account, this client) inside the same 30-day window — a row only the hub’s own join relay creates — refusing **404, never 403**, and BEFORE any account is read (asserted on the statement log, because a gate that refuses after reading has already done the disclosure work). LIVE means nothing is stored at either end; the queue keys on the VOUCH and never on `github_login` (the one PUBLIC unauthenticated write makes an unvouched login an impersonation surface); and the hub client secret stays in core behind a narrow doorway port. **The path is complete but DORMANT until R09 ([task 1002285](https://cloudbongos.com/builders#/task/1002285)) ships the vouch writer** — nothing writes `applicant_github_id` today, and that dependency was missing from 1002972’s graph. Rejected: routing the port through `getRecruiterSliver`, a snapshot at apply time, resolving by `github_login`, and putting `loadIdpConfig` on the doorway.](<redacted>.md) | platform identity / privacy |
355
355
  | 0261 | [**A preselect always carries a reason; the bundle’s summary is the floor** ([task 1003684](https://cloudbongos.com/builders#/task/1003684) · goal 1000046 — *Project creation*). Resolves a collision between two rules that were each right alone. [ADR 0243](<redacted>.md) made `adjustments` a **delta** — a team-shape rule that fires without moving anything claims nothing, because a sentence explaining a change that did not happen is a claim the owner cannot check. [ADR 0237](<redacted>.md) says a preselect the owner cannot see a reason for is one they must audit, which is worse than no preselect at all. The two collide whenever a type’s bundle **already contains** what a rule would add: `game` is `[dev-box, discord]` and the `small-team` rule adds `dev-box`, so the rule fires, moves nothing, and correctly reports `adjustments: []`. Confirmed on live core 1.19.580 — `byTeamShape[small-team]` = `{"bundle":["dev-box","discord"],"adjustments":[]}`. The owner then reached step 4 and saw **two extras switched on with no reason beside them**, `#modWhy` an empty hidden div: exactly what 0237 exists to prevent, produced by 0243 behaving correctly. Filed as a dropped adjustment; it was not — the engine is right, and the panel simply had ONE voice and fell silent when the rule had nothing to say. **Decision: the why-line has two voices and the type’s is the floor.** A real adjustment still speaks for itself and is never displaced; when none moved, the type’s own curated `summary` explains the preselect; when the owner has answered the picker themselves the panel stays silent (unchanged — a reason handed back for a toggle they just flipped reads as the panel arguing with them); a bundle with no summary still says nothing, because a floor is not an invention. **No new copy, endpoint or field** — the summary was authored for this job in `starter-bundles.js`, served on every row of `GET /provisioning/starter-bundles`, and rendered NOWHERE in the product until now. 0243 is not weakened: the delta stays a delta and no rule is credited with a change it did not make. The pin `tests/projects_hub_module_picker.mjs` carried a fixture with **no `summary` field**, which made four “no adjustment, no sentence” cases pass for the wrong reason; the fixture now carries the real summaries and those cases assert the new contract, including explicitly that no RULE sentence is invented for a change that did not happen. Rejected: making the rule claim a no-op change (re-introduces the unverifiable claim 0243 removed); widening the bundles so no rule is ever redundant (contorts curated presets for a rendering concern, and the redundancy returns on the next edit); and leaving it silent, declined by the owner once the trade-off was put to them directly.](<redacted>.md) | provisioning / starter bundles / owner-facing copy |
356
+ | 0262 | [**A bug never lands in the inbox: the system names the home the reporter didn't** ([task 1003691](https://cloudbongos.com/builders#/task/1003691) · goal 1000086 — *Strict versioning*). `routing.js` returned early on a goal-less filing, so its own `kind='bug'` hard-land branch was UNREACHABLE without a goal — `kind` only ever chose a routed task's STATUS, never whether it routed — and a reported defect sat in `idea_inbox` awaiting the triage pass criterion C1 exists to remove. The premise that [ADR 0250](<redacted>.md) D4 and [ADR 0235](<redacted>.md) contradict each other is FALSE: 0235's exemption is an advisory silence in `goal-advisory.adviseGoal`, and its actual decision already requires a real `goal_id`. So nothing is amended; this is the explicit decision 0235 §6 said would be needed. **D1** a homeless bug gets the version's MAINTENANCE goal (R18's design), find-or-created under an advisory lock inside the route's own transaction — not the catch-all R11 is deleting, and adding no `allowCatchAll` caller. **D2** the gate is the VECTOR (`api`, `discord-bugs`), not the kind: `#ideas` passes no kind, so the classifier guesses one from keywords, and gating on kind alone would silently overturn [ADR 0234](<redacted>.md)'s owner-interview decision. Rejected: goal-less bug tasks (breaks D4 a day after it landed); rejecting the filing (Discord cannot retry, so the report is lost); the catch-all (repopulates the bucket being drained); building R18 whole first.](<redacted>.md) | work intake / idea routing / the version-goal invariant |
357
+ | 0263 | [**How a version closes: auto, early, roll-forward, and the maintenance exemption** ([task 1003593](https://cloudbongos.com/builders#/task/1003593) · goal 1000086 — *Strict versioning*). [ADR 0250](<redacted>.md) D5 decided a version *can* close; this is the design pass that makes its five build tasks mechanical, so they do not each invent their own refusal codes and payload shapes and meet only in production. **The asymmetry that shrinks the feature:** auto-close fires when the last non-maintenance goal achieves — so by construction every non-maintenance goal is already closed and the set needing a disposition is EMPTY. All the machinery (the disposition map, the refusal that returns the goals, successor lineage) belongs to EARLY close alone; R16 is small and R12 carries the weight. **Auto-close hangs off BOTH achievement writers** (`db-goals.achieveGoalIfComplete` and the `done-when` cascade — the deliberate duplicate D2 already governs, a require cycle being the reason they are not one helper), on the caller `exec` so it sees the ship's uncommitted flip, and **may never fail a ship**: wrapped and swallowed like `closeCompletedWorkForShip`, idempotent under `WHERE status='building'`, so a failure leaves a version un-closed (self-healing on the next reconciler sweep) and never half-closed. **Early close** is the R10 archive two-step verbatim in spirit — refuse with `version_holds_open_goals` **returning the goals**, then proceed with a per-goal `roll_forward`/`abandon` map and a required `reason`; six named codes, because the callers are agents. **Two briefs are corrected by measurement:** the lineage column is `succeeded_by_goal_id` (migration 160, on the OLD row pointing forward) — not the `succeeds_goal_id` both 0250 and R17's brief name, so R17 needs NO migration; and R18's `is_maintenance` flag is load-bearing rather than cosmetic, because answering the close count with a `title NOT LIKE` would let a hand-titled goal silently exempt itself from the gate that decides when a VERSION closes. Promotion runs inside the close txn and BEFORE roll-forward (`ensureMaintenanceGoal` returns null for a non-`building` version, so a carry-over run first silently carries nothing); zero planning versions is not an error; R19's one migration should carry the `building` AND `planning` partial unique indexes so the promotion race resolves like a 23505 rather than a prayer. Rejected: auto-creating the successor version inside a close (the "fake hotfix version" escape hatch 0250 built the override counter to prevent), writing `limitations/<version>-shipped.md` from the route (file I/O in a ship transaction — it stays R23's CLI work), and a version reopen.](<redacted>.md) | version lifecycle / scope closure |
@@ -1621,5 +1621,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1621
1621
  landed since 1.19.584 with no explicit bump. run 34165392436. (task 1002620)
1622
1622
  1.19.586 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1623
1623
  landed since 1.19.585 with no explicit bump. run 34167072966. (task 1002620)
1624
+ 1.19.587 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1625
+ landed since 1.19.586 with no explicit bump. run 34172785860. (task 1002620)
1626
+ 1.19.588 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1627
+ landed since 1.19.587 with no explicit bump. run 34178696372. (task 1002620)
1624
1628
  ---------------------------------------------------------------------------
1625
1629
  ```
@@ -248,6 +248,22 @@ async function recordInboundBug({ discordUserId, content, imageUrls = [], messag
248
248
  filerRank,
249
249
  goalId: goal ? goal.id : null,
250
250
  suggestedPriority: 3,
251
+ // task 1003691 (ADR 0262): the trusted-vector claim, and it is claimed ONLY
252
+ // FOR A LINKED BUILDER — `builder &&`, not the bare string.
253
+ //
254
+ // This channel earns the vector through guard 2 and guard 3 above, and both
255
+ // are identity-bound: the rank-aware landing needs a rank, and the rolling
256
+ // rate cap is counted per builder id. An UNLINKED author has neither. Handing
257
+ // them the vector unconditionally would let anyone who can post here mint
258
+ // unattributed backlog tasks at no cost — breaking guard 1's promise at the
259
+ // top of this file ("an unlinked author cannot inject claimable work... it no
260
+ // longer mints anything"), which is the security property ADR 0047 bought.
261
+ //
262
+ // With no vector the filing falls back to the inbox exactly as it did before
263
+ // ADR 0262: the report is still kept, still re-hosts its images, and still
264
+ // reaches a human through triage. Nothing is lost except the ability to write
265
+ // to the task table without an identity. Pinned as F16.
266
+ vector: builder ? 'discord-bugs' : null,
251
267
  };
252
268
 
253
269
  // THE HOME IS A SUGGESTION, NOT A CONDITION OF BEING HEARD. routeIdeaToGoal is
@@ -255,8 +271,13 @@ async function recordInboundBug({ discordUserId, content, imageUrls = [], messag
255
271
  // scope-wall verdict, a goal archived mid-flight — rolls the idea row back too
256
272
  // and the report is gone. That is the exact failure this task exists to end, and
257
273
  // it would be caused by a goal WE guessed at, not by anything the reporter did.
258
- // So a refusal drops the guess and files the report homeless: it lands in the
259
- // inbox for triage, which is strictly what would have happened before we tried.
274
+ // So a refusal drops the guess and re-files the report with no goal at all.
275
+ //
276
+ // Since task 1003691 that no longer means the inbox: a homeless bug from a
277
+ // trusted vector routes to the version's MAINTENANCE goal, so dropping a bad
278
+ // guess costs the reporter a triage pass rather than the whole report. A
279
+ // sub-Metic refused from a protected-scope goal therefore still gets a real
280
+ // task — at 'backlog', in an unscoped goal, with the protected goal untouched.
260
281
  let idea;
261
282
  try {
262
283
  idea = await deps.captureIdea(capture);
@@ -189,6 +189,15 @@ async function recordInboundIdea({ discordUserId, content, imageUrls = [], messa
189
189
  // tests/filing_abuse_matrix.mjs, which greps this exact call for it).
190
190
  suggestedGoalId: hint ? hint.id : null,
191
191
  suggestedVersion,
192
+ // task 1003691 (ADR 0262): named EXPLICITLY so this vector's exclusion is a
193
+ // written fact rather than an absence someone has to notice. '#ideas' is not
194
+ // on routing.HOMELESS_ROUTING_VECTORS, so a filing here NEVER becomes a task
195
+ // — which is ADR 0234's owner-interview decision ("#ideas files with hints
196
+ // only, never routes"), and the reason it holds even now that a homeless bug
197
+ // routes elsewhere: the kind here is a KEYWORD GUESS (inbox.classify fires
198
+ // 'bug' on fix/broken/crash), and a guess must not mint work. Bug reports
199
+ // from Discord have their own channel, #bugs, which does route.
200
+ vector: 'discord-ideas',
192
201
  });
193
202
 
194
203
  await auditDiscordBot(deps, {
@@ -0,0 +1,51 @@
1
+ 'use strict';
2
+
3
+ // modules/ideas/homeless-home.js — WHERE A REPORTED BUG GOES WHEN NOBODY NAMED A
4
+ // GOAL (task 1003691, [ADR 0262](../../docs/adr/0262-a-bug-never-lands-in-the-inbox.md)).
5
+ //
6
+ // THE PROBLEM THIS ANSWERS. routing.decideLanding returned early on a goal-less
7
+ // filing, which made its own kind==='bug' branch unreachable without a goal: a
8
+ // reported defect parked in idea_inbox waiting for the triage pass criterion C1
9
+ // exists to remove. Letting bugs route required answering "route into WHAT", and
10
+ // that answer is here.
11
+ //
12
+ // THE ANSWER IS THE VERSION'S MAINTENANCE GOAL, and deliberately NOT the
13
+ // "<version> — general" catch-all. The catch-all is what BV1.R11 (task 1003598)
14
+ // began deleting after 53 open tasks accumulated in two of them unread, and
15
+ // db-tasks.js warns against a third caller of its opt-out hatch. The maintenance
16
+ // goal is the home task 1003605 (BV1.R18) designs for exactly this — "where a
17
+ // defect against already-shipped work lands" — so a homeless bug gets a REAL,
18
+ // open goal and ADR 0250 D4 ("every task belongs to a goal") holds intact.
19
+ //
20
+ // ITS OWN FILE because inbox.js sits at the fitness line cap with no headroom, and
21
+ // because the concept deserves a name: this is one question ("what home does a
22
+ // homeless bug get") with one answer, and a reader looking for it should find a
23
+ // file rather than a paragraph inside a transaction.
24
+
25
+ // resolveHomelessHome — the goal id a homeless bug lands in, or a thrown refusal.
26
+ //
27
+ // TWO PROPERTIES, both load-bearing:
28
+ //
29
+ // * IT RUNS ON THE CALLER'S TRANSACTION CLIENT. Both port calls take it, so the
30
+ // find-or-create commits with the idea + task + lineage rows or not at all. A
31
+ // goal created on its own connection would outlive a rolled-back route as an
32
+ // empty goal nobody asked for.
33
+ // * IT FAILS CLOSED. No building version, or a version whose maintenance goal
34
+ // cannot be made, means there is nowhere honest to file — so it refuses rather
35
+ // than inventing a version. Filing a defect into a version not open to work is
36
+ // idea 1000771's exact failure (every Discord report entombed in a shipped
37
+ // version's catch-all), and this path exists to end that, not to repeat it.
38
+ //
39
+ // It resolves the VERSION first, which inverts the usual order: every other routed
40
+ // filing reads the version off the goal it was handed. There is no goal here yet,
41
+ // so the version has to come first — and once this returns, everything downstream
42
+ // runs unchanged against a real goal id.
43
+ async function resolveHomelessHome(lifecycle, client) {
44
+ const versionId = await lifecycle.currentBuildingVersionId({ client });
45
+ if (!versionId) throw { code: 'NO_BUILDING_VERSION' };
46
+ const home = await lifecycle.ensureMaintenanceGoal(versionId, { client });
47
+ if (!home) throw { code: 'NO_MAINTENANCE_GOAL', version_id: versionId };
48
+ return home.id;
49
+ }
50
+
51
+ module.exports = { resolveHomelessHome };
@@ -20,6 +20,8 @@ const { pool } = api;
20
20
  // The landing matrix (task 1003065, BV1.R01) — pure, DB-free, its own file so
21
21
  // every cell is unit-testable without a Postgres. See ./routing.js.
22
22
  const { decideLanding, authorizeRoutedLanding, routedPriority } = require('./routing');
23
+ // The home a goal-less bug lands in (task 1003691, ADR 0262).
24
+ const { resolveHomelessHome } = require('./homeless-home');
23
25
 
24
26
  const VALID_KINDS = [
25
27
  'feature',
@@ -247,6 +249,9 @@ async function captureIdea({
247
249
  // anything). Ignored when `goalId` is already set, so every existing caller
248
250
  // is untouched.
249
251
  suggestedGoalId = null,
252
+ // WHICH DOOR this filing came through; absent is UNTRUSTED and stays an inbox
253
+ // row. Read by one rule — see routing.HOMELESS_ROUTING_VECTORS (ADR 0262).
254
+ vector = null,
250
255
  }, deps = {}) {
251
256
  if (!title || typeof title !== 'string') {
252
257
  throw { code: 'TITLE_REQUIRED' };
@@ -264,7 +269,7 @@ async function captureIdea({
264
269
  const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
265
270
 
266
271
  // The decision, before any write. Pure — see ./routing.js.
267
- const landing = decideLanding({ goalId, kind: resolvedKind, grade, filerRank });
272
+ const landing = decideLanding({ goalId, kind: resolvedKind, grade, filerRank, vector });
268
273
  if (landing.route) {
269
274
  return routeIdeaToGoal({
270
275
  title, bodyMd, resolvedKind, suggestedVersion, suggestedPriority,
@@ -315,6 +320,23 @@ async function routeIdeaToGoal({
315
320
  try {
316
321
  await client.query('BEGIN');
317
322
 
323
+ // The `lifecycle` PORT, resolved ONCE up here because two steps need it: the
324
+ // homeless-bug home at (a0) and the task insert at (c). Never an import of
325
+ // modules/lifecycle/ — the one-way module rule, ADR 0083.
326
+ const lifecycle = api.resolveOptional && api.resolveOptional('lifecycle');
327
+ if (!lifecycle || !lifecycle.createTask) {
328
+ // A vanilla instance without the lifecycle module cannot hold tasks at all,
329
+ // so there is nothing to route INTO. Refuse loudly rather than silently
330
+ // degrading to an inbox row the caller was told became a task.
331
+ throw { code: 'LIFECYCLE_UNAVAILABLE' };
332
+ }
333
+
334
+ // a0. A homeless bug gets the version's maintenance goal — ./homeless-home.js
335
+ // says why that goal and not the catch-all, and why it fails closed.
336
+ const effectiveGoalId = landing.homeless
337
+ ? await resolveHomelessHome(lifecycle, client)
338
+ : goalId;
339
+
318
340
  // a. The goal must be a REAL, LIVE HOME. All three checks are inside the
319
341
  // transaction and the row is locked FOR SHARE, so a goal cannot be
320
342
  // archived out from under a route that is mid-flight.
@@ -330,17 +352,17 @@ async function routeIdeaToGoal({
330
352
  JOIN versions v ON v.id = g.version_id
331
353
  WHERE g.id = $1
332
354
  FOR SHARE OF g`,
333
- [goalId]
355
+ [effectiveGoalId]
334
356
  );
335
- if (goalRows.length === 0) throw { code: 'GOAL_NOT_FOUND', goal_id: goalId };
357
+ if (goalRows.length === 0) throw { code: 'GOAL_NOT_FOUND', goal_id: effectiveGoalId };
336
358
  const goal = goalRows[0];
337
359
  if (goal.status !== 'open') {
338
- throw { code: 'GOAL_NOT_OPEN', goal_id: goalId, status: goal.status };
360
+ throw { code: 'GOAL_NOT_OPEN', goal_id: effectiveGoalId, status: goal.status };
339
361
  }
340
362
  if (goal.version_status !== 'building') {
341
363
  throw {
342
364
  code: 'VERSION_NOT_BUILDING',
343
- goal_id: goalId,
365
+ goal_id: effectiveGoalId,
344
366
  version_id: goal.version_id,
345
367
  version_status: goal.version_status,
346
368
  };
@@ -357,7 +379,7 @@ async function routeIdeaToGoal({
357
379
  $7, now())
358
380
  RETURNING ${IDEA_COLS}`,
359
381
  [title, bodyMd, resolvedKind, suggestedVersion, suggestedPriority, capturedBy,
360
- goalId]
382
+ effectiveGoalId]
361
383
  );
362
384
  const idea = ideaRows[0];
363
385
 
@@ -384,7 +406,7 @@ async function routeIdeaToGoal({
384
406
  if (!authorized.ok) {
385
407
  throw {
386
408
  code: 'ROUTE_NOT_AUTHORIZED',
387
- goal_id: goalId,
409
+ goal_id: effectiveGoalId,
388
410
  gate: authorized.code,
389
411
  reason: authorized.reason,
390
412
  details: authorized.details ?? null,
@@ -399,10 +421,10 @@ async function routeIdeaToGoal({
399
421
  // The version is the GOAL'S, never a caller-supplied one, so
400
422
  // "goal belongs to the version" is true by construction rather than by
401
423
  // validation. (An idea's own `suggested_version` is kept on the idea row
402
- // as filed and does NOT override it — the goal is the binding home.) One
403
- // read stays outside this transaction by design: createTask's catch-all
404
- // goal fallback, which only runs when goalId is absent and so never on
405
- // this path.
424
+ // as filed and does NOT override it — the goal is the binding home.)
425
+ // createTask's catch-all fallback still never fires here: a goal is always
426
+ // set by this line, named by the filer or resolved at (a0), so this path
427
+ // never opts out of the goal requirement (task 1003691, ADR 0262).
406
428
  //
407
429
  // `requiresRank` is passed ONLY as the R05 protected-scope floor computed
408
430
  // above — never from anything the filer said. createTask still derives its
@@ -411,13 +433,6 @@ async function routeIdeaToGoal({
411
433
  // default — createTask auto-assigns the capped suggestion) and
412
434
  // `discipline` (left 'unclassified', exactly as the other non-authored
413
435
  // create vectors leave it).
414
- const lifecycle = api.resolveOptional && api.resolveOptional('lifecycle');
415
- if (!lifecycle || !lifecycle.createTask) {
416
- // A vanilla instance without the lifecycle module cannot hold tasks at all,
417
- // so there is nothing to route INTO. Refuse loudly rather than silently
418
- // degrading to an inbox row the caller was told became a task.
419
- throw { code: 'LIFECYCLE_UNAVAILABLE' };
420
- }
421
436
  const task = await lifecycle.createTask({
422
437
  versionId: goal.version_id,
423
438
  title,
@@ -148,6 +148,12 @@ module.exports = function buildInboxRouter() {
148
148
  goalId: body.goal_id ?? null,
149
149
  filerRank: req.builder?.rank ?? null,
150
150
  grade,
151
+ // task 1003691 (ADR 0262): the vector, hardcoded — NEVER read from the
152
+ // body. This route is the authenticated API door, so a bug filed here
153
+ // with no goal routes to the version's maintenance goal instead of
154
+ // parking in the inbox. Letting a caller name their own vector would
155
+ // hand every untrusted door the same power by typing one word.
156
+ vector: 'api',
151
157
  });
152
158
  let grade_warning;
153
159
  if (grade === 'full') {
@@ -272,6 +278,17 @@ module.exports = function buildInboxRouter() {
272
278
  // filer the way through: file it without a goal.
273
279
  err.code === 'ROUTE_NOT_AUTHORIZED' ? 403 :
274
280
  err.code === 'LIFECYCLE_UNAVAILABLE' ? 503 :
281
+ // task 1003691 (ADR 0262) — the HOMELESS-bug refusals. Both are 503 and
282
+ // NOT 4xx, because unlike every refusal above them they are not the
283
+ // filer's mistake and there is nothing they can change to get through:
284
+ // the filer named no goal (which is now the supported way to report a
285
+ // bug), and the project simply has no version open to work or no
286
+ // maintenance goal that can be made in it. That is a server-state
287
+ // problem an operator fixes, so it reads like LIFECYCLE_UNAVAILABLE
288
+ // beside it — a 400 would tell the reporter to correct a request that
289
+ // was correct.
290
+ err.code === 'NO_BUILDING_VERSION' ? 503 :
291
+ err.code === 'NO_MAINTENANCE_GOAL' ? 503 :
275
292
  500;
276
293
  return res.fail(err.code, status, { ...err });
277
294
  }
@@ -54,6 +54,32 @@ const METIC_PLUS = new Set(['metic', 'archon']);
54
54
  // 'quick' carry no such meaning and route normally.
55
55
  const NEVER_ROUTE_GRADE = 'full';
56
56
 
57
+ // The vectors a HOMELESS bug may route from (task 1003691, ADR 0262). This is an
58
+ // ALLOWLIST and the default is exclusion: a filing whose vector is absent or
59
+ // unrecognised never routes without a goal, which is what keeps the homeless
60
+ // branch below from becoming a second door into the queue.
61
+ //
62
+ // WHY THE VECTOR AND NOT THE KIND ALONE — the whole reason this set exists.
63
+ // `kind` is not always a claim a human made. Discord #ideas passes no kind at
64
+ // all, so inbox.classify() GUESSES one from keywords, and its bug pattern fires
65
+ // on fix/broken/bug/crash/error anywhere in the text: "should we fix the copy on
66
+ // the landing page" classifies as a bug. Routing on kind alone would let that
67
+ // keyword guess mint work, which is exactly the ADR 0234 owner-interview
68
+ // decision — "#ideas files with HINTS only, never routes" — that this task was
69
+ // explicitly told to keep (pinned as F12 in tests/filing_abuse_matrix.mjs).
70
+ //
71
+ // Both members carry a human or a channel that means "this is broken":
72
+ // 'api' — POST /inbox, an authenticated builder naming the kind. This
73
+ // is also how `scripts/gds/capture.js` files (it posts to the
74
+ // route rather than calling this module), so the CLI inherits
75
+ // it and needs no member of its own.
76
+ // 'discord-bugs' — the #bugs channel, where the CHANNEL is the classification
77
+ // (modules/discord/discord-bugs.js forces kind='bug'), and
78
+ // which already carries a rate cap + an unlinked-author guard.
79
+ // 'discord-ideas' is deliberately ABSENT: it is an ideation channel, and the bug
80
+ // reports that belong to Discord already have #bugs.
81
+ const HOMELESS_ROUTING_VECTORS = new Set(['api', 'discord-bugs']);
82
+
57
83
  // decideLanding — the matrix, as one pure function.
58
84
  //
59
85
  // Returns { route: false, reason } when the thing belongs in the inbox, or
@@ -65,11 +91,47 @@ function decideLanding(input) {
65
91
  // a caller spreading an absent object hands over — would throw. A landing
66
92
  // decision that CRASHES is the worst of the three outcomes: it takes down the
67
93
  // capture that was supposed to fail closed. Normalise first, decide after.
68
- const { goalId = null, kind = null, grade = null, filerRank = null } = input || {};
94
+ const { goalId = null, kind = null, grade = null, filerRank = null, vector = null } = input || {};
69
95
  // No goal named ⇒ the inbox is exactly right, and this is the DEFAULT PATH.
70
96
  // Every capture vector that never learns about goals keeps working unchanged;
71
97
  // routing is strictly additive (criterion C1 testable #5).
72
- if (goalId == null || goalId === '') return { route: false, reason: 'no_goal' };
98
+ //
99
+ // THE ONE EXCEPTION: A BUG NEVER LANDS IN THE INBOX (task 1003691, ADR 0262).
100
+ // Until now this early return made the kind==='bug' branch below UNREACHABLE
101
+ // without a goal — `kind` only ever chose the routed task's STATUS, never
102
+ // whether it routed at all — so a reported defect sat in idea_inbox waiting for
103
+ // a human triage pass, which is the triage tax criterion C1 exists to remove.
104
+ //
105
+ // This does NOT re-open the goal-less task hole ADR 0250 D4 closed. A homeless
106
+ // bug is given a REAL, open goal — the version's maintenance goal, resolved by
107
+ // the caller (inbox.routeIdeaToGoal) — so "every task belongs to a goal" holds
108
+ // exactly as before. What changes is only WHO names it: the system, not the
109
+ // person reporting the defect. That is also what ADR 0235 already asked for:
110
+ // its "a fix needs no home to be filed correctly" is an ADVISORY exemption
111
+ // (goal-advisory.adviseGoal stays silent for a bug), and its actual decision is
112
+ // that a fix category IS kind='bug' on a task carrying a real goal_id.
113
+ //
114
+ // Fail-closed, in the posture of the rest of this file: the vector must be on
115
+ // the allowlist, the grade must not be a Full Idea (F8 — the Board Room's
116
+ // subject is never converted by its own filer), and an untrusted or absent rank
117
+ // still soft-lands at 'backlog' rather than reaching the claimable queue.
118
+ if (goalId == null || goalId === '') {
119
+ if (
120
+ HARD_LAND_KINDS.has(kind) &&
121
+ HOMELESS_ROUTING_VECTORS.has(vector) &&
122
+ String(grade || '').toLowerCase() !== NEVER_ROUTE_GRADE
123
+ ) {
124
+ // `homeless: true` is the instruction to the transaction: resolve the
125
+ // version's maintenance goal before the goal read. It is a separate field
126
+ // rather than an inferred `goalId == null` so the intent survives into the
127
+ // caller and into the tests, and so a future landing that happens to carry
128
+ // no goal cannot silently inherit the maintenance-goal behaviour.
129
+ return METIC_PLUS.has(String(filerRank || '').toLowerCase())
130
+ ? { route: true, status: 'ready', homeless: true, reason: 'bug_homeless_trusted_filer' }
131
+ : { route: true, status: 'backlog', homeless: true, reason: 'bug_homeless_awaiting_nod' };
132
+ }
133
+ return { route: false, reason: 'no_goal' };
134
+ }
73
135
 
74
136
  if (String(grade || '').toLowerCase() === NEVER_ROUTE_GRADE) {
75
137
  return { route: false, reason: 'full_idea_never_routes' };
@@ -228,5 +290,6 @@ module.exports = {
228
290
  DEFAULT_ROUTED_PRIORITY,
229
291
  HARD_LAND_KINDS,
230
292
  NEVER_ROUTE_KINDS,
293
+ HOMELESS_ROUTING_VECTORS,
231
294
  METIC_PLUS,
232
295
  };
@@ -15,6 +15,7 @@ const { pool } = api;
15
15
  const doneWhen = require('./done-when.js');
16
16
  const { deriveRequiredRank, highestRank } = require('./db-rank-authz.js');
17
17
  const { nonTerminalSql } = require('./task-status');
18
+ const { maintenanceGoalTitle } = require('./goal-advisory.js');
18
19
  const { SMOKE_NOT_LIKE } = require('./db-shared.js');
19
20
 
20
21
  // -------------------------------------------------------------------------
@@ -82,6 +83,96 @@ async function createGoal(
82
83
  return rows[0];
83
84
  }
84
85
 
86
+ // The maintenance goal's scope wall: deliberately EMPTY. Named rather than inlined
87
+ // so the choice is greppable and the reasoning has one home (see the INSERT below).
88
+ const MAINTENANCE_SCOPE_MODULES = [];
89
+
90
+ // ensureMaintenanceGoal — find-or-create the version's maintenance goal, the home
91
+ // a reported bug gets when its reporter named none (task 1003691, ADR 0262).
92
+ // Returns the goal row, or null when the version cannot hold new work.
93
+ //
94
+ // WHY THIS IS A LIFECYCLE FUNCTION AND NOT AN ideas ONE. Goals and versions are
95
+ // this module's tables; the ideas module may not import lifecycle (the one-way
96
+ // module rule, ADR 0083), so it reaches this through the `lifecycle` port. Keeping
97
+ // the SQL here also means the maintenance goal has ONE definition for the routing
98
+ // path and for task 1003605 (BV1.R18) to build the rest of the feature on.
99
+ //
100
+ // IT RUNS INSIDE THE CALLER'S TRANSACTION (deps.client — the createTask seam).
101
+ // That is load-bearing rather than tidy: routeIdeaToGoal's whole promise is that
102
+ // the idea row, the task and the lineage row commit together or not at all, and a
103
+ // goal created in its own connection would survive a rolled-back route as an
104
+ // empty goal nobody asked for.
105
+ //
106
+ // THE RACE IS REAL AND IS HANDLED WITH A LOCK, NOT A PRAYER. `goals` has no unique
107
+ // constraint on (version_id, title) — migration 160 used a NOT EXISTS guard, which
108
+ // is safe for a one-shot backfill and NOT safe for a concurrent path: two bugs
109
+ // reported in the same second would both see "no maintenance goal" and both insert
110
+ // one, after which the title lookup is ambiguous forever. pg_advisory_xact_lock
111
+ // serialises find-or-create per version and releases at COMMIT/ROLLBACK with no
112
+ // unlock call to forget. It is keyed on a hash of the TITLE, so it collides only
113
+ // with another writer of this same goal.
114
+ //
115
+ // The version must be open to work, checked here rather than trusted from the
116
+ // caller: filing a defect into a shipped version's goal is the 1000771 bug
117
+ // (bugs entombed where nobody looks), and the point of this path is to end that.
118
+ async function ensureMaintenanceGoal(versionId, deps = {}) {
119
+ if (versionId == null) return null;
120
+ const exec = deps.client || ((deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool);
121
+ const title = maintenanceGoalTitle(versionId);
122
+
123
+ const { rows: vRows } = await exec.query(
124
+ `SELECT status FROM versions WHERE id = $1`,
125
+ [versionId]
126
+ );
127
+ if (!vRows[0] || vRows[0].status !== 'building') return null;
128
+
129
+ await exec.query(`SELECT pg_advisory_xact_lock(hashtext($1))`, [title]);
130
+
131
+ // MATCHED BY TITLE, because there is no column that says "this is the
132
+ // maintenance goal" — the same shape generalGoalIdForVersion uses for the
133
+ // catch-all, and it carries the same caveat: were a Metic+/Archon to hand-create
134
+ // a goal titled exactly this, routing would reuse THEIR goal and its scope
135
+ // rather than the zero-scope bucket below. That needs an already-trusted actor,
136
+ // so it is a provenance wrinkle rather than a way in, and task 1003605 (BV1.R18)
137
+ // is where a real flag belongs when the feature gets its own column.
138
+ //
139
+ // No index backs (version_id, title): goals are per-version and few, and this
140
+ // runs once per homeless bug. Revisit with R18 if either stops being true.
141
+ const { rows: found } = await exec.query(
142
+ `SELECT ${GOAL_COLS} FROM goals WHERE version_id = $1 AND title = $2 LIMIT 1`,
143
+ [versionId, title]
144
+ );
145
+ if (found[0]) return found[0];
146
+
147
+ // created_by NULL — system-attributed, the migration-160 posture: this goal has
148
+ // no owner and inserts no member row, so Archons act as one.
149
+ //
150
+ // scope_modules is deliberately EMPTY, and that is the one thing to understand
151
+ // before changing this: a defect can land in any module, so a wall listing every
152
+ // module is the same as no wall while also lying about intent. The consequence is
153
+ // real and accepted — scopeIncludesProtected([]) is false, so a Metic+ filer's
154
+ // homeless bug takes no protected-scope floor and lands claimable. That is the
155
+ // SAME grant abuse-matrix row P1 already makes for a Metic+ bug in any unscoped
156
+ // goal; what is new is only that this goal is auto-created. The protections that
157
+ // remain are the ones that were carrying the weight anyway: the matrix's rank
158
+ // split (sub-Metic lands 'backlog', never 'ready') and createTask's own derived
159
+ // floor. Do not "fix" this by inventing a scope list — narrow the GRANT instead,
160
+ // and write down why.
161
+ const { rows: made } = await exec.query(
162
+ `INSERT INTO goals (version_id, title, description, scope_modules, status, created_by, sort_order)
163
+ SELECT $1, $2, $3, $4, 'open', NULL, COALESCE(MAX(sort_order) + 1, 0)
164
+ FROM goals WHERE version_id = $1
165
+ RETURNING ${GOAL_COLS}`,
166
+ [versionId, title,
167
+ `Where a reported defect lands when its reporter named no goal (ADR 0262). ` +
168
+ `Auto-created by the bug-routing path; task 1003605 (BV1.R18) owns the rest of ` +
169
+ `the maintenance-goal feature — the close-exempt flag and carrying open bugs ` +
170
+ `forward to the successor version.`,
171
+ MAINTENANCE_SCOPE_MODULES]
172
+ );
173
+ return made[0] || null;
174
+ }
175
+
85
176
  // listVersionAdmissions (BV1.R07, task 1003594) — every goal admitted into ONE
86
177
  // version, newest first. ADR 0250 §3: "the overrides are counted and shown on the
87
178
  // version" — a visible counter is what keeps the exception honest, because an
@@ -844,6 +935,7 @@ async function listPendingRequestsForGoal(goalId, deps = {}) {
844
935
 
845
936
  module.exports = {
846
937
  acceptMembershipRequestAndAddMember,
938
+ ensureMaintenanceGoal,
847
939
  achieveGoalIfComplete,
848
940
  addGoalMember,
849
941
  addGoalScopeModules,
@@ -131,8 +131,38 @@ async function createVersion({ id, name, status = 'planning', track = 'product',
131
131
  });
132
132
  }
133
133
 
134
+ // currentBuildingVersionId — the version new work defaults into, or null when
135
+ // none is open (task 1003691, ADR 0262). Consumed by the bug-routing path, which
136
+ // has to name a version BEFORE it has a goal and so cannot derive one from the
137
+ // goal the way every other routed filing does.
138
+ //
139
+ // THE TIEBREAK IS NOT NEW. More than one version may sit at 'building' — ADR 0250
140
+ // D5 closes the PLANNING slot only, and refusing a second building version is
141
+ // still unshipped work (task 1003606, BV1.R19) — so a rule was needed, and the
142
+ // project already has one: scripts/gds/version-select.js pickDefaultVersion,
143
+ // extracted to end nine hardcoded version literals and pinned by
144
+ // tests/version_literals.mjs. This is that rule in SQL: building only, most
145
+ // recently started first, a null started_at sorting LAST so a half-filled row
146
+ // cannot decide the answer, and `id` breaking a remaining tie so the result is
147
+ // deterministic rather than whatever the planner returns.
148
+ //
149
+ // Returns null rather than guessing when nothing is building — the caller must
150
+ // fail closed, because inventing a version is how the 1000771 bug (every Discord
151
+ // report filed into a SHIPPED version's catch-all) happened in the first place.
152
+ async function currentBuildingVersionId(deps = {}) {
153
+ const exec = deps.client || ((deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool);
154
+ const { rows } = await exec.query(
155
+ `SELECT id FROM versions
156
+ WHERE status = 'building'
157
+ ORDER BY started_at DESC NULLS LAST, id
158
+ LIMIT 1`
159
+ );
160
+ return rows[0] ? rows[0].id : null;
161
+ }
162
+
134
163
  module.exports = {
135
164
  createVersion,
165
+ currentBuildingVersionId,
136
166
  getVersion,
137
167
  listVersions,
138
168
  versionProgress,
@@ -63,13 +63,14 @@ const {
63
63
  singleRungPromotionError,
64
64
  xenosClaimAllowed,
65
65
  } = require('./db-rank-authz.js');
66
- const { createVersion, getVersion, listVersions, versionProgress, versionsWithStatus } = require('./db-versions.js');
66
+ const { createVersion, currentBuildingVersionId, getVersion, listVersions, versionProgress, versionsWithStatus } = require('./db-versions.js');
67
67
  const {
68
68
  acceptMembershipRequestAndAddMember,
69
69
  achieveGoalIfComplete,
70
70
  addGoalMember,
71
71
  addGoalScopeModules,
72
72
  createGoal,
73
+ ensureMaintenanceGoal,
73
74
  createMembershipRequest,
74
75
  getGoal,
75
76
  getGoalAdmission,
@@ -217,7 +218,9 @@ module.exports = {
217
218
  versionProgress,
218
219
  versionsWithStatus,
219
220
  createVersion,
221
+ currentBuildingVersionId,
220
222
  createGoal,
223
+ ensureMaintenanceGoal,
221
224
  getGoal,
222
225
  getGoalAdmission,
223
226
  listVersionAdmissions,
@@ -27,6 +27,22 @@ function generalGoalTitle(versionId) {
27
27
  return `${versionId} — general`;
28
28
  }
29
29
 
30
+ // The per-version MAINTENANCE goal — where a defect against already-shipped work
31
+ // lands when the reporter named no goal (task 1003691, ADR 0262). Same em-dash
32
+ // rule as generalGoalTitle above, and the same reason: the title IS the lookup
33
+ // key (there is no boolean column), so an ASCII hyphen or an en dash silently
34
+ // creates a second, ordinary goal instead of matching this one.
35
+ //
36
+ // DELIBERATELY NOT the "<version> — general" catch-all. That bucket is what BV1.R11
37
+ // (task 1003598) began deleting after 53 open tasks accumulated in two of them
38
+ // unread; the maintenance goal is the honest home task 1003605 (BV1.R18) designs
39
+ // for exactly this work. This constant is the half R18 and the bug-routing path
40
+ // share — R18 still owns auto-creating it with every version, the close-exempt
41
+ // flag, and carrying open bugs forward to the successor version on close.
42
+ function maintenanceGoalTitle(versionId) {
43
+ return `${versionId} — maintenance`;
44
+ }
45
+
30
46
  // adviseGoal — PURE, FAIL-OPEN. Returns null when the task has a real home, or the
31
47
  // advisory body when it does not. Three reasons, checked in order:
32
48
  //
@@ -177,4 +193,4 @@ async function buildGoalAdvisory({ db, versionId, task, hasCriterionLink = false
177
193
  }
178
194
  }
179
195
 
180
- module.exports = { adviseGoal, candidateGoals, generalGoalTitle, buildGoalAdvisory };
196
+ module.exports = { adviseGoal, candidateGoals, generalGoalTitle, maintenanceGoalTitle, buildGoalAdvisory };
@@ -43,6 +43,17 @@ module.exports = {
43
43
  // "which goal, on which version" is a lifecycle judgement and discord may not
44
44
  // import lifecycle (the one-way module rule).
45
45
  suggestGoalForCapture: (...a) => goalSuggest.suggestGoalForCapture(...a),
46
+ // ensureMaintenanceGoal + currentBuildingVersionId (task 1003691, ADR 0262):
47
+ // the home a REPORTED BUG gets when nobody named one. Consumer: the ideas
48
+ // routing transaction, which is the one filing path that must name a version
49
+ // BEFORE it has a goal — every other routed filing derives the version FROM the
50
+ // goal it was given. Both take `deps.client` so the find-or-create commits with
51
+ // the idea + task + lineage rows, or not at all.
52
+ //
53
+ // They live behind the port for the ordinary reason: `goals` and `versions` are
54
+ // lifecycle tables and the ideas module may not import lifecycle (ADR 0083).
55
+ ensureMaintenanceGoal: (...a) => db.ensureMaintenanceGoal(...a),
56
+ currentBuildingVersionId: (...a) => db.currentBuildingVersionId(...a),
46
57
 
47
58
  // --- work-tracking READS the staying-core read routes resolve --------------
48
59
  // public.js (the status surface): version/ship feed + grade trend.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.586",
3
+ "version": "1.19.588",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.586",
9
+ "version": "1.19.588",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",