@bongos/core 1.19.813 → 1.19.815

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 (39) hide show
  1. package/.bongos-core.json +74 -39
  2. package/clients/bongos-client/bongos-client.global.js +1 -1
  3. package/clients/bongos-client/index.cjs +1 -1
  4. package/clients/bongos-client/index.d.ts +2 -3
  5. package/clients/bongos-client/index.mjs +1 -1
  6. package/docs/adr/0297-a-tasks-creator-may-retire-it.md +128 -0
  7. package/docs/adr/README.md +1 -0
  8. package/docs/api/openapi.json +5 -28
  9. package/docs/api-reference.md +2 -2
  10. package/docs/canonical-permissions.md +30 -1
  11. package/docs/copy-inventory.md +49 -45
  12. package/docs/copy-registry.json +86 -50
  13. package/docs/module-api-changelog.md +4 -0
  14. package/modules/agents/lib/validate.js +1 -1
  15. package/modules/dev-box/app/src/vendor/bongos-client.cjs +1 -1
  16. package/modules/discord/discord-channels.js +80 -5
  17. package/modules/discord/routes/discord.js +14 -2
  18. package/modules/government/catalog.js +28 -1
  19. package/modules/government/migrations/government_015_task_abandon_own.sql +64 -0
  20. package/modules/hall-ui/public/task.js +50 -1
  21. package/modules/ideas/inbox.js +29 -1
  22. package/modules/lifecycle/abandon-authz.js +132 -0
  23. package/modules/lifecycle/cascade.js +134 -19
  24. package/modules/lifecycle/db-ship.js +59 -5
  25. package/modules/lifecycle/routes/abandon-handler.js +151 -0
  26. package/modules/lifecycle/routes/lifecycle.js +18 -0
  27. package/modules/lifecycle/routes/tasks.js +26 -38
  28. package/package-lock.json +2 -2
  29. package/package.json +1 -1
  30. package/src/bongos/route-rank-check.js +12 -1
  31. package/src/module-api.js +1 -1
  32. package/tests/abandon_creator_authz.mjs +176 -0
  33. package/tests/abandon_route.mjs +276 -0
  34. package/tests/abandon_task.mjs +145 -3
  35. package/tests/agents_validate.mjs +33 -2
  36. package/tests/discord_channels.mjs +166 -0
  37. package/tests/government_ownership_scope.mjs +13 -7
  38. package/tests/idea_cascade.mjs +211 -0
  39. package/tests/task_goal_required.mjs +15 -3
