@bongos/core 1.19.274 → 1.19.276
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 +39 -24
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +4 -1
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +59 -3
- package/docs/api-reference.md +4 -3
- package/docs/copy-inventory.md +6 -4
- package/docs/copy-registry.json +23 -5
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/hall-ui/public/goal-inbox.js +81 -3
- package/modules/ideas/inbox.js +332 -7
- package/modules/ideas/migrations/ideas_004_lineage_route_index.sql +37 -0
- package/modules/ideas/routes/inbox.js +78 -1
- package/modules/ideas/routing.js +232 -0
- package/modules/lifecycle/db.js +2 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +5 -1
- package/tests/idea_routing.mjs +858 -0
|
@@ -0,0 +1,858 @@
|
|
|
1
|
+
// tests/idea_routing.mjs
|
|
2
|
+
//
|
|
3
|
+
// The CAPTURE-TIME ROUTING CORE — task 1003065 (BV1.R01, goal 1000071 "Idea
|
|
4
|
+
// routing — work that has a home lands in it"). Spec:
|
|
5
|
+
// docs/specs/bongos-v1-idea-routing.md.
|
|
6
|
+
//
|
|
7
|
+
// TWO THINGS UNDER TEST, and the split is the design:
|
|
8
|
+
// 1. modules/ideas/routing.js — the landing matrix. PURE, so every cell is
|
|
9
|
+
// pinned here directly, with no Postgres and no fixture.
|
|
10
|
+
// 2. modules/ideas/inbox.js captureIdea — the ONE TRANSACTION a routed capture
|
|
11
|
+
// runs. Driven through the #538 `deps.pool` seam (NODE_ENV==='test' only)
|
|
12
|
+
// with a SQL-aware fake client, the tests/goal_tier.mjs posture: we assert
|
|
13
|
+
// the STATEMENT SEQUENCE and the bound params, because the sequence IS the
|
|
14
|
+
// contract — an idea row, a task, and a lineage row that commit together or
|
|
15
|
+
// not at all.
|
|
16
|
+
//
|
|
17
|
+
// WHY THE ROLLBACK CASES ARE THE HEART OF THIS FILE. This goal exists because
|
|
18
|
+
// Discord #bugs creates tasks with no idea row and no lineage, so the ideator
|
|
19
|
+
// credit lane (goal 1000067) has nothing to pay against. A routed capture that
|
|
20
|
+
// committed a task and lost its lineage row would manufacture that exact bug on
|
|
21
|
+
// the path whose whole promise is that attribution survives (criterion C3). So
|
|
22
|
+
// "a failure after the task insert leaves NOTHING behind" is not an edge case
|
|
23
|
+
// here — it is the property.
|
|
24
|
+
//
|
|
25
|
+
// Run: node tests/idea_routing.mjs
|
|
26
|
+
|
|
27
|
+
import { strict as assert } from 'node:assert';
|
|
28
|
+
import { createRequire } from 'node:module';
|
|
29
|
+
import { makeRunner, makeSqlAwareClient } from './helpers.mjs';
|
|
30
|
+
|
|
31
|
+
process.env.NODE_ENV = 'test';
|
|
32
|
+
|
|
33
|
+
const require = createRequire(import.meta.url);
|
|
34
|
+
const routing = require('../modules/ideas/routing.js');
|
|
35
|
+
const { decideLanding, authorizeRoutedLanding, routedPriority, DEFAULT_ROUTED_PRIORITY, PROTECTED_SCOPE_FLOOR } = routing;
|
|
36
|
+
|
|
37
|
+
// The lifecycle port is how the ideas module reaches createTask (never an
|
|
38
|
+
// import — the one-way module rule, ADR 0083). Register a stub provider, the
|
|
39
|
+
// production wiring, exactly as tests/onboarding_route_signals.mjs does.
|
|
40
|
+
const seams = require('../src/module-seams.js');
|
|
41
|
+
seams._reset();
|
|
42
|
+
let createTaskCalls = [];
|
|
43
|
+
let createTaskImpl = async () => ({ id: 9001, status: 'backlog', goal_id: 7 });
|
|
44
|
+
seams.registerProvider('lifecycle', {
|
|
45
|
+
createTask: async (args, deps) => {
|
|
46
|
+
createTaskCalls.push({ args, deps });
|
|
47
|
+
return createTaskImpl(args, deps);
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const inbox = require('../modules/ideas/inbox.js');
|
|
52
|
+
const { captureIdea } = inbox;
|
|
53
|
+
|
|
54
|
+
const { test, summary } = makeRunner();
|
|
55
|
+
|
|
56
|
+
const norm = (sql) => String(sql).replace(/\s+/g, ' ').trim();
|
|
57
|
+
const find = (queries, re) => queries.find((q) => re.test(norm(q.sql)));
|
|
58
|
+
const seq = (queries) => queries.map((q) => norm(q.sql));
|
|
59
|
+
|
|
60
|
+
// A routable goal: open, on a version that is still building.
|
|
61
|
+
const LIVE_GOAL = { id: 7, status: 'open', version_id: 'BONGOS-V1', version_status: 'building' };
|
|
62
|
+
const IDEA_ROW = {
|
|
63
|
+
id: 555, title: 'the thing is broken', body_md: 'steps', kind: 'bug',
|
|
64
|
+
suggested_version: null, suggested_priority: null, captured_at: 'T0', captured_by: 42,
|
|
65
|
+
status: 'promoted', promoted_to_task_id: null, reviewed_at: 'T0',
|
|
66
|
+
merged_into_idea_id: null, triage_note: null, suggested_goal_id: 7,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// A fake pool whose `connect()` and `query()` share ONE call log, so a test can
|
|
70
|
+
// assert the whole transaction in order. Mirrors tests/goal_tier.mjs's fakePool.
|
|
71
|
+
function fakePool(handler) {
|
|
72
|
+
const c = makeSqlAwareClient(handler);
|
|
73
|
+
return {
|
|
74
|
+
query: (sql, params) => c._client.query(sql, params),
|
|
75
|
+
connect: c.connect,
|
|
76
|
+
queries: c._client.queries,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The happy-path handler: a live goal, an idea row, and empty results elsewhere.
|
|
81
|
+
function routableHandler(overrides = {}) {
|
|
82
|
+
return (s) => {
|
|
83
|
+
if (/FROM goals g JOIN versions v/.test(s)) {
|
|
84
|
+
return { rows: overrides.goalRows ?? [LIVE_GOAL] };
|
|
85
|
+
}
|
|
86
|
+
if (/INSERT INTO idea_inbox/.test(s)) return { rows: [{ ...IDEA_ROW, ...(overrides.idea || {}) }] };
|
|
87
|
+
return null;
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function reset() {
|
|
92
|
+
createTaskCalls = [];
|
|
93
|
+
createTaskImpl = async () => ({ id: 9001, status: 'backlog', goal_id: 7 });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// =========================================================================
|
|
97
|
+
// 1. The landing matrix — pure, every cell
|
|
98
|
+
// =========================================================================
|
|
99
|
+
|
|
100
|
+
await test('matrix: NO GOAL means the inbox, and that is the default path', () => {
|
|
101
|
+
for (const goalId of [null, undefined, '']) {
|
|
102
|
+
const d = decideLanding({ goalId, kind: 'bug', filerRank: 'archon' });
|
|
103
|
+
assert.equal(d.route, false, `goalId=${JSON.stringify(goalId)} must not route`);
|
|
104
|
+
assert.equal(d.reason, 'no_goal');
|
|
105
|
+
}
|
|
106
|
+
// A bug filed by an Archon is the MOST routable thing there is — proving it
|
|
107
|
+
// still stays put without a goal is what pins "routing is strictly additive".
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await test('matrix: a bug from a trusted filer HARD-LANDS at ready (criterion C1)', () => {
|
|
111
|
+
for (const rank of ['metic', 'archon', 'ARCHON', 'Metic']) {
|
|
112
|
+
const d = decideLanding({ goalId: 7, kind: 'bug', filerRank: rank });
|
|
113
|
+
assert.deepEqual(d, { route: true, status: 'ready', reason: 'bug_trusted_filer' }, `rank ${rank}`);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
await test('matrix: a bug from a sub-Metic filer soft-lands, awaiting a nod', () => {
|
|
118
|
+
for (const rank of ['xenos', 'thetes', 'XENOS']) {
|
|
119
|
+
const d = decideLanding({ goalId: 7, kind: 'bug', filerRank: rank });
|
|
120
|
+
assert.deepEqual(d, { route: true, status: 'backlog', reason: 'bug_awaiting_nod' }, `rank ${rank}`);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await test('matrix: FAIL-CLOSED — an absent or unrecognised rank never hard-lands', () => {
|
|
125
|
+
// The property that matters: no rank input can reach 'ready' except a real
|
|
126
|
+
// trusted tier. A landing decision that misfires puts unvetted work in the
|
|
127
|
+
// claimable queue, so every uncertainty resolves downward.
|
|
128
|
+
for (const rank of [null, undefined, '', 'archon-ish', 'admin', 'root', 0, {}, []]) {
|
|
129
|
+
const d = decideLanding({ goalId: 7, kind: 'bug', filerRank: rank });
|
|
130
|
+
assert.equal(d.status, 'backlog', `rank ${JSON.stringify(rank)} must not reach ready`);
|
|
131
|
+
assert.equal(d.reason, 'bug_awaiting_nod');
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
await test('matrix: every non-bug kind soft-lands, whoever files it (interview Q1)', () => {
|
|
136
|
+
// "Bugs are urgent-true; the rest gets a nod." cleanup/refactor/infra were the
|
|
137
|
+
// open question the owner settled — they soft-land like features, not like bugs.
|
|
138
|
+
for (const kind of ['feature', 'cleanup', 'refactor', 'infra', 'learning-capture',
|
|
139
|
+
'blocker-resolution', 'unclassified', 'spike']) {
|
|
140
|
+
const d = decideLanding({ goalId: 7, kind, filerRank: 'archon' });
|
|
141
|
+
assert.deepEqual(d, { route: true, status: 'backlog', reason: 'soft_landing' }, `kind ${kind}`);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
await test('matrix: a Full Idea never auto-routes — it is the Board Room\'s subject', () => {
|
|
146
|
+
for (const grade of ['full', 'FULL', 'Full']) {
|
|
147
|
+
const d = decideLanding({ goalId: 7, kind: 'feature', grade, filerRank: 'archon' });
|
|
148
|
+
assert.deepEqual(d, { route: false, reason: 'full_idea_never_routes' }, `grade ${grade}`);
|
|
149
|
+
}
|
|
150
|
+
// ...while quick/spark/absent route exactly as their kind says.
|
|
151
|
+
for (const grade of ['quick', 'spark', null, undefined]) {
|
|
152
|
+
assert.equal(decideLanding({ goalId: 7, kind: 'feature', grade }).route, true, `grade ${grade}`);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
await test('matrix: judgment kinds never auto-route', () => {
|
|
157
|
+
for (const kind of ['criterion-proposal', 'decision']) {
|
|
158
|
+
assert.deepEqual(
|
|
159
|
+
decideLanding({ goalId: 7, kind, filerRank: 'archon' }),
|
|
160
|
+
{ route: false, reason: 'judgment_kind' },
|
|
161
|
+
`kind ${kind}`
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await test('matrix: decideLanding never throws, on any input', () => {
|
|
167
|
+
for (const args of [undefined, {}, null && {}, { goalId: 7 }, { goalId: 7, kind: null }]) {
|
|
168
|
+
assert.doesNotThrow(() => decideLanding(args));
|
|
169
|
+
}
|
|
170
|
+
assert.equal(decideLanding().route, false, 'no args ⇒ no route');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
await test('routedPriority: the filer\'s suggestion when sane, else the middle of the band', () => {
|
|
174
|
+
for (const p of [1, 2, 3, 4, 5]) assert.equal(routedPriority(p), p);
|
|
175
|
+
for (const p of [0, 6, -1, 99, null, undefined, 'high', 2.5, NaN]) {
|
|
176
|
+
assert.equal(routedPriority(p), DEFAULT_ROUTED_PRIORITY, `${JSON.stringify(p)} ⇒ default`);
|
|
177
|
+
}
|
|
178
|
+
assert.equal(DEFAULT_ROUTED_PRIORITY, 3);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// =========================================================================
|
|
182
|
+
// 2. captureIdea — the NON-routing path is byte-for-byte today's behaviour
|
|
183
|
+
// =========================================================================
|
|
184
|
+
|
|
185
|
+
await test('capture with no goal: ONE insert, status open, no transaction at all', async () => {
|
|
186
|
+
reset();
|
|
187
|
+
const pool = fakePool((s) => (/INSERT INTO idea_inbox/.test(s)
|
|
188
|
+
? { rows: [{ ...IDEA_ROW, status: 'open', suggested_goal_id: null, reviewed_at: null }] }
|
|
189
|
+
: null));
|
|
190
|
+
const idea = await captureIdea({ title: 'a thought', capturedBy: 42 }, { pool });
|
|
191
|
+
const sqls = seq(pool.queries);
|
|
192
|
+
assert.equal(sqls.filter((q) => /^BEGIN$/.test(q)).length, 0, 'no transaction is opened');
|
|
193
|
+
assert.equal(sqls.length, 1, 'exactly one statement');
|
|
194
|
+
assert.match(sqls[0], /INSERT INTO idea_inbox/);
|
|
195
|
+
assert.match(sqls[0], /'open'/, "status is 'open'");
|
|
196
|
+
assert.equal(createTaskCalls.length, 0, 'no task is created');
|
|
197
|
+
assert.deepEqual(idea.routing, { routed: false, reason: 'no_goal' });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
await test('a named goal the matrix DECLINES is kept as the core_222 hint, not dropped', async () => {
|
|
201
|
+
reset();
|
|
202
|
+
const pool = fakePool((s) => (/INSERT INTO idea_inbox/.test(s)
|
|
203
|
+
? { rows: [{ ...IDEA_ROW, kind: 'decision', status: 'open' }] } : null));
|
|
204
|
+
const idea = await captureIdea(
|
|
205
|
+
{ title: 'which cache?', kind: 'decision', goalId: 7, capturedBy: 42, filerRank: 'archon' },
|
|
206
|
+
{ pool }
|
|
207
|
+
);
|
|
208
|
+
const ins = find(pool.queries, /INSERT INTO idea_inbox/);
|
|
209
|
+
assert.match(norm(ins.sql), /suggested_goal_id/, 'the hint column is written');
|
|
210
|
+
assert.equal(ins.params[6], 7, 'the declined goal is carried forward as the hint');
|
|
211
|
+
assert.equal(createTaskCalls.length, 0, 'a judgment kind mints no task');
|
|
212
|
+
assert.deepEqual(idea.routing, { routed: false, reason: 'judgment_kind' },
|
|
213
|
+
'and the reason says WHY it declined, so a filer is not left guessing');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
await test('the pre-routing validations still fire before any write', async () => {
|
|
217
|
+
reset();
|
|
218
|
+
const pool = fakePool(() => null);
|
|
219
|
+
await assert.rejects(() => captureIdea({ title: '', goalId: 7 }, { pool }),
|
|
220
|
+
(e) => e.code === 'TITLE_REQUIRED');
|
|
221
|
+
await assert.rejects(() => captureIdea({ title: 'x', kind: 'nonsense', goalId: 7 }, { pool }),
|
|
222
|
+
(e) => e.code === 'BAD_KIND');
|
|
223
|
+
await assert.rejects(() => captureIdea({ title: 'x', suggestedPriority: 9, goalId: 7 }, { pool }),
|
|
224
|
+
(e) => e.code === 'BAD_PRIORITY');
|
|
225
|
+
assert.equal(pool.queries.length, 0, 'a bad payload touches the database zero times');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// =========================================================================
|
|
229
|
+
// 3. captureIdea — the ROUTED path: one transaction, in order
|
|
230
|
+
// =========================================================================
|
|
231
|
+
|
|
232
|
+
await test('a routed bug: idea + task + lineage in ONE transaction, in FK order', async () => {
|
|
233
|
+
reset();
|
|
234
|
+
createTaskImpl = async () => ({ id: 9001, status: 'ready', goal_id: 7, version_id: 'BONGOS-V1' });
|
|
235
|
+
const pool = fakePool(routableHandler());
|
|
236
|
+
const out = await captureIdea(
|
|
237
|
+
{ title: 'the thing is broken', bodyMd: 'steps', kind: 'bug', goalId: 7, capturedBy: 42, filerRank: 'archon' },
|
|
238
|
+
{ pool }
|
|
239
|
+
);
|
|
240
|
+
const sqls = seq(pool.queries);
|
|
241
|
+
assert.equal(sqls[0], 'BEGIN', 'opens a transaction first');
|
|
242
|
+
assert.equal(sqls[sqls.length - 1], 'COMMIT', 'and commits last');
|
|
243
|
+
|
|
244
|
+
// The order is forced by the foreign keys, not by taste: lineage references
|
|
245
|
+
// BOTH rows, so idea → task → lineage is the only legal sequence.
|
|
246
|
+
const iGoal = sqls.findIndex((q) => /FROM goals g JOIN versions v/.test(q));
|
|
247
|
+
const iIdea = sqls.findIndex((q) => /INSERT INTO idea_inbox/.test(q));
|
|
248
|
+
const iLin = sqls.findIndex((q) => /INSERT INTO ideas_task_lineage/.test(q));
|
|
249
|
+
const iUpd = sqls.findIndex((q) => /UPDATE idea_inbox SET promoted_to_task_id/.test(q));
|
|
250
|
+
assert.ok(iGoal > 0 && iGoal < iIdea, 'the goal is validated BEFORE anything is written');
|
|
251
|
+
assert.ok(iIdea < iLin, 'the idea row exists before the lineage row references it');
|
|
252
|
+
assert.ok(iLin < iUpd, 'and promoted_to_task_id is closed out last');
|
|
253
|
+
|
|
254
|
+
// The idea is 'promoted' from birth — routing's claim is that it never needed
|
|
255
|
+
// a triage pass, and reviewed_at is stamped so the triage walk skips it.
|
|
256
|
+
const ins = find(pool.queries, /INSERT INTO idea_inbox/);
|
|
257
|
+
assert.match(norm(ins.sql), /'promoted'/, "status is 'promoted', not 'open'");
|
|
258
|
+
assert.match(norm(ins.sql), /reviewed_at/, 'reviewed_at is stamped');
|
|
259
|
+
|
|
260
|
+
// origin='route' — the third provenance ideas_003 widened the CHECK for.
|
|
261
|
+
const lin = find(pool.queries, /INSERT INTO ideas_task_lineage/);
|
|
262
|
+
assert.match(norm(lin.sql), /'route'/, "origin is 'route', not 'promote'");
|
|
263
|
+
assert.deepEqual(lin.params, [9001, 555], 'lineage binds (task_id, idea_id)');
|
|
264
|
+
|
|
265
|
+
assert.equal(out.routing.routed, true);
|
|
266
|
+
assert.equal(out.routing.task_id, 9001);
|
|
267
|
+
assert.equal(out.promoted_to_task_id, 9001, 'the returned row already reflects the link');
|
|
268
|
+
assert.equal(out.routed_task.id, 9001, 'and the task itself comes back');
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
await test('the task INSERT is ENLISTED in the transaction — the whole point', async () => {
|
|
272
|
+
reset();
|
|
273
|
+
const pool = fakePool(routableHandler());
|
|
274
|
+
await captureIdea(
|
|
275
|
+
{ title: 'broken', kind: 'bug', goalId: 7, capturedBy: 42, filerRank: 'metic' },
|
|
276
|
+
{ pool }
|
|
277
|
+
);
|
|
278
|
+
assert.equal(createTaskCalls.length, 1);
|
|
279
|
+
const { args, deps } = createTaskCalls[0];
|
|
280
|
+
assert.ok(deps && deps.client, 'createTask is handed the tx client, not left on the pool');
|
|
281
|
+
assert.equal(typeof deps.client.query, 'function');
|
|
282
|
+
// If this ever regresses to a pool write, the task commits independently of
|
|
283
|
+
// its lineage row and we have rebuilt the Discord #bugs bug.
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
await test('the routed task carries the goal, the matrix status, and honest provenance', async () => {
|
|
287
|
+
reset();
|
|
288
|
+
const pool = fakePool(routableHandler());
|
|
289
|
+
await captureIdea(
|
|
290
|
+
{ title: 'broken', bodyMd: 'the body', kind: 'bug', goalId: 7, capturedBy: 42, filerRank: 'archon' },
|
|
291
|
+
{ pool }
|
|
292
|
+
);
|
|
293
|
+
const { args } = createTaskCalls[0];
|
|
294
|
+
assert.equal(args.goalId, 7, 'in the goal it was filed against');
|
|
295
|
+
assert.equal(args.versionId, 'BONGOS-V1', "on the GOAL'S version, never a caller-supplied one");
|
|
296
|
+
assert.equal(args.status, 'ready', 'at the matrix status');
|
|
297
|
+
assert.equal(args.kind, 'bug');
|
|
298
|
+
assert.equal(args.title, 'broken');
|
|
299
|
+
assert.equal(args.description, 'the body');
|
|
300
|
+
assert.equal(args.source, 'idea-route');
|
|
301
|
+
assert.equal(args.sourceRef, 'idea-555', 'keyed to the idea, so one idea mints one task');
|
|
302
|
+
// Deliberately absent: an explicit rank floor and a reward. createTask derives
|
|
303
|
+
// the ADR 0084/0085 floor itself and auto-assigns the capped reward; a filer
|
|
304
|
+
// must not be able to hand either one in.
|
|
305
|
+
assert.equal(args.requiresRank, undefined, 'no filer-supplied rank floor');
|
|
306
|
+
assert.equal(args.creditsReward, undefined, 'no filer-supplied reward');
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
await test('priority: the filer\'s suggestion rides through, else the band middle', async () => {
|
|
310
|
+
reset();
|
|
311
|
+
let pool = fakePool(routableHandler({ idea: { suggested_priority: 1 } }));
|
|
312
|
+
await captureIdea({ title: 'x', kind: 'bug', goalId: 7, suggestedPriority: 1, filerRank: 'archon' }, { pool });
|
|
313
|
+
assert.equal(createTaskCalls[0].args.priority, 1);
|
|
314
|
+
|
|
315
|
+
reset();
|
|
316
|
+
pool = fakePool(routableHandler());
|
|
317
|
+
await captureIdea({ title: 'x', kind: 'bug', goalId: 7, filerRank: 'archon' }, { pool });
|
|
318
|
+
assert.equal(createTaskCalls[0].args.priority, DEFAULT_ROUTED_PRIORITY);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// =========================================================================
|
|
322
|
+
// 4. The refusals — a named goal that is not a LIVE HOME
|
|
323
|
+
// =========================================================================
|
|
324
|
+
|
|
325
|
+
await test('a goal that does not exist: 404-shaped refusal, nothing written', async () => {
|
|
326
|
+
reset();
|
|
327
|
+
const pool = fakePool(routableHandler({ goalRows: [] }));
|
|
328
|
+
await assert.rejects(
|
|
329
|
+
() => captureIdea({ title: 'x', kind: 'bug', goalId: 404, filerRank: 'archon' }, { pool }),
|
|
330
|
+
(e) => e.code === 'GOAL_NOT_FOUND' && e.goal_id === 404
|
|
331
|
+
);
|
|
332
|
+
assert.equal(find(pool.queries, /INSERT INTO idea_inbox/), undefined, 'no idea row');
|
|
333
|
+
assert.equal(createTaskCalls.length, 0, 'no task');
|
|
334
|
+
assert.ok(find(pool.queries, /^ROLLBACK$/), 'and the transaction is rolled back');
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
await test('an achieved or archived goal is not a home', async () => {
|
|
338
|
+
for (const status of ['achieved', 'archived']) {
|
|
339
|
+
reset();
|
|
340
|
+
const pool = fakePool(routableHandler({ goalRows: [{ ...LIVE_GOAL, status }] }));
|
|
341
|
+
await assert.rejects(
|
|
342
|
+
() => captureIdea({ title: 'x', kind: 'bug', goalId: 7, filerRank: 'archon' }, { pool }),
|
|
343
|
+
(e) => e.code === 'GOAL_NOT_OPEN' && e.status === status,
|
|
344
|
+
`goal status ${status}`
|
|
345
|
+
);
|
|
346
|
+
assert.equal(createTaskCalls.length, 0);
|
|
347
|
+
assert.ok(find(pool.queries, /^ROLLBACK$/));
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
await test('THE 1000771 FAILURE: a non-building version can never receive routed work', async () => {
|
|
352
|
+
// The bug this whole goal was filed over — Discord #bugs hardcoded a SHIPPED
|
|
353
|
+
// version, so every reported bug landed in a shipped version's catch-all where
|
|
354
|
+
// nobody would look again. A route refuses rather than filing into a tomb.
|
|
355
|
+
for (const version_status of ['shipped', 'planning', 'frozen']) {
|
|
356
|
+
reset();
|
|
357
|
+
const pool = fakePool(routableHandler({ goalRows: [{ ...LIVE_GOAL, version_status }] }));
|
|
358
|
+
await assert.rejects(
|
|
359
|
+
() => captureIdea({ title: 'x', kind: 'bug', goalId: 7, filerRank: 'archon' }, { pool }),
|
|
360
|
+
(e) => e.code === 'VERSION_NOT_BUILDING' && e.version_status === version_status,
|
|
361
|
+
`version status ${version_status}`
|
|
362
|
+
);
|
|
363
|
+
assert.equal(createTaskCalls.length, 0, `no task on a ${version_status} version`);
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// =========================================================================
|
|
368
|
+
// 5. Atomicity — the property, not an edge case
|
|
369
|
+
// =========================================================================
|
|
370
|
+
|
|
371
|
+
await test('a task-create failure leaves NO orphan idea row (rollback)', async () => {
|
|
372
|
+
reset();
|
|
373
|
+
createTaskImpl = async () => { throw new Error('tasks table is on fire'); };
|
|
374
|
+
const pool = fakePool(routableHandler());
|
|
375
|
+
await assert.rejects(
|
|
376
|
+
() => captureIdea({ title: 'x', kind: 'bug', goalId: 7, filerRank: 'archon' }, { pool }),
|
|
377
|
+
/on fire/
|
|
378
|
+
);
|
|
379
|
+
assert.ok(find(pool.queries, /INSERT INTO idea_inbox/), 'the insert was attempted...');
|
|
380
|
+
assert.ok(find(pool.queries, /^ROLLBACK$/), '...and then rolled back');
|
|
381
|
+
assert.equal(find(pool.queries, /^COMMIT$/), undefined, 'never committed');
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
await test('a LINEAGE failure rolls the task back too — attribution or nothing (C3)', async () => {
|
|
385
|
+
reset();
|
|
386
|
+
const pool = fakePool((s) => {
|
|
387
|
+
if (/INSERT INTO ideas_task_lineage/.test(s)) throw new Error('lineage check violated');
|
|
388
|
+
return routableHandler()(s);
|
|
389
|
+
});
|
|
390
|
+
await assert.rejects(
|
|
391
|
+
() => captureIdea({ title: 'x', kind: 'bug', goalId: 7, filerRank: 'archon' }, { pool }),
|
|
392
|
+
/lineage/
|
|
393
|
+
);
|
|
394
|
+
assert.ok(find(pool.queries, /^ROLLBACK$/));
|
|
395
|
+
assert.equal(find(pool.queries, /^COMMIT$/), undefined,
|
|
396
|
+
'a task with no lineage row IS the bug this goal abolishes — it must not commit');
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
await test('the goal row is LOCKED, so it cannot be archived mid-route', async () => {
|
|
400
|
+
reset();
|
|
401
|
+
const pool = fakePool(routableHandler());
|
|
402
|
+
await captureIdea({ title: 'x', kind: 'bug', goalId: 7, filerRank: 'archon' }, { pool });
|
|
403
|
+
const sel = find(pool.queries, /FROM goals g JOIN versions v/);
|
|
404
|
+
assert.match(norm(sel.sql), /FOR SHARE OF g/, 'the goal row is held for the transaction');
|
|
405
|
+
assert.match(norm(sel.sql), /v\.status AS version_status/,
|
|
406
|
+
'and the version status is read in the SAME statement, not a second round trip');
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
await test('no lifecycle port ⇒ a loud refusal, never a silent inbox row', async () => {
|
|
410
|
+
reset();
|
|
411
|
+
const saved = seams.resolveOptional('lifecycle');
|
|
412
|
+
seams._reset();
|
|
413
|
+
try {
|
|
414
|
+
const pool = fakePool(routableHandler());
|
|
415
|
+
await assert.rejects(
|
|
416
|
+
() => captureIdea({ title: 'x', kind: 'bug', goalId: 7, filerRank: 'archon' }, { pool }),
|
|
417
|
+
(e) => e.code === 'LIFECYCLE_UNAVAILABLE'
|
|
418
|
+
);
|
|
419
|
+
assert.ok(find(pool.queries, /^ROLLBACK$/));
|
|
420
|
+
// The alternative — degrading to an inbox row — would tell the filer their
|
|
421
|
+
// bug became a task when it did not. Refusing is the honest failure.
|
|
422
|
+
} finally {
|
|
423
|
+
seams._reset();
|
|
424
|
+
seams.registerProvider('lifecycle', saved);
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
// =========================================================================
|
|
429
|
+
// 6. THE ROUTE — POST /inbox, through the real handler
|
|
430
|
+
//
|
|
431
|
+
// The layer test above proves the transaction. This proves the CONTRACT a client
|
|
432
|
+
// sees: where the filer's rank comes from, what the refusals look like, and that
|
|
433
|
+
// omitting goal_id leaves the endpoint exactly as it was. Driven the
|
|
434
|
+
// tests/idea_full_capture.mjs way — patch the doorway pool, pull the real
|
|
435
|
+
// handler off the mounted router, stub req/res — so the same validator and the
|
|
436
|
+
// same error map a client hits are the ones under test.
|
|
437
|
+
// =========================================================================
|
|
438
|
+
|
|
439
|
+
const apiDoorway = require('../src/module-api.js');
|
|
440
|
+
|
|
441
|
+
let routeGoalRows = [LIVE_GOAL];
|
|
442
|
+
apiDoorway.pool.query = async (sql) => {
|
|
443
|
+
if (/SELECT COUNT|count\(/i.test(String(sql))) return { rows: [{ n: '3', count: '3' }] };
|
|
444
|
+
if (/INSERT INTO idea_inbox/i.test(String(sql))) {
|
|
445
|
+
return { rows: [{ ...IDEA_ROW, status: 'open', suggested_goal_id: null }] };
|
|
446
|
+
}
|
|
447
|
+
return { rows: [] };
|
|
448
|
+
};
|
|
449
|
+
apiDoorway.pool.connect = async () => makeSqlAwareClient((s) => {
|
|
450
|
+
if (/FROM goals g JOIN versions v/.test(s)) return { rows: routeGoalRows };
|
|
451
|
+
if (/INSERT INTO idea_inbox/.test(s)) return { rows: [IDEA_ROW] };
|
|
452
|
+
return null;
|
|
453
|
+
}).connect();
|
|
454
|
+
|
|
455
|
+
const postInbox = (() => {
|
|
456
|
+
const router = require('../modules/ideas/routes/inbox.js')({});
|
|
457
|
+
const layer = router.stack.find((l) => l.route && l.route.path === '/inbox' && l.route.methods.post);
|
|
458
|
+
const handlers = layer.route.stack.map((h) => h.handle);
|
|
459
|
+
return handlers[handlers.length - 1]; // past requireBuilder
|
|
460
|
+
})();
|
|
461
|
+
|
|
462
|
+
// The res double the ADR 0116 envelope needs: res.fail is what every refusal in
|
|
463
|
+
// this module calls, so a test that only implements .json would see a crash
|
|
464
|
+
// instead of the 4xx a client gets.
|
|
465
|
+
function call(body, builder = { id: '42', rank: 'archon' }) {
|
|
466
|
+
return new Promise((resolve) => {
|
|
467
|
+
const out = { status: 200, body: null };
|
|
468
|
+
const res = {
|
|
469
|
+
status(c) { out.status = c; return this; },
|
|
470
|
+
json(b) { out.body = b; resolve(out); return this; },
|
|
471
|
+
fail(code, statusOrOpts, details) {
|
|
472
|
+
const st = typeof statusOrOpts === 'number' ? statusOrOpts : (statusOrOpts && statusOrOpts.status) || 400;
|
|
473
|
+
out.status = st;
|
|
474
|
+
out.body = { error: { code, details: details || null } };
|
|
475
|
+
resolve(out);
|
|
476
|
+
return this;
|
|
477
|
+
},
|
|
478
|
+
};
|
|
479
|
+
Promise.resolve(postInbox({ body, builder, query: {} }, res)).catch((e) => {
|
|
480
|
+
out.status = 500; out.body = { threw: String(e && e.message) }; resolve(out);
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
await test('route: POST /inbox with a goal_id returns the TASK it became', async () => {
|
|
486
|
+
reset();
|
|
487
|
+
routeGoalRows = [LIVE_GOAL];
|
|
488
|
+
createTaskImpl = async () => ({ id: 9001, status: 'ready', goal_id: 7, version_id: 'BONGOS-V1' });
|
|
489
|
+
const r = await call({ title: 'the thing is broken', kind: 'bug', goal_id: 7 });
|
|
490
|
+
assert.equal(r.status, 201, JSON.stringify(r.body));
|
|
491
|
+
assert.equal(r.body.routed_task.id, 9001, 'the task is at the TOP level, not buried in the idea');
|
|
492
|
+
assert.equal(r.body.idea.routing.routed, true);
|
|
493
|
+
assert.equal(r.body.idea.routing.reason, 'bug_trusted_filer');
|
|
494
|
+
assert.equal(r.body.idea.routed_task, undefined, 'and is not duplicated inside the idea row');
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
await test('route: THE FILER\'S RANK COMES FROM AUTH, never from the body', async () => {
|
|
498
|
+
// The property that decides whether filing can outrun rank (criterion C4),
|
|
499
|
+
// asserted from both sides.
|
|
500
|
+
//
|
|
501
|
+
// (a) A forged rank in the body is refused outright — the BV1.R109c
|
|
502
|
+
// unknown-field validator answers before the handler runs. A stronger
|
|
503
|
+
// answer than ignoring it, and it means the matrix never sees the value.
|
|
504
|
+
reset();
|
|
505
|
+
routeGoalRows = [LIVE_GOAL];
|
|
506
|
+
const forged = await call({ title: 'broken', kind: 'bug', goal_id: 7, rank: 'archon' }, { id: '99', rank: 'xenos' });
|
|
507
|
+
assert.equal(forged.status, 400, JSON.stringify(forged.body));
|
|
508
|
+
assert.equal(forged.body.error.code, 'validation_failed');
|
|
509
|
+
assert.deepEqual(forged.body.error.details, [{ field: 'rank', reason: 'unknown_field' }]);
|
|
510
|
+
assert.equal(createTaskCalls.length, 0, 'and nothing was created');
|
|
511
|
+
|
|
512
|
+
// (b) With an IDENTICAL legal body, the landing differs purely by the
|
|
513
|
+
// authenticated builder's rank — which is the positive proof that
|
|
514
|
+
// req.builder.rank is what reaches the matrix.
|
|
515
|
+
const body = { title: 'broken', kind: 'bug', goal_id: 7 };
|
|
516
|
+
reset();
|
|
517
|
+
createTaskImpl = async (a) => ({ id: 1, status: a.status, goal_id: 7 });
|
|
518
|
+
const asArchon = await call({ ...body }, { id: '1', rank: 'archon' });
|
|
519
|
+
assert.equal(asArchon.status, 201, JSON.stringify(asArchon.body));
|
|
520
|
+
assert.equal(asArchon.body.idea.routing.status, 'ready');
|
|
521
|
+
|
|
522
|
+
reset();
|
|
523
|
+
createTaskImpl = async (a) => ({ id: 2, status: a.status, goal_id: 7 });
|
|
524
|
+
const asXenos = await call({ ...body }, { id: '2', rank: 'xenos' });
|
|
525
|
+
assert.equal(asXenos.status, 201, JSON.stringify(asXenos.body));
|
|
526
|
+
assert.equal(asXenos.body.idea.routing.status, 'backlog',
|
|
527
|
+
'same request, lower rank ⇒ soft landing: filing cannot outrun rank');
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
await test('route: a sub-Metic filer\'s bug lands at backlog, awaiting a nod', async () => {
|
|
531
|
+
reset();
|
|
532
|
+
routeGoalRows = [LIVE_GOAL];
|
|
533
|
+
createTaskImpl = async (a) => ({ id: 9002, status: a.status, goal_id: 7 });
|
|
534
|
+
const r = await call({ title: 'broken', kind: 'bug', goal_id: 7 }, { id: '99', rank: 'thetes' });
|
|
535
|
+
assert.equal(r.status, 201, JSON.stringify(r.body));
|
|
536
|
+
assert.equal(r.body.idea.routing.status, 'backlog');
|
|
537
|
+
assert.equal(r.body.idea.routing.reason, 'bug_awaiting_nod');
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
await test('route: the refusals map to 404 / 409, with the offending id echoed', async () => {
|
|
541
|
+
reset();
|
|
542
|
+
routeGoalRows = [];
|
|
543
|
+
let r = await call({ title: 'x', kind: 'bug', goal_id: 4040 });
|
|
544
|
+
assert.equal(r.status, 404, JSON.stringify(r.body));
|
|
545
|
+
assert.equal(r.body.error.code, 'GOAL_NOT_FOUND');
|
|
546
|
+
|
|
547
|
+
reset();
|
|
548
|
+
routeGoalRows = [{ ...LIVE_GOAL, status: 'archived' }];
|
|
549
|
+
r = await call({ title: 'x', kind: 'bug', goal_id: 7 });
|
|
550
|
+
assert.equal(r.status, 409, JSON.stringify(r.body));
|
|
551
|
+
assert.equal(r.body.error.code, 'GOAL_NOT_OPEN');
|
|
552
|
+
|
|
553
|
+
reset();
|
|
554
|
+
routeGoalRows = [{ ...LIVE_GOAL, version_status: 'shipped' }];
|
|
555
|
+
r = await call({ title: 'x', kind: 'bug', goal_id: 7 });
|
|
556
|
+
assert.equal(r.status, 409, JSON.stringify(r.body));
|
|
557
|
+
assert.equal(r.body.error.code, 'VERSION_NOT_BUILDING');
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
await test('route: NO goal_id is still the plain old capture — 201, no task', async () => {
|
|
561
|
+
reset();
|
|
562
|
+
const r = await call({ title: 'just a thought' });
|
|
563
|
+
assert.equal(r.status, 201, JSON.stringify(r.body));
|
|
564
|
+
assert.equal(r.body.routed_task, undefined, 'nothing routed');
|
|
565
|
+
assert.equal(createTaskCalls.length, 0, 'no task created');
|
|
566
|
+
assert.equal(r.body.idea.routing.reason, 'no_goal');
|
|
567
|
+
assert.ok('open_count' in r.body, 'and the pre-routing response shape is intact');
|
|
568
|
+
assert.ok('kind_auto' in r.body);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
// =========================================================================
|
|
572
|
+
// 7. R05 — ENFORCEMENT: the rank floor and the scope wall (task 1003066)
|
|
573
|
+
//
|
|
574
|
+
// R01 decided the queue status. This is the half that BINDS: a routed task
|
|
575
|
+
// declares no touches[], so createTask's ADR 0084/0085 floor derives from an
|
|
576
|
+
// empty list and comes out 'xenos' — a bug routed into a goal scoped to the
|
|
577
|
+
// PROTECTED core was born claimable by a Xenos the moment anyone nodded it.
|
|
578
|
+
// =========================================================================
|
|
579
|
+
|
|
580
|
+
await test('authorizeRoutedLanding: a protected-scope goal raises the rank floor', () => {
|
|
581
|
+
// filerRank must be a trusted tier here: a sub-Metic is refused outright by the
|
|
582
|
+
// protected_scope rule above, so the floor is only reachable for Metic+.
|
|
583
|
+
const landing = { route: true, status: 'ready', reason: 'bug_trusted_filer' };
|
|
584
|
+
assert.deepEqual(
|
|
585
|
+
authorizeRoutedLanding({ landing, scopeProtected: true, filerRank: 'archon' }),
|
|
586
|
+
{ ok: true, status: 'ready', requiresRank: PROTECTED_SCOPE_FLOOR }
|
|
587
|
+
);
|
|
588
|
+
assert.equal(PROTECTED_SCOPE_FLOOR, 'metic');
|
|
589
|
+
// An ordinary goal says NOTHING, so createTask derives the floor exactly as it
|
|
590
|
+
// does for every other create vector. Speaking only when there is something to
|
|
591
|
+
// say is what keeps this from becoming a second, competing floor.
|
|
592
|
+
assert.deepEqual(
|
|
593
|
+
authorizeRoutedLanding({ landing, scopeProtected: false, filerRank: 'xenos' }),
|
|
594
|
+
{ ok: true, status: 'ready', requiresRank: null }
|
|
595
|
+
);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
await test('authorizeRoutedLanding: protected scope refuses a sub-Metic outright', () => {
|
|
599
|
+
const landing = { route: true, status: 'backlog', reason: 'bug_awaiting_nod' };
|
|
600
|
+
for (const rank of ['xenos', 'thetes', null, undefined, '', 'archon-ish']) {
|
|
601
|
+
const out = authorizeRoutedLanding({ landing, scopeProtected: true, filerRank: rank });
|
|
602
|
+
assert.equal(out.ok, false, `rank ${JSON.stringify(rank)} must be refused`);
|
|
603
|
+
assert.equal(out.code, 'protected_scope');
|
|
604
|
+
assert.equal(out.details.required_rank, PROTECTED_SCOPE_FLOOR);
|
|
605
|
+
}
|
|
606
|
+
for (const rank of ['metic', 'archon', 'ARCHON']) {
|
|
607
|
+
const out = authorizeRoutedLanding({ landing, scopeProtected: true, filerRank: rank });
|
|
608
|
+
assert.equal(out.ok, true, `rank ${rank} may route into protected scope`);
|
|
609
|
+
assert.equal(out.requiresRank, PROTECTED_SCOPE_FLOOR, 'and is still floored');
|
|
610
|
+
}
|
|
611
|
+
// An ordinary goal is untouched by this rule — a Xenos routes there freely.
|
|
612
|
+
assert.equal(authorizeRoutedLanding({ landing, scopeProtected: false, filerRank: 'xenos' }).ok, true);
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
await test('authorizeRoutedLanding: the wall\'s verdict passes through UNCHANGED', () => {
|
|
616
|
+
// One definition of "out of scope" — the routed refusal is the authored
|
|
617
|
+
// refusal, code, reason and details included.
|
|
618
|
+
const wall = { ok: false, code: 'out_of_scope', reason: 'a path is outside', details: { out_of_scope: ['x'] } };
|
|
619
|
+
const out = authorizeRoutedLanding({
|
|
620
|
+
landing: { route: true, status: 'backlog', reason: 'soft_landing' },
|
|
621
|
+
scopeProtected: true, scopeCheck: wall,
|
|
622
|
+
});
|
|
623
|
+
assert.equal(out.ok, false);
|
|
624
|
+
assert.equal(out.code, 'out_of_scope');
|
|
625
|
+
assert.equal(out.reason, wall.reason);
|
|
626
|
+
assert.deepEqual(out.details, wall.details);
|
|
627
|
+
// And note the wall is checked BEFORE the floor is granted: a refused filing
|
|
628
|
+
// never returns a status at all.
|
|
629
|
+
assert.equal(out.status, undefined);
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
await test('authorizeRoutedLanding: fail-closed on a non-routing or absent verdict', () => {
|
|
633
|
+
for (const landing of [null, undefined, { route: false, reason: 'no_goal' }, {}]) {
|
|
634
|
+
const out = authorizeRoutedLanding({ landing });
|
|
635
|
+
assert.equal(out.ok, false, `landing ${JSON.stringify(landing)}`);
|
|
636
|
+
assert.equal(out.code, 'not_routing');
|
|
637
|
+
}
|
|
638
|
+
assert.equal(authorizeRoutedLanding().ok, false, 'no args ⇒ refuse');
|
|
639
|
+
assert.equal(authorizeRoutedLanding(null).ok, false, 'null ⇒ refuse, not a throw');
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
await test('a protected-scope goal stamps requires_rank=metic on the routed task', async () => {
|
|
643
|
+
// goal 1000071\'s own shape: scope includes `lifecycle`, which is protected.
|
|
644
|
+
reset();
|
|
645
|
+
const pool = fakePool(routableHandler({
|
|
646
|
+
goalRows: [{ ...LIVE_GOAL, scope_modules: ['ideas', 'hall-ui', 'discord', 'lifecycle'] }],
|
|
647
|
+
}));
|
|
648
|
+
const out = await captureIdea(
|
|
649
|
+
{ title: 'broken', kind: 'bug', goalId: 7, capturedBy: 42, filerRank: 'archon' },
|
|
650
|
+
{ pool }
|
|
651
|
+
);
|
|
652
|
+
assert.equal(createTaskCalls[0].args.requiresRank, 'metic',
|
|
653
|
+
'a routed task in protected territory can never be claimed by a sub-Metic');
|
|
654
|
+
assert.equal(out.routing.requires_rank, 'metic', 'and the verdict says so out loud');
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
await test('an ordinary goal passes NO floor — createTask derives it, as for any vector', async () => {
|
|
658
|
+
reset();
|
|
659
|
+
const pool = fakePool(routableHandler({ goalRows: [{ ...LIVE_GOAL, scope_modules: ['ideas'] }] }));
|
|
660
|
+
const out = await captureIdea(
|
|
661
|
+
{ title: 'broken', kind: 'bug', goalId: 7, capturedBy: 42, filerRank: 'archon' },
|
|
662
|
+
{ pool }
|
|
663
|
+
);
|
|
664
|
+
assert.equal('requiresRank' in createTaskCalls[0].args, false, 'the key is absent, not null');
|
|
665
|
+
assert.equal(out.routing.requires_rank, null);
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
await test('a sub-Metic cannot route into PROTECTED scope at all — it is refused', async () => {
|
|
669
|
+
// The narc's major on the first R05 round: with no touches[] the protected-path
|
|
670
|
+
// arm had nothing to inspect, so a sub-Metic could MINT a row in the
|
|
671
|
+
// permission/ship/deploy core's queue and only the rank floor stopped them
|
|
672
|
+
// CLAIMING it. POST /goals/:id/tasks refuses that author outright, and
|
|
673
|
+
// admission refuses them the goal entirely — the routed path was the odd one
|
|
674
|
+
// out. Now it refuses too.
|
|
675
|
+
reset();
|
|
676
|
+
const pool = fakePool(routableHandler({ goalRows: [{ ...LIVE_GOAL, scope_modules: ['lifecycle'] }] }));
|
|
677
|
+
await assert.rejects(
|
|
678
|
+
() => captureIdea({ title: 'broken', kind: 'bug', goalId: 7, capturedBy: 9, filerRank: 'xenos' }, { pool }),
|
|
679
|
+
(e) => e.code === 'ROUTE_NOT_AUTHORIZED' && e.gate === 'protected_scope'
|
|
680
|
+
);
|
|
681
|
+
assert.equal(createTaskCalls.length, 0, 'no row lands in protected territory');
|
|
682
|
+
assert.ok(find(pool.queries, /^ROLLBACK$/));
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
await test('...and the refusal costs the filer nothing they had before', async () => {
|
|
686
|
+
// The whole reason refusing is acceptable: the SAME report with no goal_id
|
|
687
|
+
// still files, exactly as it always did. Nobody loses the ability to report.
|
|
688
|
+
reset();
|
|
689
|
+
const pool = fakePool((s) => (/INSERT INTO idea_inbox/.test(s)
|
|
690
|
+
? { rows: [{ ...IDEA_ROW, status: 'open', suggested_goal_id: null }] } : null));
|
|
691
|
+
const idea = await captureIdea({ title: 'broken', kind: 'bug', capturedBy: 9, filerRank: 'xenos' }, { pool });
|
|
692
|
+
assert.equal(idea.routing.reason, 'no_goal');
|
|
693
|
+
assert.equal(createTaskCalls.length, 0);
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
await test('a Metic+ filing into protected scope routes, and the floor still applies', async () => {
|
|
697
|
+
// Refusal and floor are LAYERS, not alternatives: the sub-Metic is refused
|
|
698
|
+
// above; a trusted filer gets through and the task is still born metic-floored,
|
|
699
|
+
// so a later nod cannot hand it to a newcomer.
|
|
700
|
+
reset();
|
|
701
|
+
const pool = fakePool(routableHandler({ goalRows: [{ ...LIVE_GOAL, scope_modules: ['lifecycle'] }] }));
|
|
702
|
+
await captureIdea({ title: 'broken', kind: 'bug', goalId: 7, capturedBy: 1, filerRank: 'metic' }, { pool });
|
|
703
|
+
assert.equal(createTaskCalls[0].args.status, 'ready');
|
|
704
|
+
assert.equal(createTaskCalls[0].args.requiresRank, 'metic');
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
await test('THE FLOOR CLAMPS UPWARD ONLY — createTask takes max(derived, explicit)', () => {
|
|
708
|
+
// The hacker's minor: R05's security claim rests on createTask's clamp, which
|
|
709
|
+
// lives in an unmodified file the panel could not open inside its read budget.
|
|
710
|
+
// Pin the real function here so the claim is checked in, not asserted in prose.
|
|
711
|
+
const lifecycleDb = require('../modules/lifecycle/db.js');
|
|
712
|
+
const { highestRank, deriveRequiredRank } = lifecycleDb;
|
|
713
|
+
// An empty touches[] — every routed task — derives the xenos floor. That is the
|
|
714
|
+
// hole R05's explicit 'metic' fills.
|
|
715
|
+
assert.equal(deriveRequiredRank([], false), 'xenos');
|
|
716
|
+
// The clamp: an explicit value can only RAISE.
|
|
717
|
+
assert.equal(highestRank('xenos', 'metic'), 'metic', 'explicit metic beats a xenos floor');
|
|
718
|
+
assert.equal(highestRank('metic', 'xenos'), 'metic', 'and an explicit xenos CANNOT lower a metic floor');
|
|
719
|
+
assert.equal(highestRank('archon', 'metic'), 'archon', 'a derived archon floor survives a lower explicit value');
|
|
720
|
+
// Which is exactly the composition createTask performs on a routed task:
|
|
721
|
+
assert.equal(highestRank(deriveRequiredRank([], false), 'metic'), 'metic');
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
await test('declared touches INSIDE the goal are allowed, and land on the task', async () => {
|
|
725
|
+
reset();
|
|
726
|
+
const pool = fakePool(routableHandler({ goalRows: [{ ...LIVE_GOAL, scope_modules: ['ideas'] }] }));
|
|
727
|
+
await captureIdea({
|
|
728
|
+
title: 'broken', kind: 'bug', goalId: 7, filerRank: 'archon',
|
|
729
|
+
touches: ['modules/ideas/inbox.js'],
|
|
730
|
+
}, { pool });
|
|
731
|
+
assert.deepEqual(createTaskCalls[0].args.touches, ['modules/ideas/inbox.js']);
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
await test('declared touches OUTSIDE the goal are refused by the real wall', async () => {
|
|
735
|
+
reset();
|
|
736
|
+
const pool = fakePool(routableHandler({ goalRows: [{ ...LIVE_GOAL, scope_modules: ['ideas'] }] }));
|
|
737
|
+
await assert.rejects(
|
|
738
|
+
() => captureIdea({
|
|
739
|
+
title: 'broken', kind: 'bug', goalId: 7, filerRank: 'archon',
|
|
740
|
+
touches: ['src/bongos/auth.js'],
|
|
741
|
+
}, { pool }),
|
|
742
|
+
(e) => e.code === 'ROUTE_NOT_AUTHORIZED' && e.gate === 'out_of_scope'
|
|
743
|
+
);
|
|
744
|
+
assert.equal(createTaskCalls.length, 0, 'nothing is created');
|
|
745
|
+
assert.ok(find(pool.queries, /^ROLLBACK$/), 'and the transaction rolls back');
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
await test('a sub-Metic naming a PROTECTED path is refused by the wall too', async () => {
|
|
749
|
+
// `kernel` is itself a protected MODULE, so a sub-Metic filing there is stopped
|
|
750
|
+
// one step earlier by protected_scope. To see the protected-PATH wall bite on
|
|
751
|
+
// its own, the goal's scope must be unprotected while still covering a
|
|
752
|
+
// protected path — which is exactly what an ordinary goal owning `config/`
|
|
753
|
+
// looks like. Two independent walls, checked independently.
|
|
754
|
+
const kernelPathInOrdinaryScope = { ...LIVE_GOAL, scope_modules: ['ideas'] };
|
|
755
|
+
reset();
|
|
756
|
+
const pool = fakePool(routableHandler({ goalRows: [kernelPathInOrdinaryScope] }));
|
|
757
|
+
await assert.rejects(
|
|
758
|
+
() => captureIdea({
|
|
759
|
+
title: 'broken', kind: 'bug', goalId: 7, filerRank: 'xenos',
|
|
760
|
+
touches: ['src/bongos/auth.js'],
|
|
761
|
+
}, { pool }),
|
|
762
|
+
// Outside the ideas scope entirely, so the subset arm answers first — which is
|
|
763
|
+
// the wall's own documented order (subset before protected-path).
|
|
764
|
+
(e) => e.code === 'ROUTE_NOT_AUTHORIZED' && e.gate === 'out_of_scope'
|
|
765
|
+
);
|
|
766
|
+
assert.equal(createTaskCalls.length, 0);
|
|
767
|
+
|
|
768
|
+
// And a Metic+ filing into a PROTECTED-scope goal with a protected touch
|
|
769
|
+
// clears both walls and is still floored — proving rank, not the path, decides.
|
|
770
|
+
reset();
|
|
771
|
+
const pool2 = fakePool(routableHandler({ goalRows: [{ ...LIVE_GOAL, scope_modules: ['kernel'] }] }));
|
|
772
|
+
await captureIdea({
|
|
773
|
+
title: 'broken', kind: 'bug', goalId: 7, filerRank: 'archon',
|
|
774
|
+
touches: ['src/bongos/auth.js'],
|
|
775
|
+
}, { pool: pool2 });
|
|
776
|
+
assert.equal(createTaskCalls.length, 1);
|
|
777
|
+
assert.equal(createTaskCalls[0].args.requiresRank, 'metic', 'kernel is protected scope, so the floor still applies');
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
await test('NO touches means NO wall — reporting does not require naming files', async () => {
|
|
781
|
+
// The deliberate divergence from POST /goals/:id/tasks, which denies
|
|
782
|
+
// `empty_touches`. Demanding a blast radius as the price of reporting is the
|
|
783
|
+
// triage tax this goal removes; the protection moves to the rank floor.
|
|
784
|
+
reset();
|
|
785
|
+
const pool = fakePool(routableHandler({ goalRows: [{ ...LIVE_GOAL, scope_modules: ['kernel'] }] }));
|
|
786
|
+
const out = await captureIdea(
|
|
787
|
+
{ title: 'something is broken and I do not know where', kind: 'bug', goalId: 7, filerRank: 'archon' },
|
|
788
|
+
{ pool }
|
|
789
|
+
);
|
|
790
|
+
assert.equal(out.routing.routed, true, 'a report with no touches still routes');
|
|
791
|
+
assert.deepEqual(createTaskCalls[0].args.touches, []);
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
await test('MEMBERSHIP IS NOT REQUIRED — reporting is not authoring (documented divergence)', async () => {
|
|
795
|
+
// authorizeGoalTaskCreate refuses a non-member with not_a_member. The routed
|
|
796
|
+
// path deliberately does not, because C5 promises the filer never has to know
|
|
797
|
+
// the goal map and C1 that a bug filed ANYWHERE lands in its home. This test
|
|
798
|
+
// exists so a later change has to argue with it rather than quietly reinstate
|
|
799
|
+
// the wall: nothing in the routed path reads goal_members at all.
|
|
800
|
+
reset();
|
|
801
|
+
const pool = fakePool(routableHandler({ goalRows: [{ ...LIVE_GOAL, scope_modules: ['ideas'] }] }));
|
|
802
|
+
const out = await captureIdea(
|
|
803
|
+
{ title: 'broken', kind: 'bug', goalId: 7, capturedBy: 999, filerRank: 'archon' },
|
|
804
|
+
{ pool }
|
|
805
|
+
);
|
|
806
|
+
assert.equal(out.routing.routed, true);
|
|
807
|
+
assert.equal(find(pool.queries, /goal_members/), undefined,
|
|
808
|
+
'no membership lookup happens on the routed path — by design, not by omission');
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
await test('the goal SCOPE is read in the same locked statement as its status', async () => {
|
|
812
|
+
reset();
|
|
813
|
+
const pool = fakePool(routableHandler({ goalRows: [{ ...LIVE_GOAL, scope_modules: ['ideas'] }] }));
|
|
814
|
+
await captureIdea({ title: 'x', kind: 'bug', goalId: 7, filerRank: 'archon' }, { pool });
|
|
815
|
+
const sel = find(pool.queries, /FROM goals g JOIN versions v/);
|
|
816
|
+
assert.match(norm(sel.sql), /g\.scope_modules/,
|
|
817
|
+
'so a rescope cannot land between the wall check and the insert');
|
|
818
|
+
assert.match(norm(sel.sql), /FOR SHARE OF g/);
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
// =========================================================================
|
|
822
|
+
// 8. R05 — the NOD QUEUE: a soft landing nobody can see is a silent drop
|
|
823
|
+
// =========================================================================
|
|
824
|
+
|
|
825
|
+
await test('the nod queue asks for routed filings at backlog, oldest first', async () => {
|
|
826
|
+
const pool = fakePool((s) => (/FROM ideas_task_lineage/.test(s)
|
|
827
|
+
? { rows: [{ task_id: 1, title: 't', kind: 'bug', status: 'backlog', goal_id: 7 }] } : null));
|
|
828
|
+
const rows = await inbox.listRoutedAwaitingNod({ limit: 5 }, { pool });
|
|
829
|
+
assert.equal(rows.length, 1);
|
|
830
|
+
const q = norm(find(pool.queries, /FROM ideas_task_lineage/).sql);
|
|
831
|
+
assert.match(q, /l\.origin = 'route'/, "scoped by LINEAGE, not by status alone — a task sits at backlog for many reasons");
|
|
832
|
+
assert.match(q, /t\.status = 'backlog'/);
|
|
833
|
+
assert.match(q, /ORDER BY t\.created_at ASC/, 'oldest first: the longest-waiting report is the most overdue');
|
|
834
|
+
assert.match(q, /i\.captured_by/, "and it carries the filer, for Q3's kind + who-filed-it copy");
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
await test('the nod count uses the SAME predicate as the list', async () => {
|
|
838
|
+
const pool = fakePool((s) => (/COUNT/.test(s) ? { rows: [{ n: 4 }] } : null));
|
|
839
|
+
assert.equal(await inbox.countRoutedAwaitingNod({ pool }), 4);
|
|
840
|
+
const q = norm(pool.queries[0].sql);
|
|
841
|
+
assert.match(q, /l\.origin = 'route'/);
|
|
842
|
+
assert.match(q, /t\.status = 'backlog'/);
|
|
843
|
+
// A pill that disagrees with its own rows is worse than no pill.
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
await test('GET /inbox/awaiting-nod is registered BEFORE /inbox/:id, or it is unreachable', async () => {
|
|
847
|
+
// Express matches in registration order. Declared after the wildcard, this
|
|
848
|
+
// route arrives as /inbox/:id with id='awaiting-nod' and 400s on parseId —
|
|
849
|
+
// a bug with no symptom except an empty card.
|
|
850
|
+
const router = require('../modules/ideas/routes/inbox.js')({});
|
|
851
|
+
const paths = router.stack.filter((l) => l.route).map((l) => l.route.path);
|
|
852
|
+
const iNod = paths.indexOf('/inbox/awaiting-nod');
|
|
853
|
+
const iParam = paths.indexOf('/inbox/:id');
|
|
854
|
+
assert.ok(iNod >= 0, 'the route exists');
|
|
855
|
+
assert.ok(iNod < iParam, `literal segment must precede the wildcard (got ${iNod} vs ${iParam})`);
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
summary('idea_routing');
|