@bongos/core 1.21.34 → 1.21.36

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.
Files changed (47) hide show
  1. package/.bongos-core.json +65 -50
  2. package/clients/bongos-client/index.d.ts +3 -3
  3. package/docs/api/openapi.json +25 -35
  4. package/docs/api-reference.md +1 -1
  5. package/docs/module-api-changelog.md +4 -0
  6. package/docs/page-inventory.json +10 -1
  7. package/docs/page-readings.json +356 -251
  8. package/modules/autonomy/governor-docket.js +68 -1
  9. package/modules/autonomy/matter-acts.js +41 -0
  10. package/modules/autonomy/routes/autonomy.js +2 -0
  11. package/modules/government/board.js +4 -0
  12. package/modules/government/db.js +14 -0
  13. package/modules/government/decision-board.js +90 -11
  14. package/modules/government/docket-floors.js +20 -0
  15. package/modules/government/docket-vocab.js +19 -0
  16. package/modules/government/docket.js +114 -2
  17. package/modules/government/routes/government.js +15 -1
  18. package/modules/hall-ui/public/street-card.js +157 -1
  19. package/modules/hall-ui/public/street-desk.js +205 -11
  20. package/modules/hall-ui/public/street-inside.js +11 -8
  21. package/modules/hall-ui/public/street.css +16 -0
  22. package/modules/hall-ui/public/street.js +97 -9
  23. package/modules/hall-ui/public/street.states.json +355 -6
  24. package/modules/ideas/governor-docket.js +91 -0
  25. package/modules/lifecycle/governor-docket.js +93 -2
  26. package/modules/lifecycle/matter-acts.js +63 -0
  27. package/modules/lifecycle/routes/done-when.js +26 -12
  28. package/modules/lifecycle/routes/gate-approvals.js +74 -59
  29. package/modules/lifecycle/routes/lifecycle.js +2 -0
  30. package/modules/lifecycle/routes/project-settings.js +24 -0
  31. package/modules/onboarding/routes/access-requests.js +7 -0
  32. package/modules/provisioning/core-upgrade.js +1 -1
  33. package/modules/provisioning/routes/core-upgrade.js +36 -1
  34. package/modules/security/routes/reports.js +23 -0
  35. package/package-lock.json +2 -2
  36. package/package.json +1 -1
  37. package/release-notes.json +16 -0
  38. package/src/bongos/route-rank-check.js +2 -0
  39. package/src/bongos/routes/backup.js +26 -0
  40. package/src/bongos/routes/builders.js +96 -1
  41. package/src/bongos/routes/modules.js +18 -0
  42. package/src/module-api.js +1 -1
  43. package/tests/governor_docket.mjs +28 -1
  44. package/tests/governor_matter_acts.mjs +241 -0
  45. package/tests/hall_matter_card.mjs +11 -2
  46. package/tests/hall_street_desk.mjs +473 -4
  47. package/tests/hall_street_inside.mjs +3 -3
@@ -19,6 +19,7 @@
19
19
 
20
20
  const api = require('../../src/module-api');
21
21
  const blockers = require('./blockers');
22
+ const ratify = require('./ratify');
22
23
 
23
24
  const POINT = 'governor.docket';
24
25
  const DOCKET_BLOCKER_LIMIT = 50;
@@ -41,9 +42,96 @@ function blockerDocketMatter(b, mayResolve) {
41
42
  tally: null,
42
43
  href: `/blockers#blocker-${b.id}`,
43
44
  ref: { type: 'blocker', id: b.id },
45
+ // the floor desk's one act (task 1004560): decide, by one of its options
46
+ ...(mayResolve ? { acts: [{ key: 'decide', options: blockerOptions(b.body_md) }] } : {}),
44
47
  };
45
48
  }
46
49
 