@@ -164,7 +164,16 @@ const PERMISSIONS = [
164
164
  { key: 'task.edit', system: false, floor: 'metic', guards: 'PATCH /tasks/:id' },
165
165
  { key: 'task.promote', system: false, floor: 'metic', guards: 'POST /tasks/:id/promote' },
166
166
  { key: 'task.demote', system: false, floor: 'metic', guards: 'POST /tasks/:id/demote' },
167
- { key: 'task.abandon', system: false, floor: 'metic', guards: 'POST /tasks/:id/abandon — scope removal; undo = POST /tasks/:id/promote (ADR 0152, was archon)' },
167
+ // task 1002443: now the CROSS-OWNER half of a pair. Floor and guards are
168
+ // unchanged — what is new is that the key DECLARES its ownership axis, so
169
+ // `task.abandon.own` (in the ownership block below) can name it as the escalation
170
+ // path a creator's own-scoped grant is measured against.
171
+ //
172
+ // Verb `act`, not `manage`: retiring a task is ONE reach into the row, and
173
+ // `manage` is the full-control verb — declaring it here would silently dominate
174
+ // every future narrower `task.*.own` key, which is exactly the verb-blind
175
+ // escalation task 1003195 closed.
176
+ { key: 'task.abandon', system: false, floor: 'metic', scope: 'any', resource: 'task', verb: 'act', guards: 'POST /tasks/:id/abandon — scope removal on ANY task; undo = POST /tasks/:id/promote (ADR 0152, was archon). The cross-owner counterpart of task.abandon.own (task 1002443)' },
168
177
  { key: 'task.merge', system: false, floor: 'metic', guards: 'POST /tasks/:id/merge — mark a duplicate task merged into another (task 1001478, the idea.triage merge analogue)' },
169
178
  { key: 'task.vote', system: false, floor: 'metic', guards: 'POST /tasks/:id/vote' },
170
179
  { key: 'task.dependency.manage', system: false, floor: 'metic', guards: 'POST|PUT|DELETE /tasks/:id/dependencies (same-tier)' },
@@ -317,6 +326,24 @@ const PERMISSIONS = [
317
326
  // marker here. Exactly why scope is declared data: the suffix rule only runs the
318
327
  // safe direction ('.own' ⇒ scope 'own'), never the converse.
319
328
  { key: 'me.wandering.set', system: false, floor: 'xenos', scope: 'own', resource: 'builder_prefs', verb: 'set', counterpart: [], guards: 'PATCH /me/wandering (clamped by maxLevelForRank) — ownership-scoped' },
329
+ // task 1002443 — the owner's 2026-07-25 request: "the creator of that task should
330
+ // be able to abandon that task directly inside of the UI". Retiring work you
331
+ // filed yourself is not a triage authority, so it does not want the metic floor
332
+ // that `task.abandon` carries; it wants the OWNERSHIP axis, which is precisely the
333
+ // narrower rule a rank cannot express (`whose row is this`).
334
+ //
335
+ // counterpart is REAL here, unlike task.act.own/memory.manage.own: `task.abandon`
336
+ // is the same `act` verb over the same `task` resource across every creator, so it
337
+ // genuinely subsumes retiring your own — a Metic keeps the power they have today
338
+ // without needing to be the creator, and the route admits on either branch through
339
+ // resolver.authorizeOwnedOrAny.
340
+ //
341
+ // The floor is the LOOSER half of the gate, never the whole of it (the
342
+ // `goal.manage.own` posture): holding this key authorizes nothing on its own, and
343
+ // the STATUS envelope — creator-abandon only while the task is backlog/ready/
344
+ // blocked, never completed/confirmed/shipped — is enforced in abandonTask under
345
+ // the same FOR UPDATE lock that performs the write, not here.
346
+ { key: 'task.abandon.own', system: false, floor: 'xenos', scope: 'own', resource: 'task', verb: 'act', counterpart: ['task.abandon'], guards: 'POST /tasks/:id/abandon on a task you CREATED (tasks.created_by), while it is backlog/ready/blocked and unclaimed — requireBuilder + in-handler authorizeOwnedOrAny; ownership-scoped (task 1002443)' },
320
347
  { key: 'cost.log', system: false, floor: 'xenos', guards: 'POST /cost' },
321
348
  { key: 'learning.create', system: false, floor: 'xenos', guards: 'POST /learnings' },
322
349
  { key: 'blocker.file', system: false, floor: 'xenos', guards: 'POST /blockers' },
@@ -0,0 +1,64 @@
1
+ -- government_015_task_abandon_own.sql — grant the new `task.abandon.own` atom to
2
+ -- every rank (task 1002443, ADR 0297).
3
+ --
4
+ -- WHAT IT IS. The owner's 2026-07-25 request: "the creator of that task should be
5
+ -- able to abandon that task directly inside of the UI." Retiring a task was a rank
6
+ -- power only (`task.abandon`, floor metic, governance_003), so a sub-Metic builder
7
+ -- could file a task and then could not withdraw their own proposal. The new key is
8
+ -- the ADR 0151 §3 OWNERSHIP axis, not a rank widening.
9
+ --
10
+ -- A BRAND-NEW key: nobody holds it yet, so every rung the floor implies is granted
11
+ -- here. RANK_SEED is a cumulative superset and this key floors at `xenos`, the
12
+ -- bottom of the ladder — so ALL FOUR rank-roles get a block. The drift guard
13
+ -- (tests/government_seed.mjs) compares the union of the run-once seed plus every
14
+ -- later grant migration against the floor-derived seed, per rank; minting the
15
+ -- catalog row WITHOUT this migration is exactly what that guard catches, and it did.
16
+ --
17
+ -- GRANTING IT TO EVERY RANK GIVES NOBODY THE POWER TO RETIRE ANYTHING ON ITS OWN.
18
+ -- That is the whole point of an ownership-scoped key and the reason a floor this low
19
+ -- is safe: holding it authorizes nothing until a handler supplies the concrete row's
20
+ -- owner. `requirePermission` REFUSES an own-scoped key outright (auth.js, R100) so it
21
+ -- can never gate a route unconditionally, and `resolveBuilderPermissions` returning
22
+ -- it is only ever half a question. The other half — `tasks.created_by` equals the
23
+ -- caller, and the task is still backlog/ready/blocked — is answered inside
24
+ -- `abandonTask`'s FOR UPDATE transaction (modules/lifecycle/abandon-authz.js).
25
+ --
26
+ -- `task.abandon` (floor metic) is UNCHANGED and is not re-granted here: governance_003
27
+ -- already granted it and migrations are run-once. It becomes this key's declared
28
+ -- cross-owner counterpart — same `act` verb, same `task` resource — so a Metic keeps
29
+ -- retiring any task without being its creator.
30
+ --
31
+ -- Additive only: one new permission key granted to four ranks; nothing is revoked.
32
+ -- Idempotent (ON CONFLICT DO NOTHING); forward-safe (INSERT only).
33
+ --
34
+ -- One block PER RANK, in the government_004/009/010/011 shape: the drift guard scans
35
+ -- for `SELECT '<rank>', unnest(ARRAY[…])` literally, so a combined
36
+ -- `unnest(ARRAY['xenos','thetes']), 'key'` form parses as zero grants and reds it.
37
+
38
+ BEGIN;
39
+
40
+ INSERT INTO government_rank_permissions (rank_key, permission_key)
41
+ SELECT 'xenos', unnest(ARRAY[
42
+ 'task.abandon.own'
43
+ ])
44
+ ON CONFLICT (rank_key, permission_key) DO NOTHING;
45
+
46
+ INSERT INTO government_rank_permissions (rank_key, permission_key)
47
+ SELECT 'thetes', unnest(ARRAY[
48
+ 'task.abandon.own'
49
+ ])
50
+ ON CONFLICT (rank_key, permission_key) DO NOTHING;
51
+
52
+ INSERT INTO government_rank_permissions (rank_key, permission_key)
53
+ SELECT 'metic', unnest(ARRAY[
54
+ 'task.abandon.own'
55
+ ])
56
+ ON CONFLICT (rank_key, permission_key) DO NOTHING;
57
+
58
+ INSERT INTO government_rank_permissions (rank_key, permission_key)
59
+ SELECT 'archon', unnest(ARRAY[
60
+ 'task.abandon.own'
61
+ ])
62
+ ON CONFLICT (rank_key, permission_key) DO NOTHING;
63
+
64
+ COMMIT;
@@ -509,6 +509,18 @@
509
509
  // should carry these permissions is a governance change that needs its own
510
510
  // ADR, so this surfaces controls only for builders who already hold the atoms.
511
511
  const canShape = isSupervisor;
512
+ // task 1002443 — the CREATOR axis, which is not a rank and so cannot live in
513
+ // canShape. `task.abandon.own` (floor xenos, ADR 0297) lets a builder retire a
514
+ // task THEY filed while it is still backlog/ready/blocked. Same display-hint
515
+ // posture as canShape: the server re-decides this under a row lock, and this
516
+ // line only chooses whether to draw the control.
517
+ //
518
+ // created_by is null on every task filed before core_227 and on system vectors
519
+ // (seed, restock, the Discord bug path), so isCreator is false for them and the
520
+ // control simply does not appear — never a button that would 403.
521
+ const CREATOR_RETIRABLE = ['backlog', 'ready', 'blocked'];
522
+ const isCreator = !!(viewer && t.created_by != null
523
+ && String(t.created_by) === String(viewer.id));
512
524
  // render() rebuilds #task-body wholesale, so a disclosure cannot keep its
513
525
  // state in the DOM. This re-renders the SAME data without a refetch — the
514
526
  // toggle is a view change, not a reason to ask the server anything.
@@ -713,7 +725,23 @@
713
725
  // active claim must be released first — both are 409s the server will raise
714
726
  // anyway, and both are knowable from this record. Offering the control and
715
727
  // letting it fail would teach the reader that the page guesses.
728
+ //
729
+ // task 1002443 — the control now has TWO audiences, and they are offered
730
+ // different amounts of it. A Metic+ keeps every branch below, over any task. A
731
+ // CREATOR who is not Metic+ gets it only on their own task and only while it is
732
+ // backlog/ready/blocked, because that is exactly what the server will admit:
733
+ // past the grader a retire is a ledger correction, not an author changing their
734
+ // mind. Outside that window the creator is told so in words, for the same reason
735
+ // the two refusals below are pre-answered — a control that cannot work teaches
736
+ // the reader that the page guesses.
716
737
  const claimIsLive = !!(lastClaim && !lastClaim.released_at);
738
+ // The creator's narrower window. This is a DISPLAY MIRROR of the server's own
739
+ // CREATOR_ABANDONABLE (modules/lifecycle/abandon-authz.js) — a browser file
740
+ // cannot require it, so the list is restated here and the server re-decides
741
+ // every call under a row lock. If that list moves, this one moves with it.
742
+ const creatorMayRetire = isCreator && !claimIsLive
743
+ && CREATOR_RETIRABLE.indexOf(t.status) !== -1;
744
+ const mayRetire = canShape || creatorMayRetire;
717
745
  let retire = '';
718
746
  if (canShape && t.status === 'shipped') {
719
747
  retire = '<p class="rec-actnote">A shipped task cannot be retired — it is immutable history now.</p>';
@@ -721,7 +749,28 @@
721
749
  retire = '<p class="rec-actnote">Someone is holding a claim on this task. It has to be released before the task can be retired.</p>';
722
750
  } else if (canShape && t.status === 'abandoned') {
723
751
  retire = '<p class="rec-actnote">This task is already retired. Promote restores it to the backlog.</p>';
724
- } else if (canShape) {
752
+ // THE CREATOR'S REFUSALS ARE ORDERED BY WHAT WOULD ACTUALLY UNBLOCK THEM, which
753
+ // is not the same order as the supervisor's above. Each branch names the ONE
754
+ // thing standing in the way, and must not promise a remedy that would still be
755
+ // refused — the failure this file's own rule warns about, applied to the words
756
+ // rather than to a button.
757
+ //
758
+ // shipped → immutable for EVERYONE, so it must not read as 'ask a Metic'.
759
+ // abandoned → already done.
760
+ // a live claim, on a task that is otherwise retirable (or 'active', which
761
+ // releases back to 'ready') → releasing genuinely unblocks it.
762
+ // anything else outside the window (completed/confirmed) → authority is the
763
+ // blocker, and a Metic really can act there.
764
+ } else if (!canShape && isCreator && t.status === 'shipped') {
765
+ retire = '<p class="rec-actnote">You created this task, but it has shipped. Shipped work is immutable history — nobody can retire it, whatever their rank.</p>';
766
+ } else if (!canShape && isCreator && t.status === 'abandoned') {
767
+ retire = '<p class="rec-actnote">You created this task and it is already retired.</p>';
768
+ } else if (!canShape && isCreator && claimIsLive
769
+ && (t.status === 'active' || CREATOR_RETIRABLE.indexOf(t.status) !== -1)) {
770
+ retire = '<p class="rec-actnote">You created this task, but someone is holding a claim on it. Once that is released you can retire it yourself.</p>';
771
+ } else if (!canShape && isCreator && !creatorMayRetire) {
772
+ retire = '<p class="rec-actnote">You created this task, but it has already moved past planning. Retiring it now is a change to the record, so it needs a Metic.</p>';
773
+ } else if (mayRetire) {
725
774
  retire = abandonOpen
726
775
  ? `
727
776
  <div class="td-form" id="abandon-form">
@@ -1198,7 +1198,35 @@ async function developSpark(ideaId, { developedBy, templateKey = DEFAULT_TEMPLAT
1198
1198
  // null = "this row was not a developable spark when we looked". THREE ways to
1199
1199
  // get here, and the caller re-reads to say which: it was never a spark,
1200
1200
  // somebody else developed it first, or a triager closed the idea underneath us.
1201
- return rows[0] ?? null;
1201
+ const graded = rows[0] ?? null;
1202
+
1203
+ // task 1003856: a Full Idea is the ideator's hand-off point, and the
1204
+ // cross-role cascade that turns it into an engineer's scoping request needs a
1205
+ // transition to hang on. Emitted only on a REAL transition — the UPDATE's
1206
+ // `AND grade = 'spark'` means a no-op re-grade returns null and fires nothing,
1207
+ // so the event cannot repeat for one idea.
1208
+ //
1209
+ // Wrapped and awaited-free by the same reasoning as every other kernel emit
1210
+ // here: a listener must never be able to fail the write that preceded it. The
1211
+ // cascade runner catches everything of its own accord as well.
1212
+ if (graded) {
1213
+ // The payload carries the WHOLE idea, not just its id. The listener lives in
1214
+ // the lifecycle module, which owns no idea table and has no read port for one
1215
+ // (ideas provides only `ideas.capture`) — so the read happens here, inside the
1216
+ // module that owns the row, rather than by widening another module's surface.
1217
+ try {
1218
+ const idea = await getIdea(ideaId);
1219
+ api.emit('idea.graded.full', {
1220
+ ...(idea || {}),
1221
+ id: ideaId,
1222
+ idea_id: ideaId,
1223
+ grade: 'full',
1224
+ completeness_score: graded.completeness_score,
1225
+ developed_by: graded.developed_by,
1226
+ });
1227
+ } catch { /* a listener, or the read that feeds it, must never cost the ideator their grade */ }
1228
+ }
1229
+ return graded;
1202
1230
  }
1203
1231
 
1204
1232
  // The grade row on its own. null = no row, which reads as grade='quick'
@@ -0,0 +1,132 @@
1
+ // modules/lifecycle/abandon-authz.js — the PURE authorization decision behind
2
+ // POST /tasks/:id/abandon: may THIS builder retire THIS task, right now?
3
+ //
4
+ // WHY IT IS ITS OWN FILE. Two authorities reach that transition and only one of
5
+ // them is rank-shaped (ADR 0297):
6
+ //
7
+ // • `task.abandon` (floor metic) — retire ANY task. Triage authority; the
8
+ // inverse of /promote, re-ranked to match it (ADR 0152).
9
+ // • `task.abandon.own` (floor xenos) — retire a task you CREATED, while it is
10
+ // still backlog/ready/blocked. The owner's 2026-07-25 request: "the creator
11
+ // of that task should be able to abandon that task directly inside of the
12
+ // UI." A predicate over tasks.created_by, which no rank floor can express —
13
+ // the ADR 0151 ownership axis.
14
+ //
15
+ // The second one is a rule about a ROW, so it cannot be a middleware and it must
16
+ // not be re-derived at each call site. Carving it out follows the
17
+ // goal-route-authz.js / shouldRefuseSessionClaim precedent: PURE and exported, so
18
+ // every branch is pinned by a DB-free unit test instead of only through a live
19
+ // route, and so the hall's display hint and the server's decision are provably
20
+ // reading the same rule.
21
+ //
22
+ // PURE — no DB, no clock, no I/O. The caller resolves the RANK fact before its
23
+ // transaction opens (request-scoped, uncached, ADR 0016) and supplies the ROW
24
+ // fact from inside its FOR UPDATE lock. Splitting them is what makes the decision
25
+ // both fresh and race-free: reading created_by outside the lock would be a TOCTOU
26
+ // window, and resolving permissions inside it would hold a row lock across a
27
+ // second pool acquire.
28
+ //
29
+ // MODULE BOUNDARY: reaches core only through the published doorway.
30
+
31
+ 'use strict';
32
+
33
+ // The statuses a CREATOR may retire their own task in. Deliberately the
34
+ // not-yet-started, not-yet-judged window:
35
+ // • backlog / ready / blocked — the task is still a plan. Retiring it is the
36
+ // author withdrawing their own proposal, which is what the owner asked for.
37
+ // • active — excluded here AND by the active-claim guard in abandonTask: a
38
+ // live claim means a session still owns the work.
39
+ // • completed / confirmed — excluded ON PURPOSE. Past the grader a retire is a
40
+ // correction to the ledger, not an author changing their mind, so it stays
41
+ // with the cross-owner `task.abandon` authority.
42
+ // • shipped / abandoned — never reached from here; abandonTask refuses shipped
43
+ // outright and treats abandoned as an idempotent no-op.
44
+ const CREATOR_ABANDONABLE = Object.freeze(['backlog', 'ready', 'blocked']);
45
+
46
+ // The permission pair this decision is spelled in, exported so the route, the
47
+ // tests and the denial text all name the same two atoms.
48
+ const ABANDON_ANY_KEY = 'task.abandon';
49
+ const ABANDON_OWN_KEY = 'task.abandon.own';
50
+
51
+ // Does this builder hold enough to be worth locking the row for? The cheap
52
+ // pre-transaction half: `false` here is a flat 403 and no transaction is opened.
53
+ // Holding only the own-scoped key is NOT an admit — it is permission to be ASKED
54
+ // the ownership question, which is the whole point of the `.own` axis.
55
+ function mayAttemptAbandon({ holdsAny = false, holdsOwn = false } = {}) {
56
+ return holdsAny === true || holdsOwn === true;
57
+ }
58
+
59
+ // The row-scoped verdict. ORDERED and FAIL-CLOSED — every path that is not an
60
+ // explicit admit returns { ok: false } with a reason the caller can map to a
61
+ // refusal, and the default at the bottom denies rather than falls through.
62
+ //
63
+ // `sameOwner` is INJECTED rather than hand-rolled: Postgres hands bigints back as
64
+ // strings and JS code carries them as numbers, so 7 and '7' must compare equal
65
+ // while null, '', objects and NaN must match nothing. That normalization already
66
+ // exists once, in the government resolver, and duplicating it here is exactly how
67
+ // an ownership comparison quietly widens. A caller that supplies no comparator
68
+ // gets a strict scalar fallback with the same posture — never a loose `==`.
69
+ function strictSameOwner(a, b) {
70
+ const norm = (v) => {
71
+ if (typeof v === 'number') return Number.isFinite(v) ? String(v) : null;
72
+ if (typeof v === 'bigint') return String(v);
73
+ if (typeof v === 'string') return v === '' ? null : v;
74
+ return null;
75
+ };
76
+ const left = norm(a);
77
+ const right = norm(b);
78
+ return left !== null && right !== null && left === right;
79
+ }
80
+
81
+ function abandonVerdict({
82
+ holdsAny = false,
83
+ holdsOwn = false,
84
+ builderId = null,
85
+ createdBy = null,
86
+ status = null,
87
+ sameOwner = strictSameOwner,
88
+ } = {}) {
89
+ // The cross-owner branch: reaches every creator's row, so no predicate applies.
90
+ // Checked FIRST so a Metic+ is never refused by the creator-only window below —
91
+ // their authority is wider, not narrower.
92
+ if (holdsAny === true) return { ok: true, via: ABANDON_ANY_KEY };
93
+
94
+ if (holdsOwn !== true) {
95
+ return {
96
+ ok: false,
97
+ reason: 'missing_permission',
98
+ message: `missing permission: ${ABANDON_ANY_KEY} (floor: metic), or ${ABANDON_OWN_KEY} on a task you created`,
99
+ };
100
+ }
101
+
102
+ // An own-scoped key is never sufficient on its own — it is half a question, and
103
+ // this is the other half. A null created_by (every task filed before core_227,
104
+ // and every system vector) matches nobody, by construction.
105
+ const compare = typeof sameOwner === 'function' ? sameOwner : strictSameOwner;
106
+ if (!compare(createdBy, builderId)) {
107
+ return {
108
+ ok: false,
109
+ reason: 'not_task_creator',
110
+ message: `you did not create this task — retiring someone else's task needs ${ABANDON_ANY_KEY} (floor: metic)`,
111
+ };
112
+ }
113
+
114
+ if (!CREATOR_ABANDONABLE.includes(String(status))) {
115
+ return {
116
+ ok: false,
117
+ reason: 'creator_status_not_abandonable',
118
+ message: `this task is '${status}' — a creator can retire their own task only while it is ${CREATOR_ABANDONABLE.join(', ')}; past that it needs ${ABANDON_ANY_KEY} (floor: metic)`,
119
+ };
120
+ }
121
+
122
+ return { ok: true, via: ABANDON_OWN_KEY };
123
+ }
124
+
125
+ module.exports = {
126
+ CREATOR_ABANDONABLE,
127
+ ABANDON_ANY_KEY,
128
+ ABANDON_OWN_KEY,
129
+ mayAttemptAbandon,
130
+ abandonVerdict,
131
+ strictSameOwner,
132
+ };
@@ -168,6 +168,68 @@ const CASCADES = Object.freeze([
168
168
  }),
