@bongos/core 1.19.286 → 1.19.288

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/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.286",
3
+ "version": "1.19.288",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.286",
9
+ "version": "1.19.288",
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.286",
3
+ "version": "1.19.288",
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",
@@ -363,6 +363,10 @@ const MODULE_EXPECTED_RANKS = {
363
363
  // out of asking/accepting, and any gate removal.
364
364
  'GET /goals/inbox': 'any-builder',
365
365
  'PATCH /goals/:id/visibility': 'any-builder',
366
+ // task 1003102: a goal's own authority (owner|manager|Archon, in-handler
367
+ // authorizeGoalManagement) corrects its own title/description — same shape
368
+ // and same gate as the visibility route above.
369
+ 'PATCH /goals/:id': 'any-builder',
366
370
  'POST /goals/:id/requests': 'any-builder',
367
371
  'POST /goals/:id/requests/:reqId/respond': 'any-builder',
368
372
  'POST /goals/:id/invitations': 'any-builder',
@@ -381,6 +385,9 @@ const MODULE_EXPECTED_RANKS = {
381
385
  // planning/building version, so the in-handler wall is load-bearing — and an
382
386
  // unauthorized caller is masked as bad_goal_id. Unsatisfy stays Archon.
383
387
  'POST /versions/:id/done-when': 'metic+archon',
388
+ // task 1003102: correcting a criterion's words is the SAME authority as
389
+ // authoring them, so this is pinned to the create gate, not to satisfy.
390
+ 'PATCH /done-when/:criterionId': 'metic+archon',
384
391
  // ...and its inverse stays ARCHON. Un-flipping a CONFIRMED criterion rewrites
385
392
  // shipped scope-truth; ADR 0154's whole safety argument is that the backward
386
393
  // ratchet did not move with the create. Previously unpinned — pinned here so a
@@ -402,6 +409,10 @@ const MODULE_EXPECTED_RANKS = {
402
409
  // criterion). Metic+ may file the proposal; ADR 0157 also lets Metic ratify it
403
410
  // (`idea.ratify` archon→metic) — the rank that triages ideas now also ratifies them.
404
411
  ideas: {
412
+ // task 1003102: an idea's AUTHOR may correct their own text, so this is
413
+ // requireBuilder + an in-handler author|triage gate. Deliberately a separate
414
+ // route from the triage-gated PATCH /inbox/:id, which keeps its own rank.
415
+ 'PATCH /inbox/:id/body': 'any-builder',
405
416
  'POST /inbox/:id/ratify': 'perm:idea.ratify',
406
417
  },
407
418
  // Cross-builder memory READS — the counterpart to the BFG cross-builder WRITE the
package/src/module-api.js CHANGED
@@ -999,8 +999,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
999
999
  // landed since 1.19.284 with no explicit bump. run 32531963073. (task 1002620)
1000
1000
  // 1.19.286 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1001
1001
  // landed since 1.19.285 with no explicit bump. run 32533647172. (task 1002620)
1002
+ // 1.19.287 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1003
+ // landed since 1.19.286 with no explicit bump. run 32539649191. (task 1002620)
1004
+ // 1.19.288 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1005
+ // landed since 1.19.287 with no explicit bump. run 32546561232. (task 1002620)
1002
1006
  // ---------------------------------------------------------------------------
1003
- const CORE_VERSION = '1.19.286'; // CI auto-patch carrier (ADR 0161); see changelog above
1007
+ const CORE_VERSION = '1.19.288'; // CI auto-patch carrier (ADR 0161); see changelog above
1004
1008
 
1005
1009
  // A namespaced logger so a module's log lines are attributable + consistent.
1006
1010
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,212 @@
1
+ // tests/auto_satisfy_criteria.mjs — task 1002069 / ADR 0183.
2
+ //
3
+ // A done-when criterion closes ITSELF once every gating task has shipped, and a
4
+ // goal whose criteria are all satisfied flips open→achieved. The manual
5
+ // /goal-review confirm stops being the only door (owner request 2026-07-05).
6
+ //
7
+ // What these tests actually protect, in order of how badly it would hurt:
8
+ //
9
+ // 1. THE DELIVERY GUARD, and the DELIBERATE ASYMMETRY around it. `abandoned` is
10
+ // terminal (task 1002651), so "no task is still pending" is TRUE for a
11
+ // criterion whose every linked task was CUT. Auto-closing on that alone would
12
+ // satisfy criteria on zero delivered work and cascade goals to `achieved`, so
13
+ // the auto-close SQL additionally requires >=1 SHIPPED.
14
+ // The review flag does NOT get that guard, on purpose. `pending_review` means
15
+ // "a human should LOOK at this", and task 1002651 chose that an all-abandoned
16
+ // criterion surfaces there — its work was cut, so someone must decide whether
17
+ // the criterion still means anything. Auto-close is the narrower question.
18
+ // Everything that auto-closes was flagged; not everything flagged auto-closes.
19
+ // An earlier draft of this task "aligned" the two and broke that intent —
20
+ // tests/criterion_progress.mjs caught it. Do not re-align them.
21
+ // 2. THE EXEC CONTRACT. The helper writes through the caller's `exec`, never a
22
+ // module pool, because shipTask passes its open transaction client — a
23
+ // rolled-back ship must not leave a goal achieved.
24
+ // 3. THE CASCADE RULE. Mirrors db.achieveGoalIfComplete: open goals only, >=1
25
+ // criterion, zero unsatisfied. Re-derived from the table, not from the rows
26
+ // that closed in this call.
27
+ //
28
+ // DB-free: a fake exec records the SQL and returns scripted rows, which is the
29
+ // right level for a pure-SQL helper — the shapes and the branching are the risk,
30
+ // and a live Postgres would test Postgres.
31
+ //
32
+ // Run: node tests/auto_satisfy_criteria.mjs
33
+
34
+ import { strict as assert } from 'node:assert';
35
+ import { createRequire } from 'node:module';
36
+ import { readFileSync } from 'node:fs';
37
+ import { makeRunner } from './helpers.mjs';
38
+
39
+ process.env.NODE_ENV = 'test';
40
+ const require = createRequire(import.meta.url);
41
+ const doneWhen = require('../modules/lifecycle/done-when.js');
42
+ const { autoSatisfyShippedCriteria, isPendingReview, buildCriterionProgress } = doneWhen;
43
+ const { test, summary } = makeRunner();
44
+
45
+ // A fake exec: hands back queued result sets in order, recording every call.
46
+ function fakeExec(...results) {
47
+ const calls = [];
48
+ return {
49
+ calls,
50
+ async query(sql, params) {
51
+ calls.push({ sql, params });
52
+ return results[calls.length - 1] || { rows: [] };
53
+ },
54
+ };
55
+ }
56
+
57
+ const CRIT = { id: 10, version_id: 'BONGOS-V1', criterion_id: 'c-one', criterion_md: 'x', goal_id: 77, satisfied_by_task_id: 5 };
58
+
59
+ // ---- 1. the delivery guard, in all three derivations -----------------------
60
+
61
+ await test('GUARD (sql): the update requires BOTH nothing-pending AND >=1 shipped', () => {
62
+ const src = readFileSync(new URL('../modules/lifecycle/done-when.js', import.meta.url), 'utf8');
63
+ const body = src.slice(src.indexOf('async function autoSatisfyShippedCriteria'));
64
+ assert.match(body, /HAVING count\(\*\) FILTER \(WHERE t\.status NOT IN \('shipped', 'abandoned'\)\) = 0/,
65
+ 'nothing may still be pending');
66
+ assert.match(body, /AND count\(\*\) FILTER \(WHERE t\.status = 'shipped'\) >= 1/,
67
+ 'THE GUARD: an all-abandoned gating set delivered nothing and must not close a criterion');
68
+ });
69
+
70
+ await test('ASYMMETRY (sql): the review queue deliberately does NOT carry the delivery guard', () => {
71
+ const src = readFileSync(new URL('../modules/lifecycle/done-when.js', import.meta.url), 'utf8');
72
+ const start = src.indexOf('async function listPendingReviewCriteria');
73
+ const body = src.slice(start, src.indexOf('async function criterionProgress', start));
74
+ assert.ok(body.length > 200, 'the slice must actually contain the function');
75
+ assert.match(body, /HAVING count\(\*\) FILTER \(WHERE t\.status NOT IN \('shipped', 'abandoned'\)\) = 0/);
76
+ assert.ok(!/status = 'shipped'\) >= 1/.test(body),
77
+ 'task 1002651 intends an all-abandoned criterion to SURFACE for review; only auto-close is gated');
78
+ });
79
+
80
+ await test('ASYMMETRY (pure): isPendingReview stays the WIDER question — unchanged by this task', () => {
81
+ // An all-abandoned criterion still flags for review (1002651's intent) even
82
+ // though it will never auto-close. That gap is the feature, not a bug.
83
+ assert.equal(isPendingReview({ satisfied: false, linked: 1, unshipped: 0 }), true,
84
+ 'all-abandoned still SURFACES — a human decides whether the criterion survives');
85
+ assert.equal(isPendingReview({ satisfied: false, linked: 2, unshipped: 1 }), false, 'still work pending');
86
+ assert.equal(isPendingReview({ satisfied: true, linked: 1, unshipped: 0 }), false, 'already confirmed');
87
+ assert.equal(isPendingReview({ satisfied: false, linked: 0, unshipped: 0 }), false, 'no linked tasks');
88
+ });
89
+
90
+ await test('ASYMMETRY (rollup): the /status rollup is untouched by this task', () => {
91
+ const row = (tid, status) => ({
92
+ cnum: 1, id: 10, criterion_id: 'c-cut', criterion_md: 'x', satisfied: false, goal_id: 1,
93
+ task_id: tid, task_title: 't', task_status: status,
94
+ });
95
+ const g = [{ id: 1, title: 'g', status: 'open', scope_modules: [] }];
96
+ const cut = buildCriterionProgress('V1', [row(301, 'abandoned')], 0, g);
97
+ assert.equal(cut.criteria[0].counts.remaining, 0, 'abandoned is not "remaining" (task 1002651)');
98
+ assert.equal(cut.criteria[0].pending_review, true, 'and it still flags for review — pinned in criterion_progress.mjs');
99
+ assert.equal(buildCriterionProgress('V1', [row(302, 'shipped')], 0, g).criteria[0].pending_review, true);
100
+ });
101
+
102
+ // ---- 2. the exec contract --------------------------------------------------
103
+
104
+ await test('EXEC: every write goes through the caller-supplied exec (so a ship txn is atomic)', async () => {
105
+ const exec = fakeExec({ rows: [CRIT] }, { rows: [{ id: 77, title: 'g', version_id: 'BONGOS-V1', status: 'achieved' }] });
106
+ const out = await autoSatisfyShippedCriteria(exec, { taskId: 5 });
107
+ assert.equal(exec.calls.length, 2, 'one criterion update + one goal cascade');
108
+ assert.deepEqual(out.criteria, [CRIT]);
109
+ assert.deepEqual(out.goals.map((g) => g.id), [77]);
110
+ const src = readFileSync(new URL('../modules/lifecycle/done-when.js', import.meta.url), 'utf8');
111
+ const body = src.slice(src.indexOf('async function autoSatisfyShippedCriteria'), src.indexOf('module.exports'));
112
+ assert.ok(!/\bpool\.query\b/.test(body), 'must never reach for the module pool — that would escape the caller txn');
113
+ });
114
+
115
+ await test('EXEC: shipTask passes its transaction CLIENT, and the reconciler passes the pool', () => {
116
+ const db = readFileSync(new URL('../modules/lifecycle/db.js', import.meta.url), 'utf8');
117
+ // db.js is over the fitness oversize threshold, so the hook is ONE line there
118
+ // and its body lives in done-when.closeCompletedWorkForShip. What must hold is
119
+ // that the thing handed in is the txn client, not a pool.
120
+ assert.match(db, /closeCompletedWorkForShip\(client, taskId\)/,
121
+ 'the ship hook must run on the txn client, not the pool');
122
+ const dw = readFileSync(new URL('../modules/lifecycle/done-when.js', import.meta.url), 'utf8');
123
+ assert.match(dw, /autoSatisfyShippedCriteria\(client, \{ taskId \}\)/, 'and it forwards that client through');
124
+ const rec = readFileSync(new URL('../modules/lifecycle/publish-reconciler.js', import.meta.url), 'utf8');
125
+ assert.match(rec, /autoSatisfyShippedCriteria\(exec, \{\}\)/, 'the sweep is UNSCOPED (no taskId)');
126
+ });
127
+
128
+ // ---- 3. scoping, cascade, idempotence -------------------------------------
129
+
130
+ await test('SCOPED vs UNSCOPED: the taskId parameter is what narrows the candidate set', async () => {
131
+ const scoped = fakeExec({ rows: [] });
132
+ await autoSatisfyShippedCriteria(scoped, { taskId: 42 });
133
+ assert.deepEqual(scoped.calls[0].params, [42]);
134
+ assert.match(scoped.calls[0].sql, /\$1::bigint IS NULL OR c\.id IN/, 'one SQL serves both shapes');
135
+
136
+ const sweep = fakeExec({ rows: [] });
137
+ await autoSatisfyShippedCriteria(sweep, {});
138
+ assert.deepEqual(sweep.calls[0].params, [null], 'unscoped passes NULL, which disables the narrowing clause');
139
+ });
140
+
141
+ await test('NO-OP: nothing closed means no cascade query at all', async () => {
142
+ const exec = fakeExec({ rows: [] });
143
+ const out = await autoSatisfyShippedCriteria(exec, {});
144
+ assert.deepEqual(out, { criteria: [], goals: [] });
145
+ assert.equal(exec.calls.length, 1, 'must not run the goal cascade when no criterion moved');
146
+ });
147
+
148
+ await test('CASCADE: only OPEN goals, only with >=1 criterion and zero unsatisfied', async () => {
149
+ const exec = fakeExec({ rows: [CRIT] }, { rows: [] });
150
+ await autoSatisfyShippedCriteria(exec, { taskId: 5 });
151
+ const cascade = exec.calls[1].sql;
152
+ assert.match(cascade, /g\.status = 'open'/, 'never re-touch an achieved/archived goal');
153
+ assert.match(cascade, /EXISTS\s*\(SELECT 1 FROM done_when_criteria d WHERE d\.goal_id = g\.id\)/, 'an empty goal cannot auto-achieve');
154
+ assert.match(cascade, /NOT EXISTS \(SELECT 1 FROM done_when_criteria d WHERE d\.goal_id = g\.id AND NOT d\.satisfied\)/, 'every criterion must be satisfied');
155
+ assert.deepEqual(exec.calls[1].params, [[77]]);
156
+ });
157
+
158
+ await test('CASCADE: a criterion with no goal closes nothing (goal_id NULL completes no goal)', async () => {
159
+ const exec = fakeExec({ rows: [{ ...CRIT, goal_id: null }] });
160
+ const out = await autoSatisfyShippedCriteria(exec, { taskId: 5 });
161
+ assert.equal(exec.calls.length, 1, 'no cascade query for a goal-less criterion');
162
+ assert.deepEqual(out.goals, []);
163
+ });
164
+
165
+ await test('CASCADE: goal ids are deduped, so N criteria under one goal is ONE cascade', async () => {
166
+ const exec = fakeExec({ rows: [CRIT, { ...CRIT, id: 11, criterion_id: 'c-two' }] }, { rows: [] });
167
+ await autoSatisfyShippedCriteria(exec, {});
168
+ assert.deepEqual(exec.calls[1].params, [[77]], 'one goal id, not two');
169
+ });
170
+
171
+ await test('IDEMPOTENT: the candidate set reads only unsatisfied rows, so a re-run is a no-op', () => {
172
+ const src = readFileSync(new URL('../modules/lifecycle/done-when.js', import.meta.url), 'utf8');
173
+ const body = src.slice(src.indexOf('async function autoSatisfyShippedCriteria'), src.indexOf('module.exports'));
174
+ assert.match(body, /WHERE c\.satisfied = false/, 'this is what makes the every-tick sweep safe');
175
+ });
176
+
177
+ await test('a non-numeric taskId is rejected rather than silently becoming an unscoped sweep', async () => {
178
+ const exec = fakeExec({ rows: [] });
179
+ await assert.rejects(() => autoSatisfyShippedCriteria(exec, { taskId: 'oops' }), /must be numeric/);
180
+ assert.equal(exec.calls.length, 0, 'nothing ran');
181
+ });
182
+
183
+ // ---- the ship hook is best-effort, the sweep is the retry ------------------
184
+
185
+ await test('the ship hook cannot fail a ship, and the sweep exists to retry it', async () => {
186
+ // Behavioural, not textual: a throwing exec must not propagate out of the hook.
187
+ const boom = { query: async () => { throw new Error('db exploded'); } };
188
+ const logged = [];
189
+ const line = await doneWhen.closeCompletedWorkForShip(boom, 5, (m) => logged.push(m));
190
+ assert.match(line, /auto-satisfy skipped \(sweep retries\)/, 'the failure is reported, not thrown');
191
+ assert.match(logged.join('\n'), /shipTask 5/, 'and it names the ship it happened on');
192
+ const rec = readFileSync(new URL('../modules/lifecycle/publish-reconciler.js', import.meta.url), 'utf8');
193
+ assert.match(rec, /runCriterionSweep\(deps\)\.catch\(/, 'the sweep is fire-and-forget on the tick, like runCostSweep');
194
+ assert.match(rec, /BACKLOG DRAIN/, 'and documents why it is not merely belt-and-braces');
195
+ });
196
+
197
+ await test('closeCompletedWorkForShip: silent on the common no-op, and logs what closed', async () => {
198
+ const quiet = [];
199
+ const none = await doneWhen.closeCompletedWorkForShip({ query: async () => ({ rows: [] }) }, 5, (m) => quiet.push(m));
200
+ assert.equal(none, null, 'nothing closed => nothing returned');
201
+ assert.deepEqual(quiet, [], 'and nothing logged — a ship that closes nothing stays quiet');
202
+
203
+ const loud = [];
204
+ let call = 0;
205
+ const exec = { async query() { call += 1; return call === 1 ? { rows: [CRIT] } : { rows: [{ id: 77, title: 'G' }] }; } };
206
+ const line = await doneWhen.closeCompletedWorkForShip(exec, 9, (m) => loud.push(m));
207
+ assert.match(line, /auto-satisfied criteria \[c-one\]/);
208
+ assert.match(line, /auto-ACHIEVED goals \[77 "G"\]/);
209
+ assert.match(loud[0], /shipTask 9/);
210
+ });
211
+
212
+ summary();
@@ -0,0 +1,339 @@
1
+ // tests/prose_edits.mjs — durable prose is CORRECTABLE (task 1003102 / idea 1000740).
2
+ //
3
+ // A goal's title/description, an idea's body and a criterion's text were all
4
+ // write-once: created, then reachable only by prod SQL. The incident that forced
5
+ // it — a shell-quoting error stripped backtick-wrapped literals out of three
6
+ // criteria on goal 1000073 AT CREATION TIME, leaving text that was wrong with no
7
+ // API path to fix it.
8
+ //
9
+ // What these pin, in order of how badly getting them wrong would hurt:
10
+ //
11
+ // 1. THE SATISFIED-CRITERION REFUSAL. This task's own item 5 asked for a
12
+ // decision and it is a real one: a satisfied criterion is a claim somebody
13
+ // VERIFIED, so re-wording it re-points that verification at text nobody
14
+ // checked. Refused by name, with the two-step unsatisfy→edit→re-satisfy
15
+ // escape so the undo is recorded rather than implicit.
16
+ // 2. ABSENT FIELDS ARE NOT BLANKED. A title-only PATCH must not wipe a
17
+ // description — and a goal description is where a whole planning spec can
18
+ // live (goal 1000062 carries a decision record in its description).
19
+ // 3. THE AUDIT DELTA. The global write-audit middleware records only the
20
+ // REQUEST body (the NEW text), so without an explicit from/to the trail says
21
+ // a field changed and never what it replaced.
22
+ // 4. THE ROUTE-RANK SHAPE. The idea-author path is a NEW route rather than a
23
+ // new field on the triage-gated PATCH /inbox/:id, precisely so nothing
24
+ // already pinned gets widened to reach one field.
25
+ //
26
+ // Run: node tests/prose_edits.mjs
27
+
28
+ import { strict as assert } from 'node:assert';
29
+ import { createRequire } from 'node:module';
30
+ import { readFileSync } from 'node:fs';
31
+ import { makeRunner } from './helpers.mjs';
32
+
33
+ process.env.NODE_ENV = 'test';
34
+ const require = createRequire(import.meta.url);
35
+ const goalEdits = require('../modules/lifecycle/goal-edits.js');
36
+ const { checkRouteRanks, MODULE_EXPECTED_RANKS, pinMismatch } = require('../src/bongos/route-rank-check.js');
37
+ const { test, summary } = makeRunner();
38
+
39
+ // ---- 1. the decision: a satisfied criterion's text is frozen ---------------
40
+
41
+ await test('DECISION: a satisfied criterion refuses the edit, and names the recorded way round it', () => {
42
+ const r = goalEdits.criterionProseRefusal({ id: 5, satisfied: true, criterion_md: 'x' });
43
+ assert.equal(r.code, 'criterion_satisfied');
44
+ assert.equal(r.status, 409);
45
+ assert.match(r.message, /verified/i, 'says WHY, not just no');
46
+ assert.match(r.message, /unsatisfy/i, 'and names the escape hatch');
47
+ });
48
+
49
+ await test('DECISION: an UNSATISFIED criterion is editable — nobody has certified it', () => {
50
+ assert.equal(goalEdits.criterionProseRefusal({ id: 5, satisfied: false, criterion_md: 'x' }), null);
51
+ });
52
+
53
+ await test('a missing criterion is a 404, distinct from the 409 freeze', () => {
54
+ assert.equal(goalEdits.criterionProseRefusal(null).status, 404);
55
+ assert.equal(goalEdits.criterionProseRefusal(undefined).code, 'criterion_not_found');
56
+ });
57
+
58
+ // ---- 2. absent fields are left alone --------------------------------------
59
+
60
+ function fakePool(...results) {
61
+ const calls = [];
62
+ return { calls, async query(sql, params) { calls.push({ sql, params }); return results[calls.length - 1] || { rows: [] }; } };
63
+ }
64
+ const GOAL = { id: 7, title: 'Old title', description: 'A whole spec lives here.' };
65
+
66
+ await test('THE HAZARD: a title-only PATCH preserves the description verbatim', async () => {
67
+ const pool = fakePool({ rows: [GOAL] }, { rows: [{ ...GOAL, title: 'New title' }] });
68
+ const out = await goalEdits.updateGoalProse({ goalId: 7, patch: { title: 'New title' } }, { pool });
69
+ // the UPDATE must carry the ORIGINAL description, not null/undefined
70
+ assert.equal(pool.calls[1].params[2], 'A whole spec lives here.', 'description must be carried through untouched');
71
+ assert.deepEqual(out.deltas.map((d) => d.field), ['title'], 'and only the title reports a delta');
72
+ });
73
+
74
+ await test('a description-only PATCH preserves the title', async () => {
75
+ const pool = fakePool({ rows: [GOAL] }, { rows: [GOAL] });
76
+ await goalEdits.updateGoalProse({ goalId: 7, patch: { description: 'new' } }, { pool });
77
+ assert.equal(pool.calls[1].params[1], 'Old title');
78
+ });
79
+
80
+ await test('an explicitly EMPTY description is honoured (clearing is not the same as omitting)', async () => {
81
+ const pool = fakePool({ rows: [GOAL] }, { rows: [GOAL] });
82
+ await goalEdits.updateGoalProse({ goalId: 7, patch: { description: '' } }, { pool });
83
+ assert.equal(pool.calls[1].params[2], '', 'an explicit empty string means clear it');
84
+ });
85
+
86
+ await test('a no-change PATCH writes nothing and says so', async () => {
87
+ const pool = fakePool({ rows: [GOAL] });
88
+ const out = await goalEdits.updateGoalProse({ goalId: 7, patch: { title: GOAL.title } }, { pool });
89
+ assert.equal(pool.calls.length, 1, 'no UPDATE issued');
90
+ assert.equal(out.unchanged, true);
91
+ assert.deepEqual(out.deltas, []);
92
+ });
93
+
94
+ await test('a missing goal returns null rather than inventing a row', async () => {
95
+ assert.equal(await goalEdits.updateGoalProse({ goalId: 9, patch: { title: 'x' } }, { pool: fakePool({ rows: [] }) }), null);
96
+ });
97
+
98
+ // ---- 3. the audit delta ---------------------------------------------------
99
+
100
+ await test('AUDIT: a delta names the field and both sides, with real lengths', () => {
101
+ const d = goalEdits.proseDelta('description', 'before text', 'after text');
102
+ assert.equal(d.field, 'description');
103
+ assert.equal(d.from, 'before text');
104
+ assert.equal(d.to, 'after text');
105
+ assert.equal(d.from_len, 11);
106
+ assert.equal(d.to_len, 10);
107
+ });
108
+
109
+ await test('AUDIT: an unchanged value yields NO delta (a no-op PATCH is not an edit)', () => {
110
+ assert.equal(goalEdits.proseDelta('title', 'same', 'same'), null);
111
+ });
112
+
113
+ await test('AUDIT: long prose is CLIPPED — the audit line is not a revision store', () => {
114
+ const d = goalEdits.proseDelta('description', 'x'.repeat(500), 'y'.repeat(500));
115
+ assert.ok(d.from.length <= goalEdits.AUDIT_EXCERPT, `from clipped (${d.from.length})`);
116
+ assert.ok(d.to.length <= goalEdits.AUDIT_EXCERPT);
117
+ assert.match(d.from, /…$/, 'and says it was clipped');
118
+ assert.equal(d.from_len, 500, 'while still reporting the TRUE length');
119
+ });
120
+
121
+ await test('AUDIT: newlines are flattened so one edit is one log line', () => {
122
+ const d = goalEdits.proseDelta('description', 'a\nb\n\nc', 'z');
123
+ assert.equal(d.from, 'a b c');
124
+ });
125
+
126
+ await test('AUDIT: logProseEdit emits one line per changed field, and nothing on a no-op', () => {
127
+ const lines = [];
128
+ goalEdits.logProseEdit('goal', 7, 42, [goalEdits.proseDelta('title', 'a', 'b')], (m) => lines.push(m));
129
+ assert.equal(lines.length, 1);
130
+ assert.match(lines[0], /goal 7 title edited by builder 42/);
131
+ goalEdits.logProseEdit('goal', 7, 42, [], (m) => lines.push(m));
132
+ goalEdits.logProseEdit('goal', 7, 42, null, (m) => lines.push(m));
133
+ assert.equal(lines.length, 1, 'no deltas => no noise');
134
+ });
135
+
136
+ // ---- 4. the route-rank shape ----------------------------------------------
137
+
138
+ await test('ROUTE SHAPE: all three new routes exist and classify to their pins', () => {
139
+ const res = checkRouteRanks();
140
+ const byKey = new Map();
141
+ for (const r of res.routes) if (!byKey.has(r.key)) byKey.set(r.key, r);
142
+ const expect = [
143
+ ['PATCH /goals/:id', MODULE_EXPECTED_RANKS.lifecycle, 'any-builder'],
144
+ ['PATCH /done-when/:criterionId', MODULE_EXPECTED_RANKS.lifecycle, 'metic+archon'],
145
+ ['PATCH /inbox/:id/body', MODULE_EXPECTED_RANKS.ideas, 'any-builder'],
146
+ ];
147
+ for (const [key, pins, want] of expect) {
148
+ const row = byKey.get(key);
149
+ assert.ok(row, `${key} must exist in the live route set`);
150
+ assert.equal(pins[key], want, `${key} must be pinned ${want}`);
151
+ assert.equal(pinMismatch(pins[key], row), null, `${key} classification must match its pin`);
152
+ }
153
+ });
154
+
155
+ await test('ROUTE SHAPE: the triage-gated PATCH /inbox/:id was NOT widened to reach body_md', () => {
156
+ // The whole reason the author path is its own route. If someone later drops
157
+ // this route's permission gate to add body_md, this fails.
158
+ const res = checkRouteRanks();
159
+ const row = res.routes.find((r) => r.key === 'PATCH /inbox/:id');
160
+ assert.ok(row, 'the triage route still exists');
161
+ assert.equal(row.rank, 'metic+archon', 'PATCH /inbox/:id must keep its triage-level gate');
162
+ const src = readFileSync(new URL('../modules/ideas/routes/inbox.js', import.meta.url), 'utf8');
163
+ const start = src.indexOf("router.patch('/inbox/:id',");
164
+ const body = src.slice(start, src.indexOf('router.', start + 20));
165
+ // Assert on the VALIDATION SCHEMA, not on any mention of the string: that route
166
+ // already discusses body_md in a comment (about it not being parseable as a
167
+ // criterion spec), so a bare /body_md/ match would fail on prose forever.
168
+ const schema = body.slice(body.indexOf('validateOrRespond'), body.indexOf('})) return;'));
169
+ assert.ok(!/body_md/.test(schema), 'body_md must NOT be an accepted FIELD on the triage route');
170
+ });
171
+
172
+ await test('ROUTE SHAPE: the author gate is fail-closed and uses the array form of rankMeetsThreshold', () => {
173
+ const api = require('../src/module-api');
174
+ // The array form is the correct one — a bare string throws inside reduce, which
175
+ // is exactly the bug a silent fallback would have hidden.
176
+ assert.equal(api.rankMeetsThreshold('xenos', ['metic', 'archon']), false);
177
+ assert.equal(api.rankMeetsThreshold('thetes', ['metic', 'archon']), false);
178
+ assert.equal(api.rankMeetsThreshold('metic', ['metic', 'archon']), true);
179
+ assert.equal(api.rankMeetsThreshold(null, ['metic', 'archon']), false, 'unknown rank fails closed');
180
+ const src = readFileSync(new URL('../modules/ideas/routes/inbox.js', import.meta.url), 'utf8');
181
+ assert.match(src, /rankMeetsThreshold\(req\.builder\.rank, \['metic', 'archon'\]\)/, 'array form, not a bare string');
182
+ assert.match(src, /captured_by != null/, 'and the author check treats a NULL author as not-you');
183
+ });
184
+
185
+ await test('ROUTE SHAPE: the goal + visibility handlers share ONE authority gate', () => {
186
+ // Both were moved into goal-edits.js precisely because they are the same kind
187
+ // of operation behind the same gate; this pins that they still are.
188
+ const src = readFileSync(new URL('../modules/lifecycle/goal-edits.js', import.meta.url), 'utf8');
189
+ assert.equal((src.match(/authorizeGoalManagement\(/g) || []).length, 2, 'prose + visibility, both gated');
190
+ assert.equal(typeof goalEdits.goalProseHandler, 'function');
191
+ assert.equal(typeof goalEdits.goalVisibilityHandler, 'function');
192
+ const routes = readFileSync(new URL('../modules/lifecycle/routes/goals.js', import.meta.url), 'utf8');
193
+ assert.match(routes, /router\.patch\('\/goals\/:id',/, 'the REGISTRATION stays in the route file so route-rank-check sees it');
194
+ assert.match(routes, /router\.patch\('\/goals\/:id\/visibility',/);
195
+ });
196
+
197
+ // ---- 5. THE HANDLERS ARE ACTUALLY INVOKED ---------------------------------
198
+ // The gap that let a blocker through on the first cut of this task: the handler
199
+ // body, once extracted into goal-edits.js, still called `proseEdits.updateGoalProse`
200
+ // — a name that exists only in the ROUTE file — so PATCH /goals/:id threw a
201
+ // ReferenceError and 500'd on every call. Every test passed, because they
202
+ // exercised the helpers and the route CLASSIFICATION and never the handler.
203
+ // So: invoke it. A factory-built handler with fakes is cheap to drive.
204
+
205
+ function fakeReqRes({ body = {}, params = {}, builder = { id: 42, rank: 'archon' } } = {}) {
206
+ const res = {
207
+ code: null, payload: null, statusCode: 200,
208
+ json(p) { this.payload = p; return this; },
209
+ status(c) { this.statusCode = c; return this; },
210
+ fail(code, statusOrOpts, extra) { this.code = code; this.statusCode = typeof statusOrOpts === 'number' ? statusOrOpts : (statusOrOpts && statusOrOpts.status) || 500; this.payload = extra || statusOrOpts; return this; },
211
+ };
212
+ return { req: { body, params, builder, ip: '127.0.0.1', get: () => 'test' }, res };
213
+ }
214
+ const deps = {
215
+ db: {
216
+ getGoal: async () => ({ id: 7, status: 'open', created_by: 42, version_id: 'V1' }),
217
+ getGoalMember: async () => ({ membership_kind: 'lead' }),
218
+ },
219
+ authorizeGoalManagement: () => ({ ok: true }),
220
+ validateOrRespond: () => false,
221
+ parseId: () => 7,
222
+ LIMITS: { TITLE: 200, DESCRIPTION: 50000 },
223
+ };
224
+
225
+ await test('INVOKED: goalProseHandler runs end-to-end and returns the updated goal', async () => {
226
+ const recorded = [];
227
+ const handler = goalEdits.goalProseHandler({
228
+ ...deps,
229
+ update: async ({ goalId, patch }) => ({ goal: { id: goalId, ...patch }, deltas: [goalEdits.proseDelta('title', 'Old', patch.title)] }),
230
+ record: async (...a) => { recorded.push(a); },
231
+ });
232
+ const { req, res } = fakeReqRes({ body: { title: 'Corrected title' } });
233
+ await handler(req, res);
234
+ // THE REGRESSION: the extracted handler called `proseEdits.updateGoalProse`, a
235
+ // name that exists only in the route file, so this threw ReferenceError and the
236
+ // catch turned it into a 500 on every legitimate call.
237
+ assert.equal(res.code, null, `must not fail — got ${res.code}`);
238
+ assert.equal(res.payload.ok, true);
239
+ assert.equal(res.payload.goal.title, 'Corrected title');
240
+ assert.deepEqual(res.payload.deltas.map((d) => d.field), ['title']);
241
+ assert.equal(recorded.length, 1, 'and the edit is recorded (audit row + log)');
242
+ });
243
+
244
+ await test('INVOKED: a PATCH with no editable field is refused before any write', async () => {
245
+ const handler = goalEdits.goalProseHandler(deps);
246
+ const { req, res } = fakeReqRes({ body: { unrelated: 'x' } });
247
+ await handler(req, res);
248
+ assert.equal(res.code, 'nothing_to_update');
249
+ assert.equal(res.statusCode, 400);
250
+ });
251
+
252
+ await test('INVOKED: an unauthorized actor is refused and NOTHING is written', async () => {
253
+ let wrote = false;
254
+ const handler = goalEdits.goalProseHandler({
255
+ ...deps,
256
+ authorizeGoalManagement: () => ({ ok: false, status: 403, body: { error: 'not_owner_or_manager' } }),
257
+ db: { ...deps.db, getGoal: async () => { return { id: 7, status: 'open' }; } },
258
+ });
259
+ const { req, res } = fakeReqRes({ body: { title: 'sneaky' } });
260
+ await handler(req, res);
261
+ assert.equal(res.statusCode, 403);
262
+ assert.equal(res.payload.error, 'not_owner_or_manager');
263
+ assert.equal(wrote, false);
264
+ });
265
+
266
+ await test('the file no longer references a name that does not exist in it', () => {
267
+ const src = readFileSync(new URL('../modules/lifecycle/goal-edits.js', import.meta.url), 'utf8');
268
+ assert.ok(!/proseEdits\./.test(src), 'the extracted handler must call its LOCAL helpers');
269
+ });
270
+
271
+ // ---- 6. the audit row is PERSISTED, not just logged ------------------------
272
+
273
+ await test('AUDIT ROW: a prose edit writes a queryable audit_log row carrying the delta', async () => {
274
+ const rows = [];
275
+ const logs = [];
276
+ const { req } = fakeReqRes();
277
+ await goalEdits.recordProseEdit('goal', 7, req, [goalEdits.proseDelta('title', 'a', 'b')], {
278
+ insertAuditLog: async (row) => { rows.push(row); },
279
+ log: (m) => logs.push(m),
280
+ });
281
+ assert.equal(rows.length, 1, 'the delta must reach audit_log, not only stdout');
282
+ assert.equal(rows[0].route, 'goal:7:prose-edit');
283
+ assert.equal(rows[0].method, 'PATCH');
284
+ assert.match(rows[0].requestBodyRedacted, /"from":"a"/);
285
+ assert.match(rows[0].requestBodyRedacted, /"to":"b"/);
286
+ assert.equal(rows[0].builderId, 42);
287
+ assert.equal(logs.length, 1, 'and it still logs');
288
+ });
289
+
290
+ await test('AUDIT ROW: a no-op edit writes no row, and an audit failure never throws', async () => {
291
+ const rows = [];
292
+ const { req } = fakeReqRes();
293
+ await goalEdits.recordProseEdit('goal', 7, req, [], { insertAuditLog: async (r) => rows.push(r), log: () => {} });
294
+ assert.equal(rows.length, 0, 'nothing changed => no audit row');
295
+ const logs = [];
296
+ await goalEdits.recordProseEdit('goal', 7, req, [goalEdits.proseDelta('title', 'a', 'b')], {
297
+ insertAuditLog: async () => { throw new Error('db down'); }, log: (m) => logs.push(m),
298
+ });
299
+ assert.match(logs.join('\n'), /AUDIT GAP/, 'a failed audit write is loud, not swallowed');
300
+ });
301
+
302
+ // ---- 7. the criterion PATCH enforces the OWNERSHIP half, not just the rank --
303
+
304
+ await test('OWNERSHIP: the criterion PATCH runs authorizeCriterionCreate, like its CREATE sibling', () => {
305
+ // The second blocker on the first cut: the route claimed "gated like criterion
306
+ // CREATION" while reusing only the rank-floor half, letting any Metic rewrite
307
+ // criteria on a goal they had nothing to do with.
308
+ const src = readFileSync(new URL('../modules/lifecycle/routes/done-when.js', import.meta.url), 'utf8');
309
+ const start = src.indexOf("router.patch('/done-when/:criterionId'");
310
+ assert.ok(start > 0, 'the route exists');
311
+ const body = src.slice(start, src.indexOf('router.', start + 30));
312
+ assert.match(body, /authorizeCriterionCreate\(/, 'the per-goal ownership wall must run here too');
313
+ assert.match(body, /getGoalMember\(/, 'membership is resolved for the non-Archon case');
314
+ assert.match(body, /requirePermission\('criterion\.create'\)/, 'and the rank floor still applies');
315
+ // ordering: authority before the satisfied-freeze, so an outsider learns nothing about state
316
+ assert.ok(body.indexOf('authorizeCriterionCreate(') < body.indexOf('criterionProseRefusal('),
317
+ 'authority is decided BEFORE any state-based refusal');
318
+ });
319
+
320
+ await test('AUDIT ROW: ALL THREE prose surfaces persist a row, not just log (item 4 says all three)', () => {
321
+ // The gap the grader caught on re-grade: goals + criteria went through
322
+ // recordProseEdit (which writes audit_log) while the ideas route only called
323
+ // api.logger. A log line is not an audit row — not queryable, and it rotates.
324
+ // Asserted per-surface so a future surface cannot quietly ship log-only.
325
+ const goalEditsSrc = readFileSync(new URL('../modules/lifecycle/goal-edits.js', import.meta.url), 'utf8');
326
+ assert.match(goalEditsSrc, /insertAuditLog/, 'goal + criterion path persists via recordProseEdit');
327
+
328
+ const doneWhenSrc = readFileSync(new URL('../modules/lifecycle/routes/done-when.js', import.meta.url), 'utf8');
329
+ assert.match(doneWhenSrc, /recordProseEdit\('criterion'/, 'the criterion route uses the persisting helper');
330
+
331
+ const ideasSrc = readFileSync(new URL('../modules/ideas/routes/inbox.js', import.meta.url), 'utf8');
332
+ const bodyRoute = ideasSrc.slice(ideasSrc.indexOf("router.patch('/inbox/:id/body'"));
333
+ const upTo = bodyRoute.slice(0, bodyRoute.indexOf('router.') > 0 ? bodyRoute.indexOf('router.', 30) : bodyRoute.length);
334
+ assert.match(upTo, /api\.insertAuditLog\(/, 'the ideas route must PERSIST its delta, not only log it');
335
+ assert.match(upTo, /route: `idea:\$\{id\}:prose-edit`/, 'and use the same route shape as the other two');
336
+ assert.match(upTo, /AUDIT GAP/, 'with a loud failure rather than a swallowed one');
337
+ });
338
+
339
+ summary();