@bongos/core 1.19.921 → 1.19.923
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +64 -24
- package/docs/adr/0321-brightness-is-a-weighted-share-and-the-targets-are-a-row.md +163 -0
- package/docs/adr/0322-a-remnant-needs-evidence-and-merged-wins.md +110 -0
- package/docs/adr/README.md +2 -0
- package/docs/module-api-changelog.md +4 -0
- package/modules/economy/idea-brightness.js +212 -0
- package/modules/economy/idea-size-historical.js +22 -3
- package/modules/economy/idea-size.js +30 -3
- package/modules/economy/reward.js +11 -0
- package/modules/ideas/idea-state.js +254 -0
- package/modules/ideas/routes/inbox.js +8 -0
- package/modules/ideas/trail.js +13 -1
- package/modules/lifecycle/cascade.js +36 -0
- package/modules/lifecycle/migrations/lifecycle_010_removal_targets.sql +116 -0
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/removal-targets.js +217 -0
- package/modules/lifecycle/routes/lifecycle.js +9 -0
- package/modules/lifecycle/routes/removal-proposals.js +33 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/provision-core-upgrade.js +26 -2
- package/src/module-api.js +1 -1
- package/tests/core_upgrade_runner.mjs +56 -0
- package/tests/idea_brightness.mjs +550 -0
- package/tests/idea_state.mjs +399 -0
package/modules/ideas/trail.js
CHANGED
|
@@ -42,6 +42,16 @@ const { skillFrictionHiddenClause } = require('./skill-friction');
|
|
|
42
42
|
// first. Both are returned: the lineage array is the complete answer, the legacy
|
|
43
43
|
// column is the only answer for promotions that predate the lineage table. The
|
|
44
44
|
// LATERAL is bounded by the page window, so this stays one query, not an N+1.
|
|
45
|
+
//
|
|
46
|
+
// `goal_id` RIDES ALONG ON BOTH TASK JOINS (task 1004103 / BV2.R17) because it
|
|
47
|
+
// is what tells a protostar in a WELL from one in OPEN SPACE — the distinction
|
|
48
|
+
// the design makes position carry, and the reason the nod queue is a thing a
|
|
49
|
+
// person can see rather than a list someone must remember to open
|
|
50
|
+
// (docs/design/ideator-surface-direction.md). It is the BINDING home for "which
|
|
51
|
+
// goal"; `idea_inbox.suggested_goal_id`, already on the row, is a HINT about a
|
|
52
|
+
// filing that did not convert and is deliberately not what ./idea-state.js
|
|
53
|
+
// reads. Two nullable columns on joins the query already makes, so this costs
|
|
54
|
+
// no round trip.
|
|
45
55
|
const IDEA_TRAIL_DEFAULT_LIMIT = 20;
|
|
46
56
|
const IDEA_TRAIL_MAX_LIMIT = 100;
|
|
47
57
|
|
|
@@ -68,6 +78,7 @@ async function listIdeaTrailForBuilder(builderId, { limit = IDEA_TRAIL_DEFAULT_L
|
|
|
68
78
|
`SELECT ${IDEA_COLS_Q}, ${GRADE_COLS_Q},
|
|
69
79
|
pt.title AS promoted_task_title,
|
|
70
80
|
pt.status AS promoted_task_status,
|
|
81
|
+
pt.goal_id AS promoted_task_goal_id,
|
|
71
82
|
mi.title AS merged_into_idea_title,
|
|
72
83
|
mi.status AS merged_into_idea_status,
|
|
73
84
|
COALESCE(lin.tasks, '[]'::json) AS lineage_tasks
|
|
@@ -78,7 +89,8 @@ async function listIdeaTrailForBuilder(builderId, { limit = IDEA_TRAIL_DEFAULT_L
|
|
|
78
89
|
LEFT JOIN LATERAL (
|
|
79
90
|
SELECT json_agg(json_build_object(
|
|
80
91
|
'task_id', l.task_id, 'origin', l.origin,
|
|
81
|
-
'title', lt.title, 'status', lt.status
|
|
92
|
+
'title', lt.title, 'status', lt.status,
|
|
93
|
+
'goal_id', lt.goal_id
|
|
82
94
|
) ORDER BY l.task_id) AS tasks
|
|
83
95
|
FROM ideas_task_lineage l
|
|
84
96
|
JOIN tasks lt ON lt.id = l.task_id
|
|
@@ -565,6 +565,42 @@ async function runCascadesForShip(shipped, deps = {}) {
|
|
|
565
565
|
report.errors.push(...errors);
|
|
566
566
|
|
|
567
567
|
await fileMatches({ matched, deps: { ...deps, log }, report, trigger: TRIGGERS.ship, subject: task });
|
|
568
|
+
|
|
569
|
+
// WHOSE WORK THE PROPOSAL IS ABOUT (task 1004099 / BV2.R16 brightness).
|
|
570
|
+
//
|
|
571
|
+
// The proposal's body renders `ctx.removals` as prose for a person; this
|
|
572
|
+
// stores the same objects as rows, because brightness needs the attribution
|
|
573
|
+
// structured and removal-proposals.js is right to refuse to parse a body
|
|
574
|
+
// back. One derivation, two renderings — they cannot disagree.
|
|
575
|
+
//
|
|
576
|
+
// WRITTEN HERE AND NOT AT VERDICT TIME because `ctx.removals` only exists
|
|
577
|
+
// while the cascade runs: it came from capped, rate-limited GitHub
|
|
578
|
+
// commit-history reads, and re-deriving it when a person clicks confirm
|
|
579
|
+
// would spend that budget again against a history that has moved.
|
|
580
|
+
//
|
|
581
|
+
// ON THE SKIPPED ROW TOO, not only the created one. A re-ship finds the
|
|
582
|
+
// proposal already filed and reports it as `skipped`, and that is exactly
|
|
583
|
+
// the run that must be able to fill in an attribution the first run missed
|
|
584
|
+
// (a derivation outage files the question with no targets). The writer is
|
|
585
|
+
// idempotent, so doing it on every run costs a no-op and buys the repair.
|
|
586
|
+
//
|
|
587
|
+
// NOT WIDENING THE RUNNER (ADR 0242): the removal rule still files exactly
|
|
588
|
+
// one task. This records a side fact about that one task, the same shape as
|
|
589
|
+
// the verdict row its answer writes.
|
|
590
|
+
if (typeof deps.recordRemovalTargets === 'function' && Array.isArray(ctx.removals) && ctx.removals.length) {
|
|
591
|
+
const filed = [...report.created, ...report.skipped].find((r) => r.key === REMOVAL_RULE_KEY);
|
|
592
|
+
if (filed && filed.taskId != null) {
|
|
593
|
+
try {
|
|
594
|
+
await deps.recordRemovalTargets({ proposalTaskId: filed.taskId, removals: ctx.removals });
|
|
595
|
+
} catch (e) {
|
|
596
|
+
// Reported, never thrown. The ship is landed, graded and paid before
|
|
597
|
+
// any of this runs; an attribution that cannot be stored leaves the
|
|
598
|
+
// proposal answerable and brightness unable to attribute it, which
|
|
599
|
+
// countUnattributedConfirmed() is there to make visible.
|
|
600
|
+
report.errors.push(`cascade: recordRemovalTargets() threw (${e && e.message}) — proposal ${filed.taskId} has no stored targets`);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
568
604
|
} catch (e) {
|
|
569
605
|
report.errors.push(`cascade: ${e && e.message}`);
|
|
570
606
|
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
-- lifecycle_010_removal_targets.sql — WHOSE WORK a removal proposal is about
|
|
2
|
+
-- (task 1004099 / BV2.R16 brightness, goal 1000095; ADR 0321, under ADR 0313 +
|
|
3
|
+
-- ADR 0319).
|
|
4
|
+
--
|
|
5
|
+
-- THE GAP THIS FILLS, IN ONE SENTENCE. lifecycle_009 records the VERDICT on a
|
|
6
|
+
-- removal proposal and the SHIP that did the deleting, and says in as many words
|
|
7
|
+
-- that the list of tasks whose work was removed is "deliberately not here" —
|
|
8
|
+
-- "brightness designs its own storage for targets when it needs one". This is
|
|
9
|
+
-- brightness needing one.
|
|
10
|
+
--
|
|
11
|
+
-- WHY THE VERDICT ROW IS NOT ENOUGH. `lifecycle_removal_verdicts.ship_task_id` is
|
|
12
|
+
-- the task whose merge deleted the files. Brightness is the live share of an
|
|
13
|
+
-- IDEA'S DESCENT, so what it has to know is the opposite direction: which
|
|
14
|
+
-- previously-shipped tasks stopped counting. The ship that deleted them is almost
|
|
15
|
+
-- never in the descent it dims — a later, unrelated piece of work tore out an
|
|
16
|
+
-- earlier one. Reading `ship_task_id` as the removed task would dim the wrong
|
|
17
|
+
-- star, and would dim it for the ship's own idea rather than for the idea whose
|
|
18
|
+
-- work actually died.
|
|
19
|
+
--
|
|
20
|
+
-- WHY NOT PARSE THE PROPOSAL BODY. The attribution already exists in prose:
|
|
21
|
+
-- `cascade.js:removalProposalBody` writes ` - task <id> — <title>` under a
|
|
22
|
+
-- "Removed work attributed to:" heading, from the derivation's structured output.
|
|
23
|
+
-- removal-proposals.js refuses to read it and is right to — "a body is written for
|
|
24
|
+
-- a person to read, and a reader that silently depends on its line shape breaks
|
|
25
|
+
-- the day somebody improves the wording". That refusal only holds if something
|
|
26
|
+
-- else carries the fact, which is this table. The derivation produces
|
|
27
|
+
-- `{ taskId, title, paths }` objects (removal-derivation.js); the body renders
|
|
28
|
+
-- them and this stores them, so the prose and the rows have ONE source and cannot
|
|
29
|
+
-- disagree.
|
|
30
|
+
--
|
|
31
|
+
-- WRITTEN AT PROPOSAL TIME, NOT AT VERDICT TIME. The attribution is a fact about
|
|
32
|
+
-- the diff, and the diff is only in hand while the cascade is running — it comes
|
|
33
|
+
-- from GitHub commit-history reads that are capped at eight paths per ship and run
|
|
34
|
+
-- on the server's shared push credential (removal-derivation.js
|
|
35
|
+
-- MAX_ATTRIBUTED_PATHS). Re-deriving it when a person clicks confirm, days later,
|
|
36
|
+
-- would spend that budget again against a history that has moved on. So the rows
|
|
37
|
+
-- land with the question and the verdict arrives beside them later.
|
|
38
|
+
--
|
|
39
|
+
-- THIS IS NOT THE CASCADE RUNNER FANNING OUT. ADR 0242 forbids widening the runner
|
|
40
|
+
-- to file more than one task per rule per ship, and that is untouched: the removal
|
|
41
|
+
-- rule still files exactly one proposal. This records a side fact ABOUT that one
|
|
42
|
+
-- task, which is the same shape as the verdict row the answer writes.
|
|
43
|
+
--
|
|
44
|
+
-- A PROPOSAL WITH NO ROWS IS A REAL STATE, not a bug, and brightness must read it
|
|
45
|
+
-- as "cannot attribute" rather than "nothing was removed". Three ways it happens:
|
|
46
|
+
-- a proposal filed before this migration; an attribution that found nothing
|
|
47
|
+
-- because every deletion predates `claims.published_head_sha`; a derivation
|
|
48
|
+
-- outage. In all three the honest answer is that the targets are unknown, and
|
|
49
|
+
-- idea-brightness.js treats an unattributed confirmed removal as exactly that —
|
|
50
|
+
-- it dims nothing and says the count out loud, rather than guessing in either
|
|
51
|
+
-- direction.
|
|
52
|
+
--
|
|
53
|
+
-- MODULE-OWNED and `lifecycle_`-prefixed so fitness check 11 permits it (ADR 0083
|
|
54
|
+
-- §Decision #5) — the lifecycle_004..009 convention. It only REFERENCES the core
|
|
55
|
+
-- `tasks` table; a module migration may not ALTER one.
|
|
56
|
+
--
|
|
57
|
+
-- Module migrations are ADDITIVE and NAMESPACED: no down-migration, idempotent
|
|
58
|
+
-- (safe to re-run).
|
|
59
|
+
|
|
60
|
+
BEGIN;
|
|
61
|
+
|
|
62
|
+
CREATE TABLE IF NOT EXISTS lifecycle_removal_targets (
|
|
63
|
+
-- The PROPOSAL task — the question these targets belong to. The join key to
|
|
64
|
+
-- lifecycle_removal_verdicts, whose primary key is the same column, which is
|
|
65
|
+
-- what makes "confirmed removals and what they removed" one join and not a
|
|
66
|
+
-- correlated lookup.
|
|
67
|
+
proposal_task_id bigint NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
|
68
|
+
|
|
69
|
+
-- The task whose shipped work the deletion was attributed to. THIS is what
|
|
70
|
+
-- brightness intersects with a descent.
|
|
71
|
+
removed_task_id bigint NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
|
72
|
+
|
|
73
|
+
-- The deleted paths attributed to that task, as the derivation found them.
|
|
74
|
+
-- Stored because the proposal's prose is not a record: once a person confirms,
|
|
75
|
+
-- "what exactly went" is the only evidence of what the verdict covered, and a
|
|
76
|
+
-- later reader asking why a star dimmed has nothing else to look at. An empty
|
|
77
|
+
-- array is legal — the attribution is the load-bearing half, the paths are the
|
|
78
|
+
-- receipt.
|
|
79
|
+
paths text[] NOT NULL DEFAULT '{}',
|
|
80
|
+
|
|
81
|
+
-- One row per (proposal, removed task). The derivation already groups its paths
|
|
82
|
+
-- by task, so a second row for the same pair would be a double count of the
|
|
83
|
+
-- same removal — and since the cascade is idempotent (a re-ship re-files
|
|
84
|
+
-- nothing and re-runs this writer), the constraint is what makes re-running
|
|
85
|
+
-- safe rather than additive.
|
|
86
|
+
PRIMARY KEY (proposal_task_id, removed_task_id)
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
-- Brightness reads in the removed_task_id direction: "of these descent tasks,
|
|
90
|
+
-- which are named by a confirmed removal". The primary key leads with
|
|
91
|
+
-- proposal_task_id and cannot serve that, so the reverse index is the one the hot
|
|
92
|
+
-- query needs.
|
|
93
|
+
CREATE INDEX IF NOT EXISTS idx_lifecycle_removal_targets_removed
|
|
94
|
+
ON lifecycle_removal_targets (removed_task_id);
|
|
95
|
+
|
|
96
|
+
-- SAFETY NET — a migration that cannot prove its own effect must not report
|
|
97
|
+
-- success (the lifecycle_007 / ideas_007 / lifecycle_009 precedent). Read-only,
|
|
98
|
+
-- no DDL. If the composite primary key came back wrong, one proposal could carry
|
|
99
|
+
-- the same removed task twice and brightness would count that work as dying
|
|
100
|
+
-- twice — dimming a star further than the removal warrants.
|
|
101
|
+
DO $$
|
|
102
|
+
DECLARE pk_cols text;
|
|
103
|
+
BEGIN
|
|
104
|
+
SELECT string_agg(a.attname, ',' ORDER BY a.attname) INTO pk_cols
|
|
105
|
+
FROM pg_constraint c
|
|
106
|
+
JOIN unnest(c.conkey) AS k(attnum) ON true
|
|
107
|
+
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum
|
|
108
|
+
WHERE c.conrelid = 'lifecycle_removal_targets'::regclass
|
|
109
|
+
AND c.contype = 'p';
|
|
110
|
+
IF pk_cols IS DISTINCT FROM 'proposal_task_id,removed_task_id' THEN
|
|
111
|
+
RAISE EXCEPTION
|
|
112
|
+
'lifecycle_010: lifecycle_removal_targets primary key is (%) — expected (proposal_task_id, removed_task_id), so one proposal names one removed task once.', COALESCE(pk_cols, 'none');
|
|
113
|
+
END IF;
|
|
114
|
+
END $$;
|
|
115
|
+
|
|
116
|
+
COMMIT;
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/lifecycle/removal-targets.js — WHOSE WORK a removal proposal is about
|
|
4
|
+
// (task 1004099 / BV2.R16 brightness, goal 1000095; ADR 0321, under ADR 0313 +
|
|
5
|
+
// ADR 0319).
|
|
6
|
+
//
|
|
7
|
+
// THE ONE FACT THIS FILE CARRIES. lifecycle_009 stores the VERDICT on a removal
|
|
8
|
+
// proposal and the SHIP that deleted the files, and says outright that the list
|
|
9
|
+
// of tasks whose work was removed is "deliberately not here — brightness designs
|
|
10
|
+
// its own storage for targets when it needs one". Brightness needs one, because
|
|
11
|
+
// the direction it reads is the opposite of the one the verdict row holds: it
|
|
12
|
+
// asks "which previously-shipped tasks stopped counting", and `ship_task_id`
|
|
13
|
+
// answers "which ship did the deleting". Those are different tasks, usually under
|
|
14
|
+
// different ideas, and confusing them dims the wrong star.
|
|
15
|
+
//
|
|
16
|
+
// THE PROSE IS NOT THE RECORD. `cascade.js:removalProposalBody` already renders
|
|
17
|
+
// the attribution for a person to read. removal-proposals.js refuses to parse it
|
|
18
|
+
// back — "a body is written for a person to read, and a reader that silently
|
|
19
|
+
// depends on its line shape breaks the day somebody improves the wording" — and
|
|
20
|
+
// that refusal is only safe because of this table. Both the prose and these rows
|
|
21
|
+
// are rendered from the SAME derivation output (`{ taskId, title, paths }` off
|
|
22
|
+
// removal-derivation.js), so there is one source and they cannot disagree.
|
|
23
|
+
//
|
|
24
|
+
// WRITTEN WITH THE QUESTION, READ AFTER THE ANSWER. The attribution is only
|
|
25
|
+
// available while the cascade runs: it comes from GitHub commit-history reads,
|
|
26
|
+
// capped at eight paths per ship, on the server's shared push credential. Storing
|
|
27
|
+
// it at proposal time and joining the verdict later is what keeps `confirm` a
|
|
28
|
+
// pure database write instead of a second trip to GitHub against a history that
|
|
29
|
+
// has since moved.
|
|
30
|
+
//
|
|
31
|
+
// MODULE BOUNDARY: reaches core only through the published doorway
|
|
32
|
+
// `../../src/module-api`. Nothing here imports another module; the economy module
|
|
33
|
+
// reads it through the `lifecycle.removals` port, never by require (ADR 0083
|
|
34
|
+
// rule 1).
|
|
35
|
+
|
|
36
|
+
const api = require('../../src/module-api');
|
|
37
|
+
|
|
38
|
+
const { pool } = api;
|
|
39
|
+
|
|
40
|
+
// A positive integer id as it arrives off a row, a caller or a derivation —
|
|
41
|
+
// where it may be a bigint serialised to a string. Anything else is dropped
|
|
42
|
+
// rather than sent to the database, and never coerced with Number(), which
|
|
43
|
+
// loses bigint precision. The same guard idea-size.js uses, for the same reason.
|
|
44
|
+
function idOf(v) {
|
|
45
|
+
return /^[1-9]\d*$/.test(String(v)) ? String(v) : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Paths as the derivation hands them: an array of non-empty strings. Anything
|
|
49
|
+
// else becomes an empty array rather than a null, because the column is NOT NULL
|
|
50
|
+
// and because the paths are the receipt, not the load-bearing half — a malformed
|
|
51
|
+
// path list must not cost us the attribution it came with.
|
|
52
|
+
function pathsOf(v) {
|
|
53
|
+
if (!Array.isArray(v)) return [];
|
|
54
|
+
return v.filter((p) => typeof p === 'string' && p.length > 0).map(String);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// recordRemovalTargets — store the attribution for ONE proposal.
|
|
58
|
+
//
|
|
59
|
+
// proposalTaskId the task the removal cascade filed
|
|
60
|
+
// removals the derivation's output: [{ taskId, title, paths }]
|
|
61
|
+
//
|
|
62
|
+
// Returns the number of rows written (0 when there was nothing to attribute).
|
|
63
|
+
//
|
|
64
|
+
// IDEMPOTENT, because its caller is. The cascade re-runs on a re-ship and on a
|
|
65
|
+
// reconciler sweep, and `createTask`'s (source, source_ref) idempotency hands
|
|
66
|
+
// back the ORIGINAL proposal row rather than minting a second one — so this
|
|
67
|
+
// writer is called again with the same proposal id. ON CONFLICT DO NOTHING makes
|
|
68
|
+
// that a no-op instead of a duplicate-key throw that would surface as a cascade
|
|
69
|
+
// error on every re-ship.
|
|
70
|
+
//
|
|
71
|
+
// DO NOTHING, NOT DO UPDATE, and that is a decision rather than a shortcut. A
|
|
72
|
+
// second derivation of the same ship can legitimately find FEWER paths — the
|
|
73
|
+
// commit-history walk is capped and rate-limited, and an exhausted page yields
|
|
74
|
+
// no attribution at all. Overwriting would let a degraded second read erase a
|
|
75
|
+
// good first one. The first attribution is the one taken while the diff was
|
|
76
|
+
// freshest; it stands.
|
|
77
|
+
//
|
|
78
|
+
// A `title` in the derivation output is deliberately NOT stored: it is the
|
|
79
|
+
// task's own title, which lives on `tasks` and can be joined for. Copying it
|
|
80
|
+
// here would be a second copy that goes stale the first time a title is edited.
|
|
81
|
+
//
|
|
82
|
+
// NEVER THROWS INTO THE SHIP. The cascade runner catches everything and turns it
|
|
83
|
+
// into a report, and this is called from inside that guard — but the ship has
|
|
84
|
+
// already landed, been graded and been paid before any of this runs, so an
|
|
85
|
+
// attribution that cannot be stored must cost the ship nothing. The caller
|
|
86
|
+
// reports; it does not fail.
|
|
87
|
+
async function recordRemovalTargets({ proposalTaskId, removals } = {}, { client = null } = {}) {
|
|
88
|
+
const proposal = idOf(proposalTaskId);
|
|
89
|
+
if (!proposal) return 0;
|
|
90
|
+
|
|
91
|
+
// De-duplicated on the way in. The derivation groups paths by task, so a
|
|
92
|
+
// repeat is not expected — but the primary key would reject the whole
|
|
93
|
+
// statement rather than the offending row, and a doubled pair inside ONE
|
|
94
|
+
// insert is not something ON CONFLICT rescues.
|
|
95
|
+
const byTask = new Map();
|
|
96
|
+
for (const r of Array.isArray(removals) ? removals : []) {
|
|
97
|
+
const taskId = idOf(r && r.taskId);
|
|
98
|
+
if (!taskId) continue;
|
|
99
|
+
if (byTask.has(taskId)) {
|
|
100
|
+
// Merge rather than drop: two entries for one task means the derivation
|
|
101
|
+
// split its paths, and losing half of them loses half the receipt.
|
|
102
|
+
const merged = new Set([...byTask.get(taskId), ...pathsOf(r.paths)]);
|
|
103
|
+
byTask.set(taskId, [...merged]);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
byTask.set(taskId, pathsOf(r && r.paths));
|
|
107
|
+
}
|
|
108
|
+
if (byTask.size === 0) return 0;
|
|
109
|
+
|
|
110
|
+
const q = client || pool;
|
|
111
|
+
const payload = [...byTask.entries()].map(([taskId, paths]) => ({ taskId, paths }));
|
|
112
|
+
|
|
113
|
+
// ONE jsonb parameter, not two parallel arrays — and the reason is a trap
|
|
114
|
+
// worth naming. The obvious shape is `unnest($2::bigint[])` joined by
|
|
115
|
+
// ORDINALITY to `unnest($3::text[][])`, and it is wrong: a Postgres
|
|
116
|
+
// multidimensional array is rectangular and `unnest` FLATTENS it completely,
|
|
117
|
+
// so a task with two paths and a task with one do not even produce the same
|
|
118
|
+
// number of rows as their ids, and the ordinality join silently pairs the
|
|
119
|
+
// wrong path with the wrong task. jsonb keeps each task's paths nested where
|
|
120
|
+
// they belong, and it costs the same single round trip.
|
|
121
|
+
const { rowCount } = await q.query(
|
|
122
|
+
`INSERT INTO lifecycle_removal_targets (proposal_task_id, removed_task_id, paths)
|
|
123
|
+
SELECT $1::bigint,
|
|
124
|
+
(e->>'taskId')::bigint,
|
|
125
|
+
COALESCE(ARRAY(SELECT jsonb_array_elements_text(e->'paths')), '{}')
|
|
126
|
+
FROM jsonb_array_elements($2::jsonb) AS e
|
|
127
|
+
ON CONFLICT (proposal_task_id, removed_task_id) DO NOTHING`,
|
|
128
|
+
[proposal, JSON.stringify(payload)]
|
|
129
|
+
);
|
|
130
|
+
return rowCount || 0;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// confirmedRemovalsForTasks — of these tasks, which are named by a CONFIRMED
|
|
134
|
+
// removal, and by which proposal.
|
|
135
|
+
//
|
|
136
|
+
// This is the read brightness is built on. Returns
|
|
137
|
+
// Map<removedTaskIdString, { proposalTaskId, shipTaskId, paths, decidedAt }>;
|
|
138
|
+
// a task absent from the map is alive. Only 'confirmed' rows join — a dismissed
|
|
139
|
+
// proposal is a refactor or a move and must dim nothing, which is the entire
|
|
140
|
+
// reason the verdict is a person's answer rather than a derived signal
|
|
141
|
+
// (ADR 0313).
|
|
142
|
+
//
|
|
143
|
+
// ONE QUERY FOR THE WHOLE SET, not one per task: this runs behind a page of
|
|
144
|
+
// ideas, each with a descent, and a per-task lookup would be a round trip per
|
|
145
|
+
// node of every descent on screen.
|
|
146
|
+
//
|
|
147
|
+
// A task named by SEVERAL confirmed proposals collapses to the EARLIEST verdict.
|
|
148
|
+
// Work can be deleted, re-added and deleted again, and the question brightness
|
|
149
|
+
// asks is binary — is this alive — so the count of times it died is not the
|
|
150
|
+
// fact. The earliest is kept because it is the one whose paths describe the
|
|
151
|
+
// original removal rather than a later re-removal of something restored in
|
|
152
|
+
// between, and because a stable pick makes the read deterministic.
|
|
153
|
+
async function confirmedRemovalsForTasks(taskIds, { client = null } = {}) {
|
|
154
|
+
const ids = [...new Set((Array.isArray(taskIds) ? taskIds : []).map(idOf).filter(Boolean))];
|
|
155
|
+
const out = new Map();
|
|
156
|
+
if (ids.length === 0) return out;
|
|
157
|
+
|
|
158
|
+
const q = client || pool;
|
|
159
|
+
const { rows } = await q.query(
|
|
160
|
+
`SELECT tg.removed_task_id,
|
|
161
|
+
tg.proposal_task_id,
|
|
162
|
+
tg.paths,
|
|
163
|
+
v.ship_task_id,
|
|
164
|
+
v.decided_at
|
|
165
|
+
FROM lifecycle_removal_targets tg
|
|
166
|
+
JOIN lifecycle_removal_verdicts v ON v.proposal_task_id = tg.proposal_task_id
|
|
167
|
+
WHERE v.verdict = 'confirmed'
|
|
168
|
+
AND tg.removed_task_id = ANY($1::bigint[])
|
|
169
|
+
ORDER BY tg.removed_task_id, v.decided_at ASC, tg.proposal_task_id ASC`,
|
|
170
|
+
[ids]
|
|
171
|
+
);
|
|
172
|
+
for (const row of rows) {
|
|
173
|
+
const key = idOf(row.removed_task_id);
|
|
174
|
+
if (!key || out.has(key)) continue; // ORDER BY put the earliest first
|
|
175
|
+
out.set(key, {
|
|
176
|
+
proposalTaskId: String(row.proposal_task_id),
|
|
177
|
+
shipTaskId: row.ship_task_id == null ? null : String(row.ship_task_id),
|
|
178
|
+
paths: Array.isArray(row.paths) ? row.paths : [],
|
|
179
|
+
decidedAt: row.decided_at || null,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// countUnattributedConfirmed — how many CONFIRMED removals name no target at all.
|
|
186
|
+
//
|
|
187
|
+
// The number nothing else can tell you, and the reason it is exported rather
|
|
188
|
+
// than left implicit. A confirmed removal with no target rows is invisible to
|
|
189
|
+
// brightness by construction: the work is genuinely gone, and no star dims for
|
|
190
|
+
// it. That happens for proposals filed before lifecycle_010, for attributions
|
|
191
|
+
// where every deletion predates `claims.published_head_sha`, and for a
|
|
192
|
+
// derivation outage.
|
|
193
|
+
//
|
|
194
|
+
// Brightness cannot fix that — it does not know which idea to dim, and guessing
|
|
195
|
+
// is worse than not dimming. What it can do is refuse to let the gap be silent.
|
|
196
|
+
// A surface over the sky (BV2.R17/R18) can say "the sky may read brighter than
|
|
197
|
+
// the truth by up to N confirmed removals", which is an honest caveat, where a
|
|
198
|
+
// silently-optimistic picture is the quiet lie the spec names.
|
|
199
|
+
async function countUnattributedConfirmed({ client = null } = {}) {
|
|
200
|
+
const q = client || pool;
|
|
201
|
+
const { rows } = await q.query(
|
|
202
|
+
`SELECT COUNT(*)::int AS n
|
|
203
|
+
FROM lifecycle_removal_verdicts v
|
|
204
|
+
WHERE v.verdict = 'confirmed'
|
|
205
|
+
AND NOT EXISTS (
|
|
206
|
+
SELECT 1 FROM lifecycle_removal_targets tg
|
|
207
|
+
WHERE tg.proposal_task_id = v.proposal_task_id
|
|
208
|
+
)`
|
|
209
|
+
);
|
|
210
|
+
return (rows[0] && rows[0].n) || 0;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
module.exports = {
|
|
214
|
+
recordRemovalTargets,
|
|
215
|
+
confirmedRemovalsForTasks,
|
|
216
|
+
countUnattributedConfirmed,
|
|
217
|
+
};
|
|
@@ -29,6 +29,7 @@ const lifecycle = require('../lifecycle');
|
|
|
29
29
|
const db = require('../db');
|
|
30
30
|
const cascade = require('../cascade');
|
|
31
31
|
const removalDerivation = require('../removal-derivation');
|
|
32
|
+
const removalTargets = require('../removal-targets');
|
|
32
33
|
|
|
33
34
|
// task 1003549 — the cross-role cascade listener. `task.shipped` already exists
|
|
34
35
|
// as a kernel event (emitted post-commit by the publish reconciler and the ship
|
|
@@ -102,6 +103,14 @@ function registerSeams() {
|
|
|
102
103
|
removalCandidates: (task) => removalDerivation.removalCandidatesForTask(task, {
|
|
103
104
|
shippedTasksByPublishedSha: (shas) => db.shippedTasksByPublishedSha(shas),
|
|
104
105
|
}),
|
|
106
|
+
// WHOSE WORK IT WAS, stored (task 1004099 / BV2.R16 brightness). The
|
|
107
|
+
// derivation above already computed the attribution and the proposal body
|
|
108
|
+
// renders it as prose; this persists the same objects so brightness can
|
|
109
|
+
// read them structured. Passed as the NAMED function rather than wrapped in
|
|
110
|
+
// a closure, for the reason the note above gives: a closure here is not
|
|
111
|
+
// reachable from a test, and that is how the `number`/`pr_number` bug lived
|
|
112
|
+
// in production with a green suite.
|
|
113
|
+
recordRemovalTargets: removalTargets.recordRemovalTargets,
|
|
105
114
|
log: api.logger ? api.logger('lifecycle') : null,
|
|
106
115
|
}));
|
|
107
116
|
}
|
|
@@ -32,9 +32,34 @@
|
|
|
32
32
|
const express = require('express');
|
|
33
33
|
const api = require('../../../src/module-api');
|
|
34
34
|
const proposals = require('../removal-proposals');
|
|
35
|
+
const removalTargets = require('../removal-targets');
|
|
35
36
|
|
|
36
37
|
const log = api.logger('lifecycle');
|
|
37
38
|
|
|
39
|
+
// THE `lifecycle.removals` PORT (task 1004099 / BV2.R16 brightness).
|
|
40
|
+
//
|
|
41
|
+
// Brightness is the live share of an idea's descent, and it lives in the ECONOMY
|
|
42
|
+
// module beside size — because it is a share of the same payouts, and putting it
|
|
43
|
+
// in lifecycle would mean economy asking lifecycle for money arithmetic. That
|
|
44
|
+
// makes "which of these tasks were removed" a cross-module read, and the one-way
|
|
45
|
+
// rule (ADR 0083 rule 1) says it crosses through a kernel-mediated port, never a
|
|
46
|
+
// require. This is the same shape as `ideas.descent`, which economy already
|
|
47
|
+
// resolves for the descent walk itself.
|
|
48
|
+
//
|
|
49
|
+
// REGISTERED HERE, in a route file, because that is where a module's ports are
|
|
50
|
+
// registered (the `ideas.capture` / `lifecycle.kickoffSeed` precedent) — the
|
|
51
|
+
// registration runs when the module mounts, so nothing resolves a port from a
|
|
52
|
+
// module that is switched off.
|
|
53
|
+
//
|
|
54
|
+
// RESOLVED OPTIONALLY on the far side. Economy degrades to "cannot attribute"
|
|
55
|
+
// when lifecycle is absent rather than failing the read, so this is not declared
|
|
56
|
+
// in economy's `consumes` — the same treatment `ideas.descent` gets, and the
|
|
57
|
+
// reason `consumes` there is still `[]`.
|
|
58
|
+
const removalsPort = Object.freeze({
|
|
59
|
+
confirmedRemovalsForTasks: removalTargets.confirmedRemovalsForTasks,
|
|
60
|
+
countUnattributedConfirmed: removalTargets.countUnattributedConfirmed,
|
|
61
|
+
});
|
|
62
|
+
|
|
38
63
|
// The floor: `task.promote` is Metic+ and is the atom the nod already carries,
|
|
39
64
|
// which is what makes 'the same floor as the nod' a fact about the gate rather
|
|
40
65
|
// than a claim in a comment.
|
|
@@ -48,6 +73,14 @@ const log = api.logger('lifecycle');
|
|
|
48
73
|
module.exports = function buildRemovalProposalsRouter() {
|
|
49
74
|
const router = express.Router();
|
|
50
75
|
|
|
76
|
+
// Guarded, because the router factory runs again every time a test rebuilds
|
|
77
|
+
// it and registerProvider THROWS on a duplicate — a port is single-provider by
|
|
78
|
+
// design. The `lifecycle` port's own registration in routes/lifecycle.js uses
|
|
79
|
+
// the identical guard for the identical reason.
|
|
80
|
+
if (!api.hasProvider('lifecycle.removals')) {
|
|
81
|
+
api.registerProvider('lifecycle.removals', removalsPort);
|
|
82
|
+
}
|
|
83
|
+
|
|
51
84
|
// GET /removal-proposals — the removal questions nobody has answered yet.
|
|
52
85
|
//
|
|
53
86
|
// rank: metic+archon (task.promote). No client-side rank gate is needed or wanted in the
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.923",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.923",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.923",
|
|
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",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
|
|
31
31
|
const path = require('path');
|
|
32
32
|
const { CONFIG, MANIFEST_VERIFY_INTERVAL_MS, MANIFEST_VERIFY_TRIES, REPO_ROOT } = require('./provision-config.js');
|
|
33
|
-
const { standaloneRoot } = require('./provision-repo.js');
|
|
33
|
+
const { APP_USER, dbName, instanceUser, standaloneRoot } = require('./provision-repo.js');
|
|
34
34
|
const { isValidSlug } = require('../../modules/provisioning/provisioning.js');
|
|
35
35
|
|
|
36
36
|
// The shapes whose box this runner can actually reach. Mirrors UPGRADE_SHAPES in the
|
|
@@ -186,7 +186,31 @@ async function coreUpgradeInstance(inst, deps, intent) {
|
|
|
186
186
|
// the silent thirteen-release revert upgrade.js's own header records.
|
|
187
187
|
const healthUrl = `http://127.0.0.1:${inst.port}/healthz`;
|
|
188
188
|
const versionUrl = `http://127.0.0.1:${inst.port}/version`;
|
|
189
|
-
|
|
189
|
+
// WHO THE UPGRADE RUNS AS, AND WHICH DATABASE IT NAMES (task 1004119).
|
|
190
|
+
//
|
|
191
|
+
// A bare `sudo` runs as ROOT, and both halves of the upgrade resolve against the
|
|
192
|
+
// CURRENT OS USER rather than a flag:
|
|
193
|
+
//
|
|
194
|
+
// • migrate.sh peer-auths over the local socket as whoever invoked it, and `root`
|
|
195
|
+
// is almost never a Postgres role — pre-flight refuses with "Postgres has no
|
|
196
|
+
// role root" before the pin moves (correctly; the pin must not move for a
|
|
197
|
+
// migrate that cannot run).
|
|
198
|
+
// • with no PGDATABASE, resolveInstanceDb() has to FIND the database by loading
|
|
199
|
+
// node_modules/@bongos/core/src/bongos/pool from the instance root. An adopted
|
|
200
|
+
// repo that installs its workspaces rather than its root has no core there, so
|
|
201
|
+
// the name cannot be resolved at all.
|
|
202
|
+
//
|
|
203
|
+
// Both were live on 2026-09-20: four presses of the owner's Upgrade button, four
|
|
204
|
+
// pre-flight refusals, no instance ever moved. The helpers to fix it already sat in
|
|
205
|
+
// provision-repo.js — this leg simply never used them.
|
|
206
|
+
//
|
|
207
|
+
// The account is the instance's own (`bongos-<slug>`), which is ALSO the name of its
|
|
208
|
+
// Postgres login role (instanceDbRole), so peer auth lands on a role that owns
|
|
209
|
+
// exactly that instance's database. The control plane is the exception: it is not a
|
|
210
|
+
// provisioned tenant and has no `bongos-` account — it runs as APP_USER, the operator
|
|
211
|
+
// the recipe's gate-3 invocation names.
|
|
212
|
+
const runAs = inst.hosting_shape === 'control-plane' ? APP_USER : instanceUser(inst);
|
|
213
|
+
const sudoP = privileged ? `sudo -u ${runAs} env PGDATABASE=${dbName(inst)} ` : '';
|
|
190
214
|
// WHERE THE CLI LIVES AND WHERE THE TARGET LIVES ARE DIFFERENT PLACES (task 1004053).
|
|
191
215
|
//
|
|
192
216
|
// `scripts/gds/upgrade.js` ships in the CORE checkout. A provisioned instance root holds
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.923'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -415,5 +415,61 @@ await t('the snapshot NEVER fails a tick — a registry hiccup leaves the old re
|
|
|
415
415
|
assert.equal(r.skipped, true);
|
|
416
416
|
});
|
|
417
417
|
|
|
418
|
+
console.log('\nWHO the upgrade runs as, and WHICH database it names (task 1004119):');
|
|
419
|
+
|
|
420
|
+
// The bug: `sudo ` with no -u, and no PGDATABASE. Both halves of upgrade.js resolve
|
|
421
|
+
// against the CURRENT OS USER rather than a flag, so root got a "Postgres has no role
|
|
422
|
+
// root" refusal and an adopted repo could not resolve its database name at all. Four
|
|
423
|
+
// live presses on 2026-09-20, four pre-flight refusals, nothing ever upgraded.
|
|
424
|
+
//
|
|
425
|
+
// Asserting on the ASSEMBLED command rather than on the helpers, because the helpers
|
|
426
|
+
// were already correct and already exported — the defect was that this leg did not
|
|
427
|
+
// call them.
|
|
428
|
+
|
|
429
|
+
const upgradeCall = (d) => (d.ranWith.find((c) => /upgrade\.js/.test(c.cmd)) || {}).cmd || '';
|
|
430
|
+
|
|
431
|
+
await t('the control plane upgrades as the operator account, never as root', async () => {
|
|
432
|
+
const d = { ...makeDeps({ served: '1.19.762' }), privileged: true };
|
|
433
|
+
await R.coreUpgradeInstance(inst({ hosting_shape: 'control-plane', slug: 'cloudbongos', port: 3002 }), d, intent());
|
|
434
|
+
const cmd = upgradeCall(d);
|
|
435
|
+
assert.match(cmd, /sudo -u lars /, 'runs as the operator account');
|
|
436
|
+
assert.doesNotMatch(cmd, /sudo node /, 'a bare `sudo node` is the bug — it lands on root');
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
await t('a tenant upgrades as its OWN account, which is also its Postgres login role', async () => {
|
|
440
|
+
const d = { ...makeDeps({ served: '1.19.762' }), privileged: true };
|
|
441
|
+
await R.coreUpgradeInstance(inst({ hosting_shape: 'standalone', slug: 'test4', port: 3006 }), d, intent());
|
|
442
|
+
const cmd = upgradeCall(d);
|
|
443
|
+
assert.match(cmd, /sudo -u bongos-test4 /, 'the instance account, not the operator and not root');
|
|
444
|
+
assert.doesNotMatch(cmd, /sudo -u lars /, 'one tenant must never be upgraded under another account');
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
await t('the database is NAMED, not discovered — an adopted repo has no core at its root to ask', async () => {
|
|
448
|
+
// hermeslines-marketing installs its workspaces rather than its root, so
|
|
449
|
+
// resolveInstanceDb() could not load node_modules/@bongos/core/.../pool and the
|
|
450
|
+
// upgrade died with "cannot determine which database to migrate".
|
|
451
|
+
const d = { ...makeDeps({ served: '1.19.762' }), privileged: true };
|
|
452
|
+
await R.coreUpgradeInstance(
|
|
453
|
+
inst({ hosting_shape: 'standalone', slug: 'hermeslines-marketing', db_name: 'hermeslines-marketing', port: 3011 }), d, intent());
|
|
454
|
+
assert.match(upgradeCall(d), /env PGDATABASE=hermeslines-marketing /, 'the runner passes the name the control plane already holds');
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
await t('the database name comes from the ROW, falling back to the slug', async () => {
|
|
458
|
+
const d = { ...makeDeps({ served: '1.19.762' }), privileged: true };
|
|
459
|
+
await R.coreUpgradeInstance(inst({ hosting_shape: 'control-plane', slug: 'cloudbongos', db_name: null, port: 3002 }), d, intent());
|
|
460
|
+
assert.match(upgradeCall(d), /env PGDATABASE=cloudbongos /, 'a row with no db_name uses its slug');
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
await t('an UNPRIVILEGED runner still adds no sudo and no env at all', async () => {
|
|
464
|
+
// The self-hosted / dev shape: already the right user, nothing to escalate. Guarding
|
|
465
|
+
// it so the fix above cannot quietly start requiring sudo where there is none.
|
|
466
|
+
const d = makeDeps({ served: '1.19.762' });
|
|
467
|
+
d.privileged = false;
|
|
468
|
+
await R.coreUpgradeInstance(inst({ hosting_shape: 'standalone', slug: 'test4', port: 3006 }), d, intent());
|
|
469
|
+
const cmd = upgradeCall(d);
|
|
470
|
+
assert.doesNotMatch(cmd, /sudo/, 'no escalation when the runner is not privileged');
|
|
471
|
+
assert.match(cmd, /^node \S*scripts\/gds\/upgrade\.js /, 'it still invokes the CLI directly');
|
|
472
|
+
});
|
|
473
|
+
|
|
418
474
|
console.log(`\ncore_upgrade_runner: ${passed} passed, ${failed} failed`);
|
|
419
475
|
if (failed) process.exit(1);
|