169
169
  ]);
170
170
 
171
+ // THE TRIGGERS. A cascade reacts to a state transition; there are two, and this
172
+ // table is what lets one runner serve both without either pretending to be the
173
+ // other. `subject` names what the rule's when/title/body receive, and the pair is
174
+ // what the generated task's preamble says happened. Adding a third trigger is a
175
+ // row here plus a rule list — deliberately not a condition language.
176
+ const TRIGGERS = Object.freeze({
177
+ ship: Object.freeze({ subject: 'task', happened: (s) => `task ${s.id} shipped` }),
178
+ idea: Object.freeze({ subject: 'idea', happened: (s) => `idea ${s.id} was graded Full` }),
179
+ });
180
+
181
+ // Cascades triggered by an IDEA reaching grade 'full' (the ideator's finished
182
+ // article), rather than by a ship. Kept as its own list rather than a `trigger`
183
+ // field on one list, so a rule cannot be evaluated against a subject shape it was
184
+ // never written for — an idea has no touches[], a task has no completeness score.
185
+ const IDEA_CASCADES = Object.freeze([
186
+ Object.freeze({
187
+ key: 'engineer-scoping-on-full-idea',
188
+ discipline: 'engineer',
189
+ kind: 'unclassified',
190
+ // A Full Idea is the ideator's hand-off point: the five questions are
191
+ // answered and the thing is described well enough to cost. What it still
192
+ // lacks is an engineer's read on what building it would actually mean, and
193
+ // that read is the scoping task this files.
194
+ //
195
+ // The anti-recursion guard is FIRST, as in the ship rule. An idea that a
196
+ // cascade itself filed cannot file another.
197
+ when: (idea) => !!idea
198
+ && idea.grade === 'full'
199
+ && idea.source !== CASCADE_SOURCE
200
+ && String(idea.status || 'open') === 'open',
201
+ title: (idea) => `Scope the Full Idea: ${String(idea.title || '').trim() || `idea ${idea.id}`}`,
202
+ body: fullIdeaScopingBody,
203
+ }),
204
+ ]);
205
+
206
+ // The body of the engineer's scoping request. Says what the ideator already
207
+ // settled so the engineer does not re-ask it, and what the scoping is FOR.
208
+ function fullIdeaScopingBody(idea) {
209
+ const score = idea.completeness_score != null ? ` (completeness ${idea.completeness_score})` : '';
210
+ const author = idea.captured_by ? ` by builder ${idea.captured_by}` : '';
211
+ // The idea's goal HINT, surfaced for a triager rather than acted on. It is
212
+ // advisory by its column's own definition, so it may inform a human decision and
213
+ // may not silently place this task.
214
+ const hint = idea.suggested_goal_id != null
215
+ ? `\nThe idea carried a routing hint — it may belong to goal ${idea.suggested_goal_id}. That is advisory; place this task wherever triage decides.\n`
216
+ : '';
217
+ return [
218
+ `Idea ${idea.id} reached **Full**${score}${author} — the ideator's five questions are answered, so the WHAT is settled.`,
219
+ hint,
220
+ '',
221
+ 'What this asks for is the engineer\'s read the idea cannot give itself:',
222
+ '',
223
+ '- What would building it actually touch, and roughly what size is it?',
224
+ '- What already exists that it should sit on rather than duplicate?',
225
+ '- What is genuinely unknown, and what would cheaply settle it?',
226
+ '',
227
+ 'Answer by turning it into real tasks, or by saying plainly why it should not be built — a scoping that concludes "not worth it, because X" is a complete answer and a useful one.',
228
+ '',
229
+ `The idea's own body is the brief; read it at the hall rather than re-deriving it here.`,
230
+ ].join('\n');
231
+ }
232
+
171
233
  // The (source, source_ref) pair the generated task is keyed on. `source` is a