50
+ // A blocker's options as one-tap chips (task 1004560). Its own, when its body
51
+ // lists them as "Option A: …" lines (two to five of them); otherwise the three
52
+ // answers every blocker takes. The chosen key becomes the resolution note, so
53
+ // a key is the option's own words (at most 64 characters) and the label its
54
+ // first words. Pure.
55
+ const DEFAULT_CHOICES = Object.freeze(['go ahead', 'not now', 'need more']);
56
+ const OPTION_LINE = /^\s*(?:[-*]|\d+[.)])?\s*(?:\*\*)?option\s+[A-Za-z0-9]{1,2}(?:\*\*)?\s*[:.)—-]+\s*(?:\*\*)?(.+)$/i;
57
+ function plainWords(s) { return String(s).replace(/[*_`[\]]+/g, '').replace(/\([^)]*\)/g, '').replace(/\s+/g, ' ').trim(); }
58
+ function shortLabel(s) {
59
+ let out = '';
60
+ for (const w of s.split(' ')) { const next = out ? out + ' ' + w : w; if (next.length > 16) break; out = next; }
61
+ return out || s.slice(0, 16);
62
+ }
63
+ function blockerOptions(bodyMd) {
64
+ const found = [];
65
+ for (const line of String(bodyMd || '').split(/\r?\n/)) {
66
+ const m = OPTION_LINE.exec(line);
67
+ if (!m) continue;
68
+ const text = plainWords(m[1]).split(/\s[—–-]\s|[.;:]\s/)[0].trim();
69
+ if (text && !found.some((o) => o.key === text.slice(0, 64))) found.push({ key: text.slice(0, 64), label: shortLabel(text) });
70
+ }
71
+ if (found.length >= 2 && found.length <= 5) return found;
72
+ return DEFAULT_CHOICES.map((t) => ({ key: t, label: t }));
73
+ }
74
+
75
+ // The Ideas-to-ratify desk (task 1004560): the ideas a ratifier may take from
76
+ // the floor, under idea.ratify (both ratify routes' atom). A criterion proposal
77
+ // ratifies in one tap (POST /inbox/:id/ratify). A Full Idea the board has
78
+ // carried becomes a goal (POST /inbox/:id/ratify-goal) with its target version
79
+ // as a chip — only when its answers compose a goal on their own; one that needs
80
+ // the ratifier to write its criterion opens its page instead. A Full Idea still
81
+ // in its sitting is not here: it is the board's vote row (ADR 0175 §8). Pure.
82
+ function ratifyDeskRows(ideas, versions, passed) {
83
+ const chips = (versions || []).slice(0, 5).map((v) => ({ key: String(v.id), label: String(v.id) }));
84
+ const out = [];
85
+ for (const i of ideas || []) {
86
+ if (!i || i.status !== 'open') continue;
87
+ const href = `/idea/${i.id}`;
88
+ if (i.kind === 'criterion-proposal') {
89
+ let p = null;
90
+ try { p = ratify.parseProposalBody(i.body_md); } catch (_) { p = null; }
91
+ if (!p || !p.target_version) continue;
92
+ out.push({
93
+ id: `p${i.id}`, name: String(i.title || 'a promise'), ref: { type: 'criterion_proposal', id: i.id }, href, opened_at: i.created_at,
94
+ fact: `a promise for ${p.target_version}`, facts: [String(p.criterion_md || '').slice(0, 80)],
95
+ acts: [{ key: 'ratify' }, { key: 'open' }],
96
+ });
97
+ continue;
98
+ }
99
+ if (i.grade !== 'full' || !passed.has(String(i.id))) continue;
100
+ const composed = ratify.composeGoalFromFullIdea({ ideaId: i.id, templateKey: i.template_key, fieldValues: i.field_values });
101
+ const fits = composed && composed.ok && chips.length > 0;
102
+ out.push({
103
+ id: `g${i.id}`, name: String(i.title || 'a full idea'), ref: { type: 'full_idea', id: i.id }, href, opened_at: i.created_at,
104
+ fact: 'the board carried it', facts: [fits ? 'becomes a goal' : 'needs its page to finish'],
105
+ acts: fits ? [{ key: 'ratify_goal', options: chips }, { key: 'open' }] : [{ key: 'open' }],
106
+ });
107
+ }
108
+ return out;
109
+ }
110
+ // The desk reads the oldest 30 open proposals and Full Ideas (older first, so
111
+ // nothing waits forever behind newer ones); which Full Ideas the board carried
112
+ // is one read for all of them (government.boardPassedIdeaIds).
113
+ const RATIFY_IDEAS_LIMIT = 30;
114
+ async function readRatifyDesk() {
115
+ const { rows: ideas } = await api.pool.query(
116
+ `SELECT i.id, i.title, i.kind, i.status, i.body_md, i.created_at,
117
+ COALESCE(g.grade, 'quick') AS grade, g.template_key, g.field_values
118
+ FROM idea_inbox i LEFT JOIN ideas_grades g ON g.idea_id = i.id
119
+ WHERE i.status = 'open' AND (i.kind = 'criterion-proposal' OR g.grade = 'full')
120
+ ORDER BY i.created_at, i.id LIMIT $1`,
121
+ [RATIFY_IDEAS_LIMIT],
122
+ );
123
+ const government = api.resolveOptional && api.resolveOptional('government');
124
+ const passed = new Set();
125
+ const full = ideas.filter((i) => i.grade === 'full').map((i) => i.id);
126
+ if (full.length && government && typeof government.boardPassedIdeaIds === 'function') {
127
+ (await government.boardPassedIdeaIds(full)).forEach((id) => passed.add(String(id)));
128
+ }
129
+ const { rows: versions } = passed.size
130
+ ? await api.pool.query(`SELECT id, name FROM versions WHERE status IN ('building', 'planning') ORDER BY (status = 'building') DESC, id LIMIT 5`)
131
+ : { rows: [] };
132
+ return ratifyDeskRows(ideas, versions, passed);
133
+ }
134
+
47
135
  // The rules-at-work reading from this month's routed filings. Pure.
48
136
  function rulesReading(settledThisMonth) {
49
137
  const n = Number(settledThisMonth) || 0;
@@ -114,6 +202,7 @@ function contributeIdeasToDocket() {
114
202
  return rows.map((b) => blockerDocketMatter(b, mayResolve));
115
203
  },
116
204
  });
205
+ api.contribute(POINT, { desk: 'planning.ratify', gate: { permission: 'idea.ratify' }, read: () => readRatifyDesk() });
117
206
  // A count, no rows: any builder may read it.
118
207
  api.contribute(POINT, {
119
208
  reading: 'rules',
@@ -127,6 +216,8 @@ function contributeIdeasToDocket() {
127
216
  module.exports = {
128
217
  contributeIdeasToDocket,
129
218
  blockerDocketMatter,
219
+ blockerOptions,
220
+ ratifyDeskRows,
130
221
  rulesReading,
131
222
  rulesFloor,
132
223
  rulesHistory,
@@ -58,10 +58,29 @@ function removalMatter(r) {
58
58
  tally: null,
59
59
  href: `/task/${encodeURIComponent(r.proposal_task_id)}`,
60
60
  ref: { type: 'task', id: r.proposal_task_id },
61
+ // POST /removal-proposals/:id/decide, the docket source's own atom (task 1004559)
62
+ acts: [{ key: 'remove' }, { key: 'keep' }],
61
63
  };
62
64
  }
63
65
 
64
- function uatMatter(c) {
66
+ // The one-tap reasons a force sign-off may give (POST /done-when/:id/satisfy
67
+ // requires one); the desk sends the label as the override reason.
68
+ const FORCE_REASONS = Object.freeze([
69
+ { key: 'tested_by_hand', label: 'tested by hand' },
70
+ { key: 'checked_review', label: 'seen in review' },
71
+ { key: 'no_longer', label: 'no longer applies' },
72
+ ]);
73
+ // What the viewer may do on a criterion waiting for a tester (task 1004559). A
74
+ // backend-only criterion signs off in one tap (with the live attestation as its
75
+ // chip); a user-facing one needs a recording, so its act goes to the goal's page.
76
+ // Force sign-off only for a holder of criterion.satisfy, that route's own atom.
77
+ function uatActs(c, { force = false } = {}) {
78
+ const acts = [c.backend_only === true ? { key: 'signoff', options: [{ key: 'live', label: 'tested it live' }] } : { key: 'record' }];
79
+ if (force) acts.push({ key: 'force', options: FORCE_REASONS });
80
+ return acts;
81
+ }
82
+
83
+ function uatMatter(c, opts) {
65
84
  return {
66
85
  id: c.id,
67
86
  rule: 'signoff',
@@ -75,6 +94,7 @@ function uatMatter(c) {
75
94
  tally: { for: c.shipped_total, against: 0, open: 0, needs: 'one sign-off by someone who did not build it' },
76
95
  href: c.goal_id ? `/goals#/goal/${encodeURIComponent(c.goal_id)}` : '/goals',
77
96
  ref: { type: 'criterion', id: c.id },
97
+ acts: uatActs(c, opts),
78
98
  };
79
99
  }
80
100
 
@@ -161,6 +181,37 @@ function prioritiesFloor(goals, versionId) {
161
181
  return { count: '', line: 'the order goals are worked in', lines: [[top, versionId ? `version ${versionId}` : ''], [`${plural(mine.length, 'goal is', 'goals are')} in the order.`, '']], hot: false };
162
182
  }
163
183
 
184
+ // The Priorities desk (task 1004560): the building version's open goals in their
185
+ // worked order, each with its promises kept of total, for a viewer who may set
186
+ // the order — POST /goals/reorder's own atom (goal.reorder), or, where Planning
187
+ // is put to a sitting, the right to file the matter (board.item.open). Which of
188
+ // the two a save takes is the decision table's call, read by the screen from the
189
+ // docket's own buildings row; the route the save reaches still decides. Pure.
190
+ const ORDER_PIPS = 12;
191
+ function orderDeskRows(goals, criteria, versionId, mayOrder) {
192
+ if (!mayOrder || !versionId) return [];
193
+ const by = new Map();
194
+ for (const c of criteria || []) {
195
+ if (c.goal_id == null) continue;
196
+ const k = String(c.goal_id);
197
+ if (!by.has(k)) by.set(k, { kept: 0, total: 0 });
198
+ const e = by.get(k); e.total += 1; if (c.satisfied === true) e.kept += 1;
199
+ }
200
+ const mine = (goals || []).filter((g) => g && g.status === 'open' && g.version_id === versionId);
201
+ if (mine.length < 2) return []; // one goal has no order to set
202
+ return mine.map((g) => {
203
+ const e = by.get(String(g.id));
204
+ const of = e ? Math.min(e.total, ORDER_PIPS) : 0;
205
+ return {
206
+ id: g.id, name: String(g.title), ref: { type: 'goal', id: g.id }, set: String(versionId),
207
+ fact: e ? `${e.kept} of ${e.total} promises kept` : 'no promises yet',
208
+ facts: [`version ${versionId}`],
209
+ ...(of ? { pips: { on: Math.min(e.kept, of), of } } : {}),
210
+ acts: [{ key: 'up' }, { key: 'down' }],
211
+ };
212
+ });
213
+ }
214
+
164
215
  // Planning · Versions: what is building, and the last that shipped.
165
216
  function versionsFloor(versions, goals) {
166
217
  const all = versions || [];
@@ -190,6 +241,26 @@ function signedOffFloor(criteria, now) {
190
241
  };
191
242
  }
192
243
 
244
+ // The Review desk's signed-off rows (task 1004561): the newest promises signed
245
+ // off, each with one act, undo, for a viewer holding criterion.unsatisfy (POST
246
+ // /done-when/:id/unsatisfy's own atom). The reason is one tap. Pure.
247
+ const UNDO_REASONS = Object.freeze([
248
+ { key: 'broke', label: 'it broke again' },
249
+ { key: 'untested', label: 'not truly tested' },
250
+ { key: 'error', label: 'signed in error' },
251
+ ]);
252
+ const SIGNED_ROWS = 8;
253
+ function signedDeskRows(criteria) {
254
+ return (criteria || []).filter((c) => c && c.satisfied === true && c.checked_at)
255
+ .sort((a, b) => Date.parse(b.checked_at) - Date.parse(a.checked_at))
256
+ .slice(0, SIGNED_ROWS)
257
+ .map((c) => ({
258
+ id: c.id, name: String(c.criterion_md), fact: `signed off ${dayWord(c.checked_at)}`,
259
+ facts: c.goal_title ? [String(c.goal_title)] : [], ref: { type: 'signed_criterion', id: c.id },
260
+ acts: [{ key: 'unsign', options: UNDO_REASONS }],
261
+ }));
262
+ }
263
+
193
264
  // Release · Live this month: shipped work, newest first.
194
265
  function liveMonthFloor(shipped, now) {
195
266
  const month = (shipped || []).filter((t) => t.shipped_at && Date.parse(t.shipped_at) >= monthStart(now));
@@ -264,6 +335,15 @@ function contributeLifecycleFloors() {
264
335
  floor: 'planning.priorities', gate: any,
265
336
  read: async (_ctx, { once } = {}) => prioritiesFloor(await openGoals(once), await db.currentBuildingVersionId()),
266
337
  });
338
+ api.contribute(POINT, {
339
+ desk: 'planning.order', gate: any,
340
+ read: async (ctx, { once } = {}) => {
341
+ const may = ctx.has('goal.reorder') || ctx.has('board.item.open');
342
+ if (!may) return [];
343
+ const v = await db.currentBuildingVersionId();
344
+ return orderDeskRows(await openGoals(once), v ? await doneWhen.listCriteria(v) : [], v, may);
345
+ },
346
+ });
267
347
  api.contribute(POINT, {
268
348
  floor: 'planning.versions', gate: any,
269
349
  read: async () => versionsFloor(await db.listVersions(), await db.listGoals({})),
@@ -275,6 +355,13 @@ function contributeLifecycleFloors() {
275
355
  return signedOffFloor(v ? await doneWhen.listCriteria(v) : [], now);
276
356
  },
277
357
  });
358
+ api.contribute(POINT, {
359
+ desk: 'review.signed', gate: { permission: 'criterion.unsatisfy' },
360
+ read: async () => {
361
+ const v = await db.currentBuildingVersionId();
362
+ return signedDeskRows(v ? await doneWhen.listCriteria(v) : []);
363
+ },
364
+ });
278
365
  api.contribute(POINT, {
279
366
  floor: 'release.live_month', gate: any,
280
367
  read: async (_ctx, { now } = {}) => liveMonthFloor(await db.recentShippedTasks(SHIPPED_LIMIT, { days: 31 }), now),
@@ -326,7 +413,8 @@ function contributeLifecycleToDocket() {
326
413
  source: 'criteria_uat',
327
414
  building: 'review',
328
415
  gate: { permission: 'criterion.review' },
329
- read: async () => (await doneWhen.listPendingReviewCriteria({})).filter((c) => c.awaiting_uat === true).map(uatMatter),
416
+ read: async (ctx) => (await doneWhen.listPendingReviewCriteria({})).filter((c) => c.awaiting_uat === true)
417
+ .map((c) => uatMatter(c, { force: ctx.has('criterion.satisfy') })),
330
418
  });
331
419
  // Own-scoped by its read, like GET /goals/inbox: a goal's lead sees the requests
332
420
  // to join their goals, and everyone sees their own pending ones. An Archon is
@@ -371,13 +459,16 @@ module.exports = {
371
459
  overrideMatter,
372
460
  removalMatter,
373
461
  uatMatter,
462
+ uatActs,
374
463
  joinRequestMatter,
375
464
  flowReading,
376
465
  promisesReading,
377
466
  goalsFloor,
378
467
  prioritiesFloor,
468
+ orderDeskRows,
379
469
  versionsFloor,
380
470
  signedOffFloor,
471
+ signedDeskRows,
381
472
  liveMonthFloor,
382
473
  heldBackFloor,
383
474
  newcomersFloor,
@@ -0,0 +1,63 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/matter-acts.js — what a PASSED Review or Release matter
4
+ // carries out (task 1004561, goal 1000125). When a project's decision table puts
5
+ // Review or Release to a sitting, its passed matter acts here, after the close
6
+ // commits, through the SAME function the direct route calls:
7
+ // review satisfy_criterion → satisfyByOverride (POST /done-when/:id/satisfy)
8
+ // release approve_override → db.decideOverrideRequest (POST /override-requests/:id/decide)
9
+ // release approve_gate → approveGatePr (POST /gate-approvals/:pr/approve)
10
+ // The government module only checks the act's shape and calls in through the
11
+ // 'governor.matterAct' contribution point (a module never requires a sibling).
12
+ // Each act returns { done, reason? }: a refusal the route would give (already
13
+ // decided, not open, owner only) is a reason, never a throw.
14
+
15
+ const api = require('../../src/module-api');
16
+ const db = require('./db');
17
+ const doneWhen = require('./done-when');
18
+
19
+ const POINT = 'governor.matterAct';
20
+ const why = (area, ctx) => `A passed ${area} matter (matter ${ctx && ctx.matterId}) decided it.`;
21
+
22
+ // Required lazily: the route files build routers, and only their shared
23
+ // functions are needed here.
24
+ const routes = {
25
+ doneWhen: () => require('./routes/done-when'),
26
+ gate: () => require('./routes/gate-approvals'),
27
+ };
28
+
29
+ const ACTS = Object.freeze({
30
+ review: Object.freeze({
31
+ satisfy_criterion: async (action, ctx) => {
32
+ const c = await doneWhen.getCriterion(action.criterion_id);
33
+ if (!c) return { done: false, reason: 'criterion_not_found' };
34
+ if (c.satisfied === true) return { done: false, reason: 'already_satisfied' };
35
+ await routes.doneWhen().satisfyByOverride({ criterionId: action.criterion_id, signerId: ctx.decidedBy, reason: why('Review', ctx) });
36
+ return { done: true };
37
+ },
38
+ }),
39
+ release: Object.freeze({
40
+ approve_override: async (action, ctx) => {
41
+ try {
42
+ await db.decideOverrideRequest({ requestId: action.request_id, archonId: ctx.decidedBy, decision: 'approve', note: why('Release', ctx) });
43
+ return { done: true };
44
+ } catch (err) {
45
+ if (err && err.code === 'REQUEST_NOT_OPEN') return { done: false, reason: 'request_not_open' };
46
+ if (err && err.code === 'OVERRIDE_OWNER_ONLY') return { done: false, reason: 'override_owner_only' };
47
+ throw err;
48
+ }
49
+ },
50
+ approve_gate: async (action, ctx) => {
51
+ const out = await routes.gate().approveGatePr({ number: action.pr, login: `council, matter ${ctx && ctx.matterId}` });
52
+ return out.ok ? { done: true } : { done: false, reason: out.code };
53
+ },
54
+ }),
55
+ });
56
+
57
+ function contributeLifecycleMatterActs() {
58
+ for (const [area, acts] of Object.entries(ACTS)) {
59
+ for (const [type, act] of Object.entries(acts)) api.contribute(POINT, { area, type, act });
60
+ }
61
+ }
62
+
63
+ module.exports = { ACTS, contributeLifecycleMatterActs };
@@ -25,6 +25,7 @@ const goalEdits = require('../goal-edits.js');
25
25
  const uatDb = require('../criterion-uat-db.js');
26
26
  const { isReservedCriterionSlug } = require('../criterion-ref.js');
27
27
  const { validateOrRespond, parseId, asyncHandler, LIMITS } = api;
28
+ const log = api.logger('lifecycle');
28
29
 
29
30
  // authorizeCriterionCreate — the ORDERED, FAIL-CLOSED decision for a non-Archon
30
31
  // POST /versions/:id/done-when (ADR 0154). PURE + exported so every rejection is
@@ -80,6 +81,24 @@ function authorizeCriterionCreate({ goal, version, actorId, actorRank, actorMemb
80
81
  return { ok: true };
81
82
  }
82
83
 
84
+ // Close a criterion WITHOUT a UAT, on a stated reason: the override row and the
85
+ // flip in ONE transaction (an override row with no flip behind it would claim a
86
+ // close that never happened), then the goal achieves if that was its last
87
+ // criterion (a criterion with no goal completes none). Shared by POST
88
+ // /done-when/:id/satisfy and a passed Review matter (task 1004561,
89
+ // ../matter-acts.js) so the two can never differ.
90
+ async function satisfyByOverride({ criterionId, signerId, reason, taskId = null }) {
91
+ const updated = await api.withTx(async (client) => {
92
+ await uatDb.recordOverride(client, { criterionId, signerId, reason });
93
+ return doneWhen.markCriterion(criterionId, taskId, { client });
94
+ }, { pool: api.pool });
95
+ let goalResult = { achieved: false, goal: null };
96
+ if (updated?.goal_id != null) {
97
+ goalResult = await db.achieveGoalIfComplete({ goalId: updated.goal_id });
98
+ }
99
+ return { updated, goalResult };
100
+ }
101
+
83
102
  module.exports = function buildDoneWhenRouter() {
84
103
  const router = express.Router();
85
104
 
@@ -248,18 +267,7 @@ module.exports = function buildDoneWhenRouter() {
248
267
  });
249
268
  }
250
269
  const taskId = req.body?.satisfied_by_task_id ?? null;
251
- // ONE transaction: an override row with no flip behind it would claim a
252
- // close that never happened, so the two commit together or not at all.
253
- const updated = await api.withTx(async (client) => {
254
- await uatDb.recordOverride(client, { criterionId: id, signerId: req.builder.id, reason });
255
- return doneWhen.markCriterion(id, taskId, { client });
256
- }, { pool: api.pool });
257
- // Auto-achieve: confirming a goal's last criterion flips it open→achieved.
258
- // A criterion with no goal (goal_id NULL) completes no goal.
259
- let goalResult = { achieved: false, goal: null };
260
- if (updated?.goal_id != null) {
261
- goalResult = await db.achieveGoalIfComplete({ goalId: updated.goal_id });
262
- }
270
+ const { updated, goalResult } = await satisfyByOverride({ criterionId: id, signerId: req.builder.id, reason, taskId });
263
271
  // BV1.R08 (task 1003595): when the goal did NOT flip because it still holds
264
272
  // unfinished work, say so. Confirming the last criterion and getting a bare
265
273
  // `goal_achieved: false` back reads as a bug to the agent that just did it —
@@ -466,12 +474,17 @@ module.exports = function buildDoneWhenRouter() {
466
474
 
467
475
  // Auth: flip a criterion back to unsatisfied (rare; for corrections).
468
476
  // rank: archon — un-flip a criterion; same gate as satisfy.
477
+ // An optional `reason` (the Review desk's one-tap chip, task 1004561) is
478
+ // logged beside who undid it; the flip itself is unchanged.
469
479
  router.post('/done-when/:criterionId/unsatisfy', auth.requireBuilder, auth.requirePermission('criterion.unsatisfy'), asyncHandler('POST /done-when/:criterionId/unsatisfy', async (req, res) => {
480
+ if (validateOrRespond(req, res, { reason: { type: 'string', maxLength: 200 } })) return;
470
481
  const id = parseId(req, res, { param: 'criterionId', code: 'bad_criterion_id' });
471
482
  if (id === null) return;
472
483
  const existing = await doneWhen.getCriterion(id);
473
484
  if (!existing) return res.fail('criterion_not_found', 404);
474
485
  const updated = await doneWhen.unmarkCriterion(id);
486
+ const why = typeof req.body?.reason === 'string' && req.body.reason.trim() ? `: ${req.body.reason.trim()}` : '';
487
+ log.info(`[gds] criterion ${id} sign-off UNDONE by builder ${req.builder.id}${why}`);
475
488
  res.json({ criterion: updated });
476
489
  }, { errorCode: 'unsatisfy_failed' }));
477
490
 
@@ -482,3 +495,4 @@ module.exports = function buildDoneWhenRouter() {
482
495
  // tests/criterion_authoring_gate.mjs (DB-free), the authorizeGoalTaskCreate
483
496
  // precedent in routes/goals.js.
484
497
  module.exports.authorizeCriterionCreate = authorizeCriterionCreate;
498
+ module.exports.satisfyByOverride = satisfyByOverride;
@@ -141,6 +141,75 @@ function contributeGateApprovalsToDocket() {
141
141
  });
142
142
  }
143
143
 
144
+ // The approval itself, shared by the route above and a passed Release matter
145
+ // (task 1004561, ../matter-acts.js) so the two can never differ. Returns
146
+ // { ok: true, body } or { ok: false, code, status, message }; throws on an
147
+ // unexpected failure (the caller says it). `login` is who approved, as the
148
+ // status description names it.
149
+ async function approveGatePr({ number, login }) {
150
+ if (!githubPush.isConfigured()) {
151
+ return { ok: false, code: 'publish_unconfigured', status: 503, message: 'No server GitHub credential configured — cannot approve.' };
152
+ }
153
+ // Resolve the live OPEN PR + its current head sha (the approval is sha-pinned,
154
+ // so a later commit's head carries no approval — red-team #2b protection).
155
+ const prs = await githubPush.listOpenPullRequests({});
156
+ const pr = prs.find((p) => p.number === number);
157
+ if (!pr) return { ok: false, code: 'pr_not_open', status: 404, message: `No open PR #${number}.` };
158
+ if (!pr.head_sha) return { ok: false, code: 'no_head_sha', status: 409, message: 'Could not resolve the PR head commit.' };
159
+
160
+ const gate = findCheck(pr, 'gate-review');
161
+ if (gate && isSuccess(gate)) {
162
+ return { ok: true, body: { ok: true, pr: number, already_clear: true, message: 'gate-review is already green — nothing to approve.' } };
163
+ }
164
+
165
+ // 1. Set the trustless owner-approval status on the head sha.
166
+ const set = await githubPush.setGateOwnerApproved({ sha: pr.head_sha, login, targetUrl: pr.url });
167
+ if (!set) {
168
+ // 500, not 502 — Cloudflare masks an origin 502 with its branded page (see the route's catch).
169
+ return { ok: false, code: 'status_set_failed', status: 500, message: 'Could not set the gate-owner-approved status.' };
170
+ }
171
+
172
+ // 2. Re-trigger gate-review (close+reopen → `pull_request: reopened`) so the
173
+ // required check re-evaluates the new approval; re-arm auto-merge.
174
+ // A PAGE TWEAK HELD FOR ITS ARTIST is reopened but NOT re-armed (task
175
+ // 1004323): clearing the gate check is the owner's act on the PR, and
176
+ // approving the page is the artist's (ADR 0341 D7). Asked fail-closed: a
177
+ // read that throws answers held, and an ordinary confirmed task left
178
+ // un-armed still lands through the publish reconciler's sweep.
179
+ const prTask = taskIdFromPr(pr);
180
+ const held = prTask != null && await pageTweakReads.heldForArtistFailClosed(prTask);
181
+ let recheck = { reopened: false, auto_merge: false };
182
+ try {
183
+ recheck = await githubPush.recheckPullRequest(held ? { number, arm: false, heldReason: HELD_NOT_ARMED.reason } : { number });
184
+ } catch (e) {
185
+ // The status IS set; if the re-trigger failed, the next push or a manual
186
+ // re-run still clears it, and the publish reconciler sweep merges once green.
187
+ console.error('[gds] gate-approval recheck failed', e);
188
+ return {
189
+ ok: true,
190
+ body: {
191
+ ok: true, pr: number, head_sha: pr.head_sha, status_set: true, recheck: 'failed',
192
+ message: 'Approval recorded. Re-run of the gate check did not fire — it will clear on the next push or a manual re-run.',
193
+ },
194
+ };
195
+ }
196
+
197
+ return {
198
+ ok: true,
199
+ body: {
200
+ ok: true,
201
+ pr: number,
202
+ head_sha: pr.head_sha,
203
+ status_set: true,
204
+ recheck,
205
+ held_for_artist: held,
206
+ message: held
207
+ ? 'Approval recorded. Re-running the gate check. This is a page tweak waiting for its artist, so it lands only once the artist approves it in the studio.'
208
+ : 'Approval recorded. Re-running the gate check — the PR merges once it goes green.',
209
+ },
210
+ };
211
+ }
212
+
144
213
  module.exports = function buildGateApprovalsRouter() {
145
214
  contributeGateApprovalsToDocket();
146
215
  const router = express.Router();
@@ -189,66 +258,10 @@ module.exports = function buildGateApprovalsRouter() {
189
258
  router.post('/gate-approvals/:pr/approve', auth.requireBuilder, auth.requirePermission('gate_approval.decide'), async (req, res) => {
190
259
  const number = parseId(req, res, { param: 'pr', code: 'bad_pr', positiveInt: true });
191
260
  if (number == null) return;
192
- if (!githubPush.isConfigured()) {
193
- return res.fail('publish_unconfigured', { status: 503, message: 'No server GitHub credential configured — cannot approve.' });
194
- }
195
261
  try {
196
- // Resolve the live OPEN PR + its current head sha (the approval is sha-pinned,
197
- // so a later commit's head carries no approval — red-team #2b protection).
198
- const prs = await githubPush.listOpenPullRequests({});
199
- const pr = prs.find((p) => p.number === number);
200
- if (!pr) return res.fail('pr_not_open', { status: 404, message: `No open PR #${number}.` });
201
- if (!pr.head_sha) return res.fail('no_head_sha', { status: 409, message: 'Could not resolve the PR head commit.' });
202
-
203
- const gate = findCheck(pr, 'gate-review');
204
- if (gate && isSuccess(gate)) {
205
- return res.json({ ok: true, pr: number, already_clear: true, message: 'gate-review is already green — nothing to approve.' });
206
- }
207
-
208
- // 1. Set the trustless owner-approval status on the head sha.
209
- const set = await githubPush.setGateOwnerApproved({
210
- sha: pr.head_sha,
211
- login: req.builder.github_login,
212
- targetUrl: pr.url,
213
- });
214
- if (!set) {
215
- // 500, not 502 — Cloudflare masks an origin 502 with its branded page (see catch).
216
- return res.fail('status_set_failed', { status: 500, message: 'Could not set the gate-owner-approved status.' });
217
- }
218
-
219
- // 2. Re-trigger gate-review (close+reopen → `pull_request: reopened`) so the
220
- // required check re-evaluates the new approval; re-arm auto-merge.
221
- // A PAGE TWEAK HELD FOR ITS ARTIST is reopened but NOT re-armed (task
222
- // 1004323): clearing the gate check is the owner's act on the PR, and
223
- // approving the page is the artist's (ADR 0341 D7). Asked fail-closed: a
224
- // read that throws answers held, and an ordinary confirmed task left
225
- // un-armed still lands through the publish reconciler's sweep.
226
- const prTask = taskIdFromPr(pr);
227
- const held = prTask != null && await pageTweakReads.heldForArtistFailClosed(prTask);
228
- let recheck = { reopened: false, auto_merge: false };
229
- try {
230
- recheck = await githubPush.recheckPullRequest(held ? { number, arm: false, heldReason: HELD_NOT_ARMED.reason } : { number });
231
- } catch (e) {
232
- // The status IS set; if the re-trigger failed, the next push or a manual
233
- // re-run still clears it, and the publish reconciler sweep merges once green.
234
- console.error('[gds] gate-approval recheck failed', e);
235
- return res.json({
236
- ok: true, pr: number, head_sha: pr.head_sha, status_set: true, recheck: 'failed',
237
- message: 'Approval recorded. Re-run of the gate check did not fire — it will clear on the next push or a manual re-run.',
238
- });
239
- }
240
-
241
- res.json({
242
- ok: true,
243
- pr: number,
244
- head_sha: pr.head_sha,
245
- status_set: true,
246
- recheck,
247
- held_for_artist: held,
248
- message: held
249
- ? 'Approval recorded. Re-running the gate check. This is a page tweak waiting for its artist, so it lands only once the artist approves it in the studio.'
250
- : 'Approval recorded. Re-running the gate check — the PR merges once it goes green.',
251
- });
262
+ const out = await approveGatePr({ number, login: req.builder.github_login });
263
+ if (!out.ok) return res.fail(out.code, { status: out.status, message: out.message });
264
+ res.json(out.body);
252
265
  } catch (err) {
253
266
  console.error('[gds] POST /gate-approvals/:pr/approve', err);
254
267
  // 500, not 502: Cloudflare replaces an origin 502/504 with its own branded
@@ -261,3 +274,5 @@ module.exports = function buildGateApprovalsRouter() {
261
274
 
262
275
  return router;
263
276
  };
277
+
278
+ module.exports.approveGatePr = approveGatePr;
@@ -139,6 +139,8 @@ function registerSeams() {
139
139
  module.exports = function buildLifecycleRouter() {
140
140
  registerSeams();
141
141
  governorDocket.contributeLifecycleToDocket();
142
+ // what a passed Review or Release matter carries out (task 1004561)
143
+ require('../matter-acts').contributeLifecycleMatterActs();
142
144
  // No HTTP surface — the lifecycle capability is a pure kernel port. The empty
143
145
  // router is only the loader's contributes.routes mount vehicle; returning a
144
146
  // valid express.Router() keeps mountModuleRoutes' `router.use(sub)` happy
@@ -134,7 +134,29 @@ function upmarketRow() {
134
134
  };
135
135
  }
136
136
 
137
+ // The Rules desk's settings rows (task 1004559), under this page's own atom: the
138
+ // rot timer as one-tap day counts (PATCH /project-settings/rot-days), and the
139
+ // work categories as a link to this page, since a category is named in words.
140
+ // Pure; exported for tests.
141
+ const ROT_CHOICES = Object.freeze([7, 14, 30, 60, 90]);
142
+ function settingsDeskRows({ days, categories }) {
143
+ const live = (categories || []).filter((c) => c && c.active !== false).length;
144
+ return [
145
+ { id: 'rot', name: 'Rot timer', fact: `stale after ${days} ${days === 1 ? 'day' : 'days'}`, facts: ['raising it only asks a person'], tool: true, ref: { type: 'project_setting', id: 'rot_days' },
146
+ acts: [{ key: 'rot', options: ROT_CHOICES.filter((d) => d !== Number(days)).slice(0, 4).map((d) => ({ key: String(d), label: `${d} days` })) }] },
147
+ { id: 'categories', name: 'Work categories', fact: `${live} in use`, tool: true, href: '/project-settings', ref: { type: 'project_setting', id: 'work_categories' }, acts: [{ key: 'open' }] },
148
+ ];
149
+ }
150
+ function contributeSettingsDesk() {
151
+ api.contribute('governor.docket', {
152
+ desk: 'rules.settings',
153
+ gate: { permission: 'project.settings.manage' },
154
+ read: async () => settingsDeskRows({ days: (await api.resolveRotDays()).days, categories: await goalCategory.listCategories({ includeInactive: true }) }),
155
+ });
156
+ }
157
+
137
158
  module.exports = function buildProjectSettingsRouter() {
159
+ contributeSettingsDesk();
138
160
  const router = express.Router();
139
161
 
140
162
  // GET /project-settings — all five rows in ONE read.
@@ -261,3 +283,5 @@ module.exports = function buildProjectSettingsRouter() {
261
283
 
262
284
  return router;
263
285
  };
286
+
287
+ module.exports.settingsDeskRows = settingsDeskRows;
@@ -242,7 +242,12 @@ function accessRequestDocketMatter(r) {
242
242
  // The governor's docket: a pending request to join stands at People, shown only
243
243
  // to a viewer holding `access_request.review` (this queue's own atom, because it
244
244
  // exposes would-be builders' usernames) and needing that same viewer's act.
245
+ // The People desk's invite row (task 1004559), under the invite route's own atom:
246
+ // one GitHub name typed in place, sent to POST /access-requests/invite.
247
+ const INVITE_DESK_ROW = Object.freeze({ id: 'invite', name: 'Invite someone', fact: 'by their GitHub name', tool: true, ref: { type: 'invite', id: 'new' }, acts: [{ key: 'invite', field: 'github_login' }] });
248
+
245
249
  function contributeAccessRequestsToDocket() {
250
+ api.contribute('governor.docket', { desk: 'people.invite', gate: { permission: 'access_request.review' }, read: async () => [INVITE_DESK_ROW] });
246
251
  api.contribute('governor.docket', {
247
252
  source: 'access_requests',
248
253
  building: 'people',
@@ -651,3 +656,5 @@ module.exports = function buildAccessRequestsRouter() {
651
656
  };
652
657
 
653
658
  module.exports.accessRequestDocketMatter = accessRequestDocketMatter;
659
+
660
+ module.exports.INVITE_DESK_ROW = INVITE_DESK_ROW;
@@ -545,5 +545,5 @@ function makeUpdateChannelHandler({ provisioning, pool, parseId, validateOrRespo
545
545
  };
546
546
  }
547
547
 
548
- module.exports = { UPDATE_CHANNELS, DEFAULT_UPDATE_CHANNEL, updateChannelOf, selfUpdateChannel, makeUpdateChannelHandler, CORE_UPGRADE_FROM, lastCoreMove, lastMoveSuperseded, parseRepoNote, preflightForOffer, UPGRADE_SHAPES, VERSION_RE, isCoreVersion, coreUpgradeFault, enqueueCoreUpgrade, makeCoreUpgradeHandler, previewFromSnapshot, makeCoreUpgradePreviewHandler, makeControlPlaneHandler };
548
+ module.exports = { loadPreview, UPDATE_CHANNELS, DEFAULT_UPDATE_CHANNEL, updateChannelOf, selfUpdateChannel, makeUpdateChannelHandler, CORE_UPGRADE_FROM, lastCoreMove, lastMoveSuperseded, parseRepoNote, preflightForOffer, UPGRADE_SHAPES, VERSION_RE, isCoreVersion, coreUpgradeFault, enqueueCoreUpgrade, makeCoreUpgradeHandler, previewFromSnapshot, makeCoreUpgradePreviewHandler, makeControlPlaneHandler };
549
549