@bongos/core 1.19.586 → 1.19.587
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +42 -27
- package/.claude/skills/idea-triage/SKILL.md +24 -4
- package/docs/adr/0262-a-bug-never-lands-in-the-inbox.md +96 -0
- package/docs/adr/README.md +1 -0
- package/docs/module-api-changelog.md +2 -0
- package/modules/discord/discord-bugs.js +23 -2
- package/modules/discord/discord-inbound.js +9 -0
- package/modules/ideas/homeless-home.js +51 -0
- package/modules/ideas/inbox.js +33 -18
- package/modules/ideas/routes/inbox.js +17 -0
- package/modules/ideas/routing.js +65 -2
- package/modules/lifecycle/db-goals.js +92 -0
- package/modules/lifecycle/db-versions.js +30 -0
- package/modules/lifecycle/db.js +4 -1
- package/modules/lifecycle/goal-advisory.js +17 -1
- package/modules/lifecycle/lifecycle.js +11 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/filing_abuse_matrix.mjs +99 -0
- package/tests/idea_routing.mjs +226 -1
- package/tests/maintenance_goal.mjs +191 -0
- package/tests/task_goal_required.mjs +2 -1
|
@@ -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
|
}
|
package/modules/ideas/routing.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/modules/lifecycle/db.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "1.19.587",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.587",
|
|
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.
|
|
3
|
+
"version": "1.19.587",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/src/module-api.js
CHANGED
|
@@ -55,7 +55,7 @@ const { buildInfo } = require('./build-info');
|
|
|
55
55
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
56
56
|
// the entry to that file. Look for a version's history there, not here.
|
|
57
57
|
// ---------------------------------------------------------------------------
|
|
58
|
-
const CORE_VERSION = '1.19.
|
|
58
|
+
const CORE_VERSION = '1.19.587'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
59
59
|
|
|
60
60
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
61
61
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -33,8 +33,12 @@
|
|
|
33
33
|
// F16 file through #bugs as an UNLINKED author → an inbox row, never work
|
|
34
34
|
// F13 declare touches[] outside the goal to widen blast area → the real wall refuses
|
|
35
35
|
// F14 file against a SHIPPED version's goal → refused (version not building)
|
|
36
|
+
// F17 get a keyword-guessed 'bug' from #ideas to mint work → cannot: vector not trusted
|
|
37
|
+
// F18 forge/omit the vector to reach the homeless-bug door → fail-closed, an inbox row
|
|
38
|
+
// F19 file a homeless NON-bug hoping for a free home → inbox; only bugs get one
|
|
36
39
|
// P1 a Metic+ files a bug in an ordinary goal → 'ready' (the C1 grant)
|
|
37
40
|
// P2 a Metic+ files into protected scope → allowed, floored to metic
|
|
41
|
+
// P3 a Metic+ files a HOMELESS bug from a trusted vector → 'ready', in the maintenance goal
|
|
38
42
|
//
|
|
39
43
|
// AND THE ANTI-DRIFT PIN (the point of the file, really). The rows above are true
|
|
40
44
|
// of the capture vectors that exist TODAY. Nothing stops a fifth being added
|
|
@@ -337,6 +341,101 @@ await test('[F16] an unlinked #bugs author gets an inbox row and CANNOT reach wo
|
|
|
337
341
|
const code = src.split('\n').filter((l) => !l.trim().startsWith('//')).join('\n');
|
|
338
342
|
assert.match(code, /if \(builder\) \{[\s\S]{0,200}suggestGoalForCapture/,
|
|
339
343
|
'a goal is only ever sought for a LINKED builder');
|
|
344
|
+
|
|
345
|
+
// ADR 0262 REOPENED THIS AND IT HAD TO BE CLOSED AGAIN — a grader blocker on
|
|
346
|
+
// task 1003691, and the reason the vector is CONDITIONAL at the call site.
|
|
347
|
+
//
|
|
348
|
+
// The rows above pass a rank but no VECTOR, so they kept passing while the real
|
|
349
|
+
// path had already changed: #bugs briefly set `vector: 'discord-bugs'`
|
|
350
|
+
// unconditionally, which meant an unlinked poster (filerRank null, goalId null —
|
|
351
|
+
// the goal is only sought `if (builder)`) took the homeless-bug branch and minted
|
|
352
|
+
// a real backlog task. Worse, the rolling rate cap is counted per builder id and
|
|
353
|
+
// so does not run at all for them: unattributed task creation at no cost.
|
|
354
|
+
//
|
|
355
|
+
// Both halves are pinned now — the decision, and the call site that feeds it.
|
|
356
|
+
assert.equal(
|
|
357
|
+
decideLanding({ goalId: null, kind: 'bug', filerRank: null, vector: 'discord-bugs' }).route,
|
|
358
|
+
true,
|
|
359
|
+
'sanity: the vector DOES open the homeless branch, so the gate below is what protects it'
|
|
360
|
+
);
|
|
361
|
+
assert.match(code, /vector:\s*builder\s*\?\s*'discord-bugs'\s*:\s*null/,
|
|
362
|
+
'the trusted vector is claimed ONLY for a linked builder — never the bare string');
|
|
363
|
+
assert.ok(!/vector:\s*'discord-bugs'\s*,/.test(code),
|
|
364
|
+
'an unconditional vector here is the blocker this row exists to catch');
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// =========================================================================
|
|
368
|
+
// F17–F19 + P3 — the HOMELESS-BUG door (task 1003691, ADR 0262)
|
|
369
|
+
//
|
|
370
|
+
// ADR 0262 opened one new way for a filing to become work: a kind='bug' with NO
|
|
371
|
+
// goal now routes to the version's maintenance goal instead of parking in the
|
|
372
|
+
// inbox. A new door is a new attack surface, so it gets its own rows.
|
|
373
|
+
//
|
|
374
|
+
// THE ATTACKER'S BEST MOVE HERE is not rank — it is the VECTOR. #ideas passes no
|
|
375
|
+
// kind, so inbox.classify() guesses one from keywords, and its bug pattern fires
|
|
376
|
+
// on fix/broken/bug/crash/error anywhere in the text. If the kind alone opened the
|
|
377
|
+
// door, "we should fix the copy on the landing page" would mint a task, and ADR
|
|
378
|
+
// 0234's owner-interview decision ("#ideas files with HINTS only, never routes")
|
|
379
|
+
// would be dead without anyone deciding to kill it.
|
|
380
|
+
// =========================================================================
|
|
381
|
+
|
|
382
|
+
await test('[F17] a keyword-guessed bug from #ideas still cannot mint work', () => {
|
|
383
|
+
// The guess is real: prove the classifier does fire, then prove it buys nothing.
|
|
384
|
+
const inbox = require('../modules/ideas/inbox.js');
|
|
385
|
+
for (const text of ['we should fix the copy on the landing page',
|
|
386
|
+
'the onboarding flow feels broken to me',
|
|
387
|
+
'error messages could be friendlier']) {
|
|
388
|
+
assert.equal(inbox.classify(text, ''), 'bug', `classifier guesses bug for: ${text}`);
|
|
389
|
+
}
|
|
390
|
+
// …and #ideas's vector is not on the allowlist, so every one of them is an inbox row.
|
|
391
|
+
assert.deepEqual(
|
|
392
|
+
decideLanding({ goalId: null, kind: 'bug', filerRank: 'archon', vector: 'discord-ideas' }),
|
|
393
|
+
{ route: false, reason: 'no_goal' },
|
|
394
|
+
'ADR 0234 holds: #ideas files hints, never work'
|
|
395
|
+
);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
await test('[F18] the vector fails CLOSED — forged, absent or unknown never opens the door', () => {
|
|
399
|
+
for (const vector of [undefined, null, '', 'api ', 'API', 'discord_bugs', 'cli',
|
|
400
|
+
'internal', 'trusted', true, 1, {}, ['api']]) {
|
|
401
|
+
assert.equal(
|
|
402
|
+
decideLanding({ goalId: null, kind: 'bug', filerRank: 'archon', vector }).route,
|
|
403
|
+
false,
|
|
404
|
+
`vector ${JSON.stringify(vector)} must not route`
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
// The allowlist is exactly two members, and both are server-set at their call
|
|
408
|
+
// site — never read from a request body, which is what makes forging it a
|
|
409
|
+
// non-move rather than a merely-hard one.
|
|
410
|
+
assert.deepEqual([...routing.HOMELESS_ROUTING_VECTORS].sort(), ['api', 'discord-bugs']);
|
|
411
|
+
const routeSrc = read('modules/ideas/routes/inbox.js');
|
|
412
|
+
assert.match(routeSrc, /vector: 'api'/, 'the route hardcodes its own vector');
|
|
413
|
+
assert.ok(!/vector:\s*body\.|vector:\s*req\.body/.test(routeSrc),
|
|
414
|
+
'and never takes it from the request body');
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
await test('[F19] only a BUG gets a free home — every other homeless kind stays in the inbox', () => {
|
|
418
|
+
// Otherwise the door empties the inbox by fabricating homes for work that
|
|
419
|
+
// genuinely has none, which is the opposite of what ADR 0262 decided.
|
|
420
|
+
for (const kind of ['feature', 'cleanup', 'refactor', 'infra', 'spike',
|
|
421
|
+
'learning-capture', 'blocker-resolution', 'unclassified',
|
|
422
|
+
'criterion-proposal', 'decision']) {
|
|
423
|
+
assert.equal(
|
|
424
|
+
decideLanding({ goalId: null, kind, filerRank: 'archon', vector: 'api' }).route,
|
|
425
|
+
false,
|
|
426
|
+
`kind ${kind}`
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
await test('[P3] a Metic+ homeless bug IS claimable — the grant the door exists for', () => {
|
|
432
|
+
const d = decideLanding({ goalId: null, kind: 'bug', filerRank: 'metic', vector: 'api' });
|
|
433
|
+
assert.equal(d.route, true);
|
|
434
|
+
assert.equal(d.status, 'ready', 'no triage stop between reported and in the queue (C1)');
|
|
435
|
+
assert.equal(d.homeless, true, 'and it is flagged, so the transaction knows to resolve a home');
|
|
436
|
+
// A sub-Metic gets the same door but not the same speed.
|
|
437
|
+
assert.equal(decideLanding({ goalId: null, kind: 'bug', filerRank: 'xenos', vector: 'api' }).status,
|
|
438
|
+
'backlog');
|
|
340
439
|
});
|
|
341
440
|
|
|
342
441
|
// =========================================================================
|