172
234
  // constant so every cascade-generated row is findable as a class; the ref pins
173
235
  // it to one rule and one originating ship.
@@ -218,7 +280,7 @@ function validateCascades(rules = CASCADES) {
218
280
  // A rule whose `when` THROWS is treated as no-match and reported in `errors`,
219
281
  // never propagated: one broken predicate must not cost the other rules their
220
282
  // tasks, and it certainly must not reach the ship.
221
- function cascadesFor(task, rules = CASCADES, ctx = {}) {
283
+ function cascadesFor(task, rules = CASCADES, ctx = {}, trigger = TRIGGERS.ship) {
222
284
  const matched = [];
223
285
  const errors = [];
224
286
  if (!task || task.id == null) return { matched, errors: ['no task'] };
@@ -234,7 +296,7 @@ function cascadesFor(task, rules = CASCADES, ctx = {}) {
234
296
  }
235
297
  if (!hit) continue;
236
298
  try {
237
- matched.push({ rule, spec: cascadeTaskSpec(rule, task) });
299
+ matched.push({ rule, spec: cascadeTaskSpec(rule, task, trigger) });
238
300
  } catch (e) {
239
301
  errors.push(`cascade "${rule.key}": title/body threw (${e && e.message})`);
240
302
  }
@@ -244,17 +306,26 @@ function cascadesFor(task, rules = CASCADES, ctx = {}) {
244
306
 
245
307
  // The createTask payload for one matched rule. The attribution preamble is
246
308
  // prepended here rather than left to each rule, so no rule can forget it.
247
- function cascadeTaskSpec(rule, task) {
309
+ function cascadeTaskSpec(rule, task, trigger = TRIGGERS.ship) {
248
310
  const body = String(rule.body(task) || '').trim();
249
311
  const preamble =
250
- `Filed automatically when task ${task.id} shipped — the "${rule.key}" cascade `
312
+ `Filed automatically when ${trigger.happened(task)} — the "${rule.key}" cascade `
251
313
  + `(modules/lifecycle/cascade.js). Nobody typed this; it is a REQUEST, filed at backlog, `
252
314
  + `and it goes through the board's ordinary triage. If the rule that made it is wrong, fix `
253
315
  + `the rule rather than only this task.`;
254
316
  return {
255
- versionId: task.version_id,
317
+ // An idea carries a GUESS where a task carries a fact. `suggested_version` is
318
+ // inherited because a version is a label; `suggested_goal_id` is NOT, because
319
+ // its own column header forbids exactly that read — see goalId below.
320
+ versionId: task.version_id != null ? task.version_id : (task.suggested_version ?? null),
256
321
  // A cascade INHERITS its parent's goal — the follow-up belongs wherever the
257
322
  // work that triggered it belongs.
323
+ // NOT inherited from an idea's `suggested_goal_id`. That column documents
324
+ // itself as "Strictly advisory: no rank check, no scope wall, and no payout
325
+ // may read it... The BINDING home for 'which goal' is tasks.goal_id" — and a
326
+ // generated task's goal is binding. So an idea-triggered cascade lands in the
327
+ // catch-all and gets triaged like any other homeless work; the hint travels in
328
+ // the body instead, where a human can act on it.
258
329
  goalId: task.goal_id != null ? Number(task.goal_id) : null,
259
330
  // BV1.R11 (task 1003598): …and when the parent has none, says so. A shipped
260
331
  // task can still be goal-less — the 53 legacy catch-all rows, or any task
@@ -283,6 +354,59 @@ function cascadeTaskSpec(rule, task) {
283
354
  // The runner — the only part that touches a database
284
355
  // ---------------------------------------------------------------------------
285
356
 
357
+ // FILE ONE SUBJECT'S MATCHES. Shared by both runners so the idempotency read,
358
+ // the per-rule isolation and the reporting cannot drift apart between triggers —
359
+ // the two runners differ only in how they FETCH a subject and which rule list and
360
+ // context they bring.
361
+ async function fileMatches({ matched, deps, report, trigger, subject }) {
362
+ for (const { rule, spec } of matched) {
363
+ try {
364
+ const existing = deps.findTaskBySourceRef
365
+ ? await deps.findTaskBySourceRef({ source: CASCADE_SOURCE, sourceRef: spec.sourceRef })
366
+ : null;
367
+ if (existing) { report.skipped.push({ key: rule.key, taskId: existing.id, reason: 'already filed' }); continue; }
368
+ const created = await deps.createTask(spec);
369
+ report.created.push({ key: rule.key, taskId: created && created.id, discipline: rule.discipline });
370
+ deps.log?.info?.(`[cascade] ${trigger.happened(subject)} → filed ${created && created.id} for ${rule.discipline} ("${rule.key}")`);
371
+ } catch (e) {
372
+ // One rule failing costs that rule its task and nothing else.
373
+ report.errors.push(`cascade "${rule.key}": ${e && e.message}`);
374
+ }
375
+ }
376
+ }
377
+
378
+ // React to one idea reaching grade 'full'. Same guarantees as the ship runner —
379
+ // returns a report, never throws, never rejects — because an ideator grading
380
+ // their own idea must not be able to fail on a follow-up task's account.
381
+ //
382
+ // deps.getIdea(id) -> the full idea row (the event payload may
383
+ // carry only the id)
384
+ // deps.findTaskBySourceRef({...}) -> an existing row or null
385
+ // deps.createTask(spec) -> the created row
386
+ // deps.log -> optional { info, error }
387
+ async function runCascadesForIdea(graded, deps = {}) {
388
+ const report = { created: [], skipped: [], errors: [] };
389
+ try {
390
+ const id = graded && (graded.id ?? graded.idea_id);
391
+ if (id == null) { report.errors.push('cascade: idea payload carried no idea id'); return report; }
392
+
393
+ // The emitter sends the whole idea (it owns the row; this module does not),
394
+ // so the payload IS the subject. deps.getIdea stays supported for a caller
395
+ // that would rather re-read — and when one is injected and returns nothing,
396
+ // that is an error rather than a silent fall-back to a payload it rejected.
397
+ const idea = deps.getIdea ? await deps.getIdea(id) : graded;
398
+ if (!idea) { report.errors.push(`cascade: idea ${id} not readable after grading`); return report; }
399
+
400
+ const { matched, errors } = cascadesFor(idea, deps.rules || IDEA_CASCADES, {}, TRIGGERS.idea);
401
+ report.errors.push(...errors);
402
+ await fileMatches({ matched, deps, report, trigger: TRIGGERS.idea, subject: idea });
403
+ } catch (e) {
404
+ report.errors.push(`cascade: ${e && e.message}`);
405
+ }
406
+ if (report.errors.length) deps.log?.error?.(`[cascade] ${report.errors.length} problem(s): ${report.errors.join(' | ')}`);
407
+ return report;
408
+ }
409
+
286
410
  // React to one shipped task. Returns a report, NEVER throws and never rejects:
287
411
  // property 1 in the header. `deps` is injected so the whole runner is testable
288
412
  // with two stubs and no database.
@@ -323,20 +447,7 @@ async function runCascadesForShip(shipped, deps = {}) {
323
447
  const { matched, errors } = cascadesFor(task, deps.rules || CASCADES, ctx);
324
448
  report.errors.push(...errors);
325
449
 
326
- for (const { rule, spec } of matched) {
327
- try {
328
- const existing = deps.findTaskBySourceRef
329
- ? await deps.findTaskBySourceRef({ source: CASCADE_SOURCE, sourceRef: spec.sourceRef })
330
- : null;
331
- if (existing) { report.skipped.push({ key: rule.key, taskId: existing.id, reason: 'already filed' }); continue; }
332
- const created = await deps.createTask(spec);
333
- report.created.push({ key: rule.key, taskId: created && created.id, discipline: rule.discipline });
334
- log?.info?.(`[cascade] task ${id} shipped → filed ${created && created.id} for ${rule.discipline} ("${rule.key}")`);
335
- } catch (e) {
336
- // One rule failing costs that rule its task and nothing else.
337
- report.errors.push(`cascade "${rule.key}": ${e && e.message}`);
338
- }
339
- }
450
+ await fileMatches({ matched, deps: { ...deps, log }, report, trigger: TRIGGERS.ship, subject: task });
340
451
  } catch (e) {
341
452
  report.errors.push(`cascade: ${e && e.message}`);
342
453
  }
@@ -346,6 +457,10 @@ async function runCascadesForShip(shipped, deps = {}) {
346
457
 
347
458
  module.exports = {
348
459
  CASCADES,
460
+ IDEA_CASCADES,
461
+ TRIGGERS,
462
+ fullIdeaScopingBody,
463
+ runCascadesForIdea,
349
464
  CASCADE_SOURCE,
350
465
  COPY_VISUAL_MODULES,
351
466
  hasCopyOrVisualWork,
@@ -953,9 +953,39 @@ async function confirmTask({ taskId, builderId }, deps = {}) {
953
953
  // still owns the work; release it first so the abandon
954
954
  // can never race a ship in flight.
955
955
  // Any other status (backlog/ready/blocked/completed/confirmed) abandons. The
956
- // route is metic+archon-gated (ADR 0152; archon-only until then), and the abandon
957
- // + its reason are captured in audit_log by the writing middleware, so the
958
- // append-only ledger records who retired what. restoreTask (below) is the undo.
956
+ // route carries the authority gate (ADR 0152, and the creator widening of task
957
+ // 1002443), and the abandon + its reason are captured in audit_log by the writing
958
+ // middleware, so the append-only ledger records who retired what. restoreTask
959
+ // (below) is the undo.
960
+ //
961
+ // `authorize` (task 1002443) — the OPTIONAL row-scoped veto, and the reason this
962
+ // function grew a parameter rather than the route growing a pre-flight read.
963
+ //
964
+ // The owner's 2026-07-25 request widened this transition from "a Metic may retire
965
+ // any task" to "...or a builder may retire a task THEY created". That second half
966
+ // is a predicate over a COLUMN OF THIS ROW (tasks.created_by), so a check made
967
+ // before the transaction would be a TOCTOU window: the row can be re-attributed,
968
+ // claimed, or shipped between the read and the write. The callback therefore runs
969
+ // INSIDE the transaction, against the row this function has already locked FOR
970
+ // UPDATE, and its verdict and the UPDATE commit or roll back together.
971
+ //
972
+ // It is deliberately PURE and DB-FREE. The authority question splits in two (the
973
+ // ADR 0151 ownership axis): the RANK fact — does this builder hold the key — is
974
+ // request-scoped and is resolved by the caller BEFORE the transaction opens; only
975
+ // the OWNER fact needs the lock. Resolving permissions in here would acquire a
976
+ // second pool connection while holding a row lock, which under a saturated pool is
977
+ // a deadlock. So the caller closes over its already-resolved grants and hands in a
978
+ // predicate; this function supplies the row.
979
+ //
980
+ // Contract: called with { taskId, createdBy, status }, returns { ok: true } or
981
+ // { ok: false, reason, message }. FAIL-CLOSED — anything that is not an explicit
982
+ // { ok: true } is a refusal, so a caller that returns undefined (or throws) denies
983
+ // rather than admits. Omitting `authorize` entirely preserves the pre-1002443
984
+ // behaviour for every caller whose route already gated the transition.
985
+ //
986
+ // It runs BEFORE the already-abandoned idempotent return, deliberately: a caller
987
+ // with no authority over this row must not be able to read its state back as a
988
+ // 200, and an unauthorized retry is a refusal, not a no-op.
959
989
  //
960
990
  // NOTE on dependencies: abandoning a task that is listed in another task's
961
991
  // dependencies[] does NOT auto-adjust those dependents — they will simply never
@@ -967,14 +997,16 @@ async function confirmTask({ taskId, builderId }, deps = {}) {
967
997
  // so a regression test can drive the txn with a SQL-aware fakeClient, same as
968
998
  // confirmTask / decideOverrideRequest. Production callers omit deps → module pool.
969
999
 
970
- async function abandonTask({ taskId, reason }, deps = {}) {
1000
+ async function abandonTask({ taskId, reason, authorize = null }, deps = {}) {
971
1001
  const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
972
1002
  const client = await activePool.connect();
973
1003
  try {
974
1004
  await client.query('BEGIN');
975
1005
 
1006
+ // created_by joins the locked SELECT (task 1002443) so the ownership predicate
1007
+ // below reads the SAME row version the UPDATE will write.
976
1008
  const { rows: taskRows } = await client.query(
977
- `SELECT id, title, status FROM tasks WHERE id = $1 FOR UPDATE`,
1009
+ `SELECT id, title, status, created_by FROM tasks WHERE id = $1 FOR UPDATE`,
978
1010
  [taskId]
979
1011
  );
980
1012
  if (taskRows.length === 0) {
@@ -982,6 +1014,28 @@ async function abandonTask({ taskId, reason }, deps = {}) {
982
1014
  }
983
1015
  const task = taskRows[0];
984
1016
 
1017
+ // The row-scoped veto, under the lock and ahead of every status branch below —
1018
+ // see the header for why both of those orderings are load-bearing. Fail-closed:
1019
+ // only an explicit { ok: true } admits.
1020
+ if (typeof authorize === 'function') {
1021
+ let verdict;
1022
+ try {
1023
+ verdict = await authorize({ taskId, createdBy: task.created_by, status: task.status });
1024
+ } catch (authErr) {
1025
+ throw Object.assign(new Error('abandon authorization could not be established'), {
1026
+ code: 'ABANDON_FORBIDDEN',
1027
+ reason: 'authorize_threw',
1028
+ cause: authErr,
1029
+ });
1030
+ }
1031
+ if (!verdict || verdict.ok !== true) {
1032
+ throw Object.assign(new Error((verdict && verdict.message) || 'not authorized to abandon this task'), {
1033
+ code: 'ABANDON_FORBIDDEN',
1034
+ reason: (verdict && verdict.reason) || 'not_authorized',
1035
+ });
1036
+ }
1037
+ }
1038
+
985
1039
  if (task.status === 'abandoned') {
986
1040
  await client.query('COMMIT');
987
1041
  return { taskId, taskTitle: task.title, taskStatus: 'abandoned', previousStatus: 'abandoned', alreadyAbandoned: true };