@bongos/core 1.19.560 → 1.19.562
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 +25 -15
- package/docs/module-api-changelog.md +4 -0
- package/modules/lifecycle/db-goals.js +30 -0
- package/modules/lifecycle/db.js +2 -0
- package/modules/lifecycle/routes/goal-route-authz.js +58 -0
- package/modules/lifecycle/routes/goals.js +30 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/goal_archive_disposition.mjs +144 -0
- package/tests/goal_create_version_gate.mjs +136 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.562",
|
|
6
|
+
"core_contract": "1.19.562",
|
|
7
|
+
"source_commit": "8812128543b799aea8050fb1d51256bbdd0fc717",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-06T17:
|
|
9
|
+
"built_at": "2026-09-06T17:33:48.313Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 443,
|
|
13
13
|
"agent_docs_stubbed": 24,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2025,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2492,
|
|
20
|
+
"tree_sha256": "dc363435f4898d5c74509b25e810a61c1f3a4962b038c4f1c0c1527167e48217",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -2657,7 +2657,7 @@
|
|
|
2657
2657
|
{
|
|
2658
2658
|
"path": "docs/module-api-changelog.md",
|
|
2659
2659
|
"mode": "0000644",
|
|
2660
|
-
"sha256": "
|
|
2660
|
+
"sha256": "9c9e6c53ef623de12cdca7cd96dfc2221c19843f45d3a84a196862f4e195c908"
|
|
2661
2661
|
},
|
|
2662
2662
|
{
|
|
2663
2663
|
"path": "docs/modules-contract.md",
|
|
@@ -5432,7 +5432,7 @@
|
|
|
5432
5432
|
{
|
|
5433
5433
|
"path": "modules/lifecycle/db-goals.js",
|
|
5434
5434
|
"mode": "0000644",
|
|
5435
|
-
"sha256": "
|
|
5435
|
+
"sha256": "16c5f2ed9ae4b70a30eb47a4864c4ad6273afbe19e925edcac5f9d965e670f24"
|
|
5436
5436
|
},
|
|
5437
5437
|
{
|
|
5438
5438
|
"path": "modules/lifecycle/db-grade.js",
|
|
@@ -5472,7 +5472,7 @@
|
|
|
5472
5472
|
{
|
|
5473
5473
|
"path": "modules/lifecycle/db.js",
|
|
5474
5474
|
"mode": "0000644",
|
|
5475
|
-
"sha256": "
|
|
5475
|
+
"sha256": "e7f65fbc43a0963ab6a7e6d25e0b52521f46b82f05115ffef80ea64d9e9fac81"
|
|
5476
5476
|
},
|
|
5477
5477
|
{
|
|
5478
5478
|
"path": "modules/lifecycle/dead-deps.js",
|
|
@@ -5647,12 +5647,12 @@
|
|
|
5647
5647
|
{
|
|
5648
5648
|
"path": "modules/lifecycle/routes/goal-route-authz.js",
|
|
5649
5649
|
"mode": "0000644",
|
|
5650
|
-
"sha256": "
|
|
5650
|
+
"sha256": "c380ad664c18beff53dd50e9bd5ad9ba78c05c18fae182cc0b0cc8ea41ee1a75"
|
|
5651
5651
|
},
|
|
5652
5652
|
{
|
|
5653
5653
|
"path": "modules/lifecycle/routes/goals.js",
|
|
5654
5654
|
"mode": "0000644",
|
|
5655
|
-
"sha256": "
|
|
5655
|
+
"sha256": "9d3079d7f3f610aa5893f02fb4858f73ec98718d6096c423cd52c15d2b30d1ec"
|
|
5656
5656
|
},
|
|
5657
5657
|
{
|
|
5658
5658
|
"path": "modules/lifecycle/routes/help-requests.js",
|
|
@@ -7477,12 +7477,12 @@
|
|
|
7477
7477
|
{
|
|
7478
7478
|
"path": "package-lock.json",
|
|
7479
7479
|
"mode": "0000644",
|
|
7480
|
-
"sha256": "
|
|
7480
|
+
"sha256": "c9fb4551642e3a6cdd1db0e7795805314e2a62b413eb96d2b6ee981146d666c3"
|
|
7481
7481
|
},
|
|
7482
7482
|
{
|
|
7483
7483
|
"path": "package.json",
|
|
7484
7484
|
"mode": "0000644",
|
|
7485
|
-
"sha256": "
|
|
7485
|
+
"sha256": "fe6556a8d666ce01124d6666fd07d7ad01920fe596ca3718398fb4bbcc885465"
|
|
7486
7486
|
},
|
|
7487
7487
|
{
|
|
7488
7488
|
"path": "public-docs/index.html",
|
|
@@ -9182,7 +9182,7 @@
|
|
|
9182
9182
|
{
|
|
9183
9183
|
"path": "src/module-api.js",
|
|
9184
9184
|
"mode": "0000644",
|
|
9185
|
-
"sha256": "
|
|
9185
|
+
"sha256": "c08cf2ff080e74afdd1c88cb06f4960909b4984bc803f6eeddab57abe14fed79"
|
|
9186
9186
|
},
|
|
9187
9187
|
{
|
|
9188
9188
|
"path": "src/module-loader/catalog.js",
|
|
@@ -10264,6 +10264,11 @@
|
|
|
10264
10264
|
"mode": "0000644",
|
|
10265
10265
|
"sha256": "b8c179ae6c5ad962356e5d82a5c6aa89bd523913de10799e53de16279404d07d"
|
|
10266
10266
|
},
|
|
10267
|
+
{
|
|
10268
|
+
"path": "tests/goal_archive_disposition.mjs",
|
|
10269
|
+
"mode": "0000644",
|
|
10270
|
+
"sha256": "d2056a3ae84b79d64409e6e43c477f6b160f202903be61710b40bc4fd06c00c9"
|
|
10271
|
+
},
|
|
10267
10272
|
{
|
|
10268
10273
|
"path": "tests/goal_category.mjs",
|
|
10269
10274
|
"mode": "0000644",
|
|
@@ -10289,6 +10294,11 @@
|
|
|
10289
10294
|
"mode": "0000644",
|
|
10290
10295
|
"sha256": "1dd92e13f21ed2b029c64d07dcdd6c47aa4f59ce25e288786c4fa7245ba707c5"
|
|
10291
10296
|
},
|
|
10297
|
+
{
|
|
10298
|
+
"path": "tests/goal_create_version_gate.mjs",
|
|
10299
|
+
"mode": "0000644",
|
|
10300
|
+
"sha256": "addd9fe7179236d24c316923fd47a60947c1a34f9e01eb1a4d47faa49b4fa18f"
|
|
10301
|
+
},
|
|
10292
10302
|
{
|
|
10293
10303
|
"path": "tests/goal_criterion_confirm_ui.mjs",
|
|
10294
10304
|
"mode": "0000644",
|
|
@@ -1569,5 +1569,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1569
1569
|
landed since 1.19.558 with no explicit bump. run 34047187381. (task 1002620)
|
|
1570
1570
|
1.19.560 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1571
1571
|
landed since 1.19.559 with no explicit bump. run 34047736326. (task 1002620)
|
|
1572
|
+
1.19.561 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1573
|
+
landed since 1.19.560 with no explicit bump. run 34048169727. (task 1002620)
|
|
1574
|
+
1.19.562 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1575
|
+
landed since 1.19.561 with no explicit bump. run 34048961786. (task 1002620)
|
|
1572
1576
|
---------------------------------------------------------------------------
|
|
1573
1577
|
```
|
|
@@ -324,6 +324,35 @@ async function listGoalMembers(goalId, deps = {}) {
|
|
|
324
324
|
// "linked criteria" half of GET /goals/:id; projection matches done-when.js's
|
|
325
325
|
// listCriteria so the hall renders goal + version criteria identically.
|
|
326
326
|
|
|
327
|
+
// openTasksInGoal — the work a goal is still holding, newest-blocking-first, for
|
|
328
|
+
// the archive preview (BV1.R10, task 1003597, goal 1000086, ADR 0250 D3).
|
|
329
|
+
//
|
|
330
|
+
// Archiving used to be a single call that checked only whether the goal was
|
|
331
|
+
// ALREADY archived — no criterion check and no task check — which is how goal
|
|
332
|
+
// 1000018 was archived with all seven of its tasks still open. It is the hole
|
|
333
|
+
// that bypasses the R08/R09 closure rule entirely: the rule guards `achieved`,
|
|
334
|
+
// and archive is the other way out.
|
|
335
|
+
//
|
|
336
|
+
// "Still holding" is the same question the closure rule asks — non-terminal per
|
|
337
|
+
// task-status.js, smoke fixtures excluded — so a goal cannot be refused for work
|
|
338
|
+
// that would not have held it open on the achieve path. `cap` bounds the returned
|
|
339
|
+
// rows (the caller renders them); `total` is the true count, so a goal holding
|
|
340
|
+
// forty tasks reports forty and shows the first few.
|
|
341
|
+
async function openTasksInGoal(goalId, { cap = 50 } = {}, deps = {}) {
|
|
342
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
343
|
+
const { rows } = await activePool.query(
|
|
344
|
+
`SELECT t.id::text, t.title, t.status,
|
|
345
|
+
count(*) OVER ()::int AS total
|
|
346
|
+
FROM tasks t
|
|
347
|
+
WHERE t.goal_id = $1
|
|
348
|
+
AND ${nonTerminalSql()}
|
|
349
|
+
AND ${SMOKE_NOT_LIKE.replace(/title/, 't.title')}
|
|
350
|
+
ORDER BY t.id
|
|
351
|
+
LIMIT $2`,
|
|
352
|
+
[goalId, cap]
|
|
353
|
+
);
|
|
354
|
+
return { total: rows[0]?.total ?? 0, tasks: rows.map(({ total, ...t }) => t) };
|
|
355
|
+
}
|
|
327
356
|
async function listCriteriaForGoal(goalId, deps = {}) {
|
|
328
357
|
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
329
358
|
// task 1002164: carry the linked-task counts so the caller can derive the
|
|
@@ -784,6 +813,7 @@ module.exports = {
|
|
|
784
813
|
isGoalMember,
|
|
785
814
|
listActiveClaimsForTasks,
|
|
786
815
|
listCriteriaForGoal,
|
|
816
|
+
openTasksInGoal,
|
|
787
817
|
listGoalMembers,
|
|
788
818
|
listGoals,
|
|
789
819
|
listInboxFor,
|
package/modules/lifecycle/db.js
CHANGED
|
@@ -78,6 +78,7 @@ const {
|
|
|
78
78
|
isGoalMember,
|
|
79
79
|
listActiveClaimsForTasks,
|
|
80
80
|
listCriteriaForGoal,
|
|
81
|
+
openTasksInGoal,
|
|
81
82
|
listGoalMembers,
|
|
82
83
|
listGoals,
|
|
83
84
|
listInboxFor,
|
|
@@ -243,6 +244,7 @@ module.exports = {
|
|
|
243
244
|
listInboxFor,
|
|
244
245
|
listPendingRequestsForGoal,
|
|
245
246
|
listCriteriaForGoal,
|
|
247
|
+
openTasksInGoal,
|
|
246
248
|
createTask,
|
|
247
249
|
generalGoalIdForVersion,
|
|
248
250
|
findTaskBySourceRef,
|
|
@@ -15,6 +15,7 @@ const db = require('../db');
|
|
|
15
15
|
const { isModuleKey, scopeIncludesProtected, moduleKeys } = api.moduleScopeMap;
|
|
16
16
|
const goalScopeCheck = api.goalScopeCheck;
|
|
17
17
|
const { isGoalOwner, authorizeGoalManagement } = require('../goal-authz');
|
|
18
|
+
const { versionAcceptsNewWork } = require('../db-tasks.js');
|
|
18
19
|
const METIC_PLUS = ['metic', 'archon'];
|
|
19
20
|
|
|
20
21
|
async function isMemberOrArchon(builder, goalId) {
|
|
@@ -65,6 +66,62 @@ function goalNotOpenRefusal(goal) {
|
|
|
65
66
|
};
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
// authorizeGoalCreate — PURE, FAIL-CLOSED. May a goal be created on this version?
|
|
70
|
+
// (BV1.R03, task 1003591, goal 1000086, ADR 0250 D1.)
|
|
71
|
+
//
|
|
72
|
+
// THE RULE: a version's goal set closes when the version starts BUILDING. New
|
|
73
|
+
// goals go on the `planning` version — that is what a planning version is for,
|
|
74
|
+
// scoping the next one in advance and at leisure. `POST /goals` used to fetch the
|
|
75
|
+
// version row and discard it after the null check, so a goal could be added to a
|
|
76
|
+
// version that had been building for months, which is how a version's scope grew
|
|
77
|
+
// without anyone deciding to widen it.
|
|
78
|
+
//
|
|
79
|
+
// Three answers, ordered fail-closed (an absent or unrecognised version denies):
|
|
80
|
+
// no version → 404 version_not_found
|
|
81
|
+
// not open to work → 409 version_closed (shipped, or a status we
|
|
82
|
+
// cannot vouch for)
|
|
83
|
+
// building → 409 version_not_planning (the R07 override admits
|
|
84
|
+
// ONE named goal, with a reason)
|
|
85
|
+
// planning → ok
|
|
86
|
+
//
|
|
87
|
+
// The open-to-work question is `versionAcceptsNewWork` — the same predicate the
|
|
88
|
+
// task-create path uses — rather than a second spelling of the status list, so
|
|
89
|
+
// "a version we can still file into" has one definition. This gate is STRICTER
|
|
90
|
+
// than that predicate on purpose: work may land on a building version, but its
|
|
91
|
+
// SCOPE may not grow. Reusing the predicate for the first question and adding the
|
|
92
|
+
// planning test on top is what keeps those two facts from being confused.
|
|
93
|
+
//
|
|
94
|
+
// `override` is the R07 Archon admission (task 1003594), which is not built yet.
|
|
95
|
+
// It is a parameter here rather than a later edit to the signature so that R07
|
|
96
|
+
// wires a decision in without reopening this ordering: pass true and a building
|
|
97
|
+
// version is accepted. It does NOT bypass version_closed — a shipped version is
|
|
98
|
+
// closed history, and no override makes it writable again.
|
|
99
|
+
function authorizeGoalCreate({ version, override = false }) {
|
|
100
|
+
if (!version) {
|
|
101
|
+
return { ok: false, status: 404, body: {
|
|
102
|
+
error: 'version_not_found', message: 'No such version.',
|
|
103
|
+
} };
|
|
104
|
+
}
|
|
105
|
+
if (!versionAcceptsNewWork(version.status)) {
|
|
106
|
+
return { ok: false, status: 409, body: {
|
|
107
|
+
error: 'version_closed', status: version.status,
|
|
108
|
+
// Says which status refused rather than asserting 'shipped': the predicate
|
|
109
|
+
// is fail-closed, so it also catches null and any status added later, and
|
|
110
|
+
// telling someone their planning-adjacent version "has shipped" would be a
|
|
111
|
+
// confident lie about state they can see for themselves.
|
|
112
|
+
message: version.status === 'shipped'
|
|
113
|
+
? `Version '${version.id}' has shipped — it is closed history and cannot take new goals.`
|
|
114
|
+
: `Version '${version.id}' is '${version.status}', which is not a state that takes new goals.`,
|
|
115
|
+
} };
|
|
116
|
+
}
|
|
117
|
+
if (version.status !== 'planning' && override !== true) {
|
|
118
|
+
return { ok: false, status: 409, body: {
|
|
119
|
+
error: 'version_not_planning', status: version.status,
|
|
120
|
+
message: `Version '${version.id}' is already building — its goal set is closed. Scope this goal onto the planning version instead, or ask an Archon to admit it with a reason.`,
|
|
121
|
+
} };
|
|
122
|
+
}
|
|
123
|
+
return { ok: true };
|
|
124
|
+
}
|
|
68
125
|
function authorizeGoalTaskCreate({ goal, version, isMember, builderRank, touches }) {
|
|
69
126
|
if (!goal) {
|
|
70
127
|
return { ok: false, status: 404, body: { error: 'goal_not_found' } };
|
|
@@ -458,6 +515,7 @@ function decorateGoalScope(goal) {
|
|
|
458
515
|
|
|
459
516
|
module.exports = {
|
|
460
517
|
authorizeCreateJoinRequest,
|
|
518
|
+
authorizeGoalCreate,
|
|
461
519
|
authorizeGoalTaskCreate,
|
|
462
520
|
authorizeInvitationAccept,
|
|
463
521
|
authorizeInvitationResponse,
|
|
@@ -63,7 +63,7 @@ const JOIN_REQUEST_RESPONSE_ACTIONS = ['approve', 'deny']; // an owner/manager/A
|
|
|
63
63
|
// proceed; a false result means "not a member" → the route should 403. This is the
|
|
64
64
|
// membership half of the wall; the protected-module rank check is enforced per-route
|
|
65
65
|
// (scope is never self-granted).
|
|
66
|
-
const { authorizeCreateJoinRequest, authorizeGoalTaskCreate, authorizeInvitationAccept, authorizeInvitationResponse, authorizeJoinRequestResponse, authorizeMembershipKindChange, authorizeOwnershipTransfer, authorizeRescindInvitation, authorizeWithdrawJoinRequest, decorateGoalScope, isMemberOrArchon, protectedScopeAdmissionBlock } = require('./goal-route-authz.js');
|
|
66
|
+
const { authorizeCreateJoinRequest, authorizeGoalCreate, authorizeGoalTaskCreate, authorizeInvitationAccept, authorizeInvitationResponse, authorizeJoinRequestResponse, authorizeMembershipKindChange, authorizeOwnershipTransfer, authorizeRescindInvitation, authorizeWithdrawJoinRequest, decorateGoalScope, isMemberOrArchon, protectedScopeAdmissionBlock } = require('./goal-route-authz.js');
|
|
67
67
|
// task 1003208: structured logging (pino via the doorway) — was console.*.
|
|
68
68
|
const log = api.logger('lifecycle');
|
|
69
69
|
module.exports = function buildGoalsRouter() {
|
|
@@ -99,8 +99,13 @@ module.exports = function buildGoalsRouter() {
|
|
|
99
99
|
|
|
100
100
|
try {
|
|
101
101
|
const version = await db.getVersion(req.body.version_id);
|
|
102
|
-
|
|
103
|
-
|
|
102
|
+
// BV1.R03 (task 1003591, goal 1000086, ADR 0250 D1): the version row was
|
|
103
|
+
// fetched and then discarded after the null check — its status was never
|
|
104
|
+
// read, so a goal could be added to a version that had been building for
|
|
105
|
+
// months. The ordered, DB-free gate answers all of it, including the 404.
|
|
106
|
+
const versionDecision = authorizeGoalCreate({ version });
|
|
107
|
+
if (!versionDecision.ok) {
|
|
108
|
+
return res.status(versionDecision.status).json(versionDecision.body);
|
|
104
109
|
}
|
|
105
110
|
// Rescope carry-forward (ADR 0086 §6 / BV1.R64): a new-version goal may declare
|
|
106
111
|
// the (achieved) goal it continues. Validate the predecessor exists BEFORE
|
|
@@ -1048,6 +1053,28 @@ module.exports = function buildGoalsRouter() {
|
|
|
1048
1053
|
if (goal.status === 'archived') {
|
|
1049
1054
|
return res.fail('already_archived', 409);
|
|
1050
1055
|
}
|
|
1056
|
+
// BV1.R10 (task 1003597, goal 1000086, ADR 0250 D3): archive is the OTHER
|
|
1057
|
+
// way out of a goal, and it checked nothing — which is how goal 1000018 was
|
|
1058
|
+
// archived with all seven of its tasks still open. R08/R09 guard `achieved`;
|
|
1059
|
+
// without this, anyone refused there could archive instead and the work
|
|
1060
|
+
// would vanish from every rollup just the same.
|
|
1061
|
+
//
|
|
1062
|
+
// The refusal RETURNS the work rather than merely naming a count, because
|
|
1063
|
+
// the caller's next move is to decide what happens to each task — and an
|
|
1064
|
+
// agent that has to go fetch the list first will guess instead. Applying a
|
|
1065
|
+
// disposition (abandon with the archive's reason, or move to a named
|
|
1066
|
+
// surviving goal) is task 1003601 (R14); until then the honest answer is
|
|
1067
|
+
// "not without saying what happens to these", not a silent discard.
|
|
1068
|
+
//
|
|
1069
|
+
// A goal holding nothing open stays a single call, unchanged.
|
|
1070
|
+
const held = await db.openTasksInGoal(id);
|
|
1071
|
+
if (held.total > 0) {
|
|
1072
|
+
return res.fail('goal_holds_open_tasks', {
|
|
1073
|
+
status: 409,
|
|
1074
|
+
message: `Goal #${id} still holds ${held.total} unfinished task(s). Ship or abandon them, or move them to another goal, before archiving it.`,
|
|
1075
|
+
details: { total: held.total, shown: held.tasks.length, tasks: held.tasks },
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1051
1078
|
const updated = await db.setGoalStatus({ goalId: id, status: 'archived' });
|
|
1052
1079
|
return res.json({ ok: true, goal: updated });
|
|
1053
1080
|
} catch (err) {
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.562",
|
|
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.562",
|
|
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.562",
|
|
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",
|
package/src/module-api.js
CHANGED
|
@@ -49,7 +49,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
49
49
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
50
50
|
// the entry to that file. Look for a version's history there, not here.
|
|
51
51
|
// ---------------------------------------------------------------------------
|
|
52
|
-
const CORE_VERSION = '1.19.
|
|
52
|
+
const CORE_VERSION = '1.19.562'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
53
53
|
|
|
54
54
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
55
55
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// tests/goal_archive_disposition.mjs — archiving a goal is a recorded decision,
|
|
2
|
+
// not a silent discard (BV1.R10, task 1003597, goal 1000086, ADR 0250 D3).
|
|
3
|
+
//
|
|
4
|
+
// THE HOLE. `POST /goals/:id/archive` refused exactly one thing: a goal that was
|
|
5
|
+
// ALREADY archived. No criterion check, no task check. R08/R09 guard the flip to
|
|
6
|
+
// `achieved`, so archive was the other way out — anyone refused there could
|
|
7
|
+
// archive instead and the work disappeared from every rollup just the same. Goal
|
|
8
|
+
// 1000018 went that way with all seven of its tasks still open.
|
|
9
|
+
//
|
|
10
|
+
// "Still holding" must be the SAME question the closure rule asks — non-terminal
|
|
11
|
+
// per task-status.js, smoke fixtures excluded — or the two doors disagree about
|
|
12
|
+
// what unfinished means and a goal is refused here for work that would not have
|
|
13
|
+
// held it open there. That equivalence is what most of this file pins.
|
|
14
|
+
//
|
|
15
|
+
// Run: node --test tests/goal_archive_disposition.mjs
|
|
16
|
+
|
|
17
|
+
import assert from 'node:assert/strict';
|
|
18
|
+
import { test } from 'node:test';
|
|
19
|
+
import { readFileSync } from 'node:fs';
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
import { makeSqlAwareClient } from './helpers.mjs';
|
|
22
|
+
|
|
23
|
+
process.env.NODE_ENV = 'test';
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const { openTasksInGoal } = require('../modules/lifecycle/db.js');
|
|
26
|
+
const { TERMINAL_STATUSES } = require('../modules/lifecycle/task-status.js');
|
|
27
|
+
|
|
28
|
+
const src = (rel) => readFileSync(new URL('../' + rel, import.meta.url), 'utf8');
|
|
29
|
+
|
|
30
|
+
function fakePool(handler) {
|
|
31
|
+
const c = makeSqlAwareClient(handler);
|
|
32
|
+
return { query: (sql, params) => c._client.query(sql, params), connect: c.connect, queries: c._client.queries };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The archive handler slice, so assertions land on the right route.
|
|
36
|
+
const GOALS = src('modules/lifecycle/routes/goals.js');
|
|
37
|
+
const ARCHIVE = (() => {
|
|
38
|
+
const a = GOALS.indexOf("router.post('/goals/:id/archive'");
|
|
39
|
+
assert.notEqual(a, -1, 'archive route not found');
|
|
40
|
+
const b = GOALS.indexOf("router.post('/goals/:id/reopen'", a);
|
|
41
|
+
assert.notEqual(b, -1, 'reopen route should follow archive');
|
|
42
|
+
return GOALS.slice(a, b);
|
|
43
|
+
})();
|
|
44
|
+
|
|
45
|
+
// ---- the read ---------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
test('openTasksInGoal asks the SAME question the closure rule asks', async () => {
|
|
48
|
+
let seen = null;
|
|
49
|
+
const pool = fakePool((s) => { seen = s; return { rows: [] }; });
|
|
50
|
+
await openTasksInGoal(77, {}, { pool });
|
|
51
|
+
assert.match(seen, /FROM tasks t WHERE t\.goal_id = \$1/);
|
|
52
|
+
assert.match(seen, /t\.status NOT IN \('shipped', 'abandoned'\)/,
|
|
53
|
+
'the terminal set is task-status.js\'s, not a fourth hand-typed copy');
|
|
54
|
+
assert.match(seen, /t\.title NOT LIKE/,
|
|
55
|
+
'a smoke fixture must not be able to block an archive either');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('openTasksInGoal reports the TRUE total even when it returns a capped page', async () => {
|
|
59
|
+
// A goal holding forty tasks must say forty. Returning only the page length
|
|
60
|
+
// would understate the decision the caller is about to make.
|
|
61
|
+
const rows = [
|
|
62
|
+
{ id: '1', title: 'a', status: 'backlog', total: 40 },
|
|
63
|
+
{ id: '2', title: 'b', status: 'ready', total: 40 },
|
|
64
|
+
];
|
|
65
|
+
const pool = fakePool(() => ({ rows }));
|
|
66
|
+
const out = await openTasksInGoal(77, { cap: 2 }, { pool });
|
|
67
|
+
assert.equal(out.total, 40, 'the window count is the real total');
|
|
68
|
+
assert.equal(out.tasks.length, 2, 'and the page is what was asked for');
|
|
69
|
+
assert.deepEqual(Object.keys(out.tasks[0]), ['id', 'title', 'status'],
|
|
70
|
+
'the window column is stripped from the rows the caller renders');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('openTasksInGoal binds the cap rather than interpolating it', async () => {
|
|
74
|
+
const pool = fakePool(() => ({ rows: [] }));
|
|
75
|
+
await openTasksInGoal(77, { cap: 5 }, { pool });
|
|
76
|
+
assert.deepEqual(pool.queries[0].params, [77, 5]);
|
|
77
|
+
assert.match(String(pool.queries[0].sql), /LIMIT \$2/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('an empty goal reports nothing held — the single-call path stays open', async () => {
|
|
81
|
+
const pool = fakePool(() => ({ rows: [] }));
|
|
82
|
+
const out = await openTasksInGoal(77, {}, { pool });
|
|
83
|
+
assert.deepEqual(out, { total: 0, tasks: [] });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// ---- the route --------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
test('archive refuses with a NAMED 409 when the goal holds open work', () => {
|
|
89
|
+
assert.match(ARCHIVE, /const held = await db\.openTasksInGoal\(id\);/,
|
|
90
|
+
'the archive route must consult the work before discarding it');
|
|
91
|
+
assert.match(ARCHIVE, /res\.fail\('goal_holds_open_tasks', \{[\s\S]*?status: 409/,
|
|
92
|
+
'a named code an agent can branch on, not a bare 409');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('the refusal RETURNS the tasks, so the caller can decide without a second fetch', () => {
|
|
96
|
+
assert.match(ARCHIVE, /details: \{ total: held\.total, shown: held\.tasks\.length, tasks: held\.tasks \}/,
|
|
97
|
+
'an agent that has to go fetch the list will guess instead');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('the check runs AFTER identity/authorization and BEFORE the status write', () => {
|
|
101
|
+
const auth = ARCHIVE.indexOf('isMemberOrArchon');
|
|
102
|
+
const already = ARCHIVE.indexOf("'already_archived'");
|
|
103
|
+
const gate = ARCHIVE.indexOf('openTasksInGoal');
|
|
104
|
+
const write = ARCHIVE.indexOf('db.setGoalStatus');
|
|
105
|
+
assert.ok(auth !== -1 && already !== -1 && gate !== -1 && write !== -1, 'all four landmarks present');
|
|
106
|
+
assert.ok(auth < gate, 'a non-member must not learn what a goal is holding');
|
|
107
|
+
assert.ok(already < gate, 're-archiving an archived goal keeps its own answer');
|
|
108
|
+
assert.ok(gate < write, 'a refusal after the write is not a refusal');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('archiving a goal that holds nothing is still ONE call', () => {
|
|
112
|
+
// The guard must not turn the ordinary case into a two-step. There is exactly
|
|
113
|
+
// one setGoalStatus in this handler and no second round trip in front of it.
|
|
114
|
+
assert.equal((ARCHIVE.match(/db\.setGoalStatus/g) || []).length, 1);
|
|
115
|
+
assert.equal(/disposition_required|preview=/.test(ARCHIVE), false,
|
|
116
|
+
'no extra confirm parameter on the empty-goal path');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// ---- the two doors agree ----------------------------------------------------
|
|
120
|
+
|
|
121
|
+
test('archive and achieve mean the same thing by "unfinished"', () => {
|
|
122
|
+
// If these drift, a goal is refused on one door for work the other would have
|
|
123
|
+
// let through — the confusing half of a rule people then route around.
|
|
124
|
+
const dbGoals = src('modules/lifecycle/db-goals.js');
|
|
125
|
+
const openTasks = dbGoals.slice(dbGoals.indexOf('async function openTasksInGoal'));
|
|
126
|
+
const achieve = dbGoals.slice(dbGoals.indexOf('async function achieveGoalIfComplete'));
|
|
127
|
+
for (const [name, body] of [['openTasksInGoal', openTasks], ['achieveGoalIfComplete', achieve]]) {
|
|
128
|
+
assert.match(body, /\$\{nonTerminalSql\(\)\}/, name + ' must read the shared terminal set');
|
|
129
|
+
assert.match(body, /SMOKE_NOT_LIKE\.replace/, name + ' must exclude smoke rows');
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('every non-terminal status is one the archive gate would catch', () => {
|
|
134
|
+
// Stated as data rather than prose: the statuses that hold a goal open are
|
|
135
|
+
// exactly the ones absent from the terminal set, and archive filters on that
|
|
136
|
+
// same set, so the enumeration cannot drift out of step with the constant.
|
|
137
|
+
const HOLDS_OPEN = ['backlog', 'ready', 'blocked', 'active', 'completed', 'confirmed'];
|
|
138
|
+
for (const s of HOLDS_OPEN) {
|
|
139
|
+
assert.equal(TERMINAL_STATUSES.includes(s), false,
|
|
140
|
+
`'${s}' must not be terminal — it is unfinished work an archive would discard`);
|
|
141
|
+
}
|
|
142
|
+
assert.deepEqual([...TERMINAL_STATUSES].sort(), ['abandoned', 'shipped'],
|
|
143
|
+
'and the terminal set is exactly the two deliberate endings');
|
|
144
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// tests/goal_create_version_gate.mjs — a version's goal set closes when the
|
|
2
|
+
// version starts building (BV1.R03, task 1003591, goal 1000086, ADR 0250 D1).
|
|
3
|
+
//
|
|
4
|
+
// THE HOLE. `POST /goals` fetched the version row and discarded it after the null
|
|
5
|
+
// check. `version.status` was never read, so a goal could be added to a version
|
|
6
|
+
// that had been building for months — which is how a version's scope grows
|
|
7
|
+
// without anyone deciding to widen it, and why 64 goals accumulated on
|
|
8
|
+
// BONGOS-V1. New goals belong on the `planning` version: that is what a planning
|
|
9
|
+
// version is FOR, scoping the next one in advance.
|
|
10
|
+
//
|
|
11
|
+
// The gate is pure and DB-free (the `authorizeGoalTaskCreate` precedent), so its
|
|
12
|
+
// whole truth table is exercised here without a database, and the route's only
|
|
13
|
+
// job is to render the decision.
|
|
14
|
+
//
|
|
15
|
+
// Run: node --test tests/goal_create_version_gate.mjs
|
|
16
|
+
|
|
17
|
+
import assert from 'node:assert/strict';
|
|
18
|
+
import { test } from 'node:test';
|
|
19
|
+
import { readFileSync } from 'node:fs';
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
|
|
22
|
+
process.env.NODE_ENV = 'test';
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
const { authorizeGoalCreate } = require('../modules/lifecycle/routes/goal-route-authz.js');
|
|
25
|
+
const { versionAcceptsNewWork } = require('../modules/lifecycle/db-tasks.js');
|
|
26
|
+
|
|
27
|
+
const V = (status) => ({ id: 'BONGOS-V1', status });
|
|
28
|
+
const decide = (version, override) => authorizeGoalCreate({ version, override });
|
|
29
|
+
|
|
30
|
+
// ---- the truth table --------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
test('planning accepts — that is what a planning version is for', () => {
|
|
33
|
+
assert.deepEqual(decide(V('planning')), { ok: true });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('building refuses with a named 409 an agent can act on', () => {
|
|
37
|
+
const d = decide(V('building'));
|
|
38
|
+
assert.equal(d.ok, false);
|
|
39
|
+
assert.equal(d.status, 409);
|
|
40
|
+
assert.equal(d.body.error, 'version_not_planning');
|
|
41
|
+
assert.equal(d.body.status, 'building', 'the caller learns WHICH state refused');
|
|
42
|
+
assert.match(d.body.message, /planning version/,
|
|
43
|
+
'and where the goal should go instead — a refusal with no next move gets routed around');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('shipped refuses as closed history, not as "not planning"', () => {
|
|
47
|
+
// Two different facts deserve two different codes: a building version might
|
|
48
|
+
// still admit this goal via an Archon override; a shipped one never will.
|
|
49
|
+
const d = decide(V('shipped'));
|
|
50
|
+
assert.equal(d.body.error, 'version_closed');
|
|
51
|
+
assert.match(d.body.message, /has shipped/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('a missing version is a 404, before any status reasoning', () => {
|
|
55
|
+
const d = decide(null);
|
|
56
|
+
assert.equal(d.status, 404);
|
|
57
|
+
assert.equal(d.body.error, 'version_not_found');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('FAIL-CLOSED: a status nobody taught this gate about refuses', () => {
|
|
61
|
+
// A version status added later must not default to "sure, file it here".
|
|
62
|
+
for (const status of ['paused', 'draft', '', undefined, null, 42]) {
|
|
63
|
+
const d = decide(V(status));
|
|
64
|
+
assert.equal(d.ok, false, `'${String(status)}' must not be accepted`);
|
|
65
|
+
assert.equal(d.body.error, 'version_closed');
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('an unrecognised status is NOT described as shipped', () => {
|
|
70
|
+
// The fail-closed arm catches more than `shipped`, and telling someone their
|
|
71
|
+
// version "has shipped" when it says 'paused' is a confident lie about state
|
|
72
|
+
// they can see for themselves.
|
|
73
|
+
const d = decide(V('paused'));
|
|
74
|
+
assert.match(d.body.message, /is 'paused'/);
|
|
75
|
+
assert.equal(/has shipped/.test(d.body.message), false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ---- the override seam (R07 is not built yet) -------------------------------
|
|
79
|
+
|
|
80
|
+
test('override admits a building version — the R07 seam, already shaped', () => {
|
|
81
|
+
assert.deepEqual(decide(V('building'), true), { ok: true });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('override does NOT reopen a shipped version', () => {
|
|
85
|
+
// The escape hatch is for a scope decision, not for rewriting closed history.
|
|
86
|
+
assert.equal(decide(V('shipped'), true).body.error, 'version_closed');
|
|
87
|
+
assert.equal(decide(V('paused'), true).body.error, 'version_closed');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('only a literal true overrides — a truthy value does not', () => {
|
|
91
|
+
for (const v of ['yes', 1, {}, [] ]) {
|
|
92
|
+
assert.equal(decide(V('building'), v).ok, false,
|
|
93
|
+
'a JSON body coercing to truthy must not open the gate');
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ---- one definition of "open to work" ---------------------------------------
|
|
98
|
+
|
|
99
|
+
test('the gate reuses versionAcceptsNewWork rather than respelling the statuses', () => {
|
|
100
|
+
const src = readFileSync(new URL('../modules/lifecycle/routes/goal-route-authz.js', import.meta.url), 'utf8');
|
|
101
|
+
const body = src.slice(src.indexOf('function authorizeGoalCreate'), src.indexOf('function authorizeGoalTaskCreate'));
|
|
102
|
+
assert.match(body, /versionAcceptsNewWork\(version\.status\)/);
|
|
103
|
+
assert.equal(/'shipped'\s*,\s*'planning'|new Set\(\[/.test(body), false,
|
|
104
|
+
'no second copy of the status list lives in this gate');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('the gate is STRICTER than versionAcceptsNewWork, deliberately', () => {
|
|
108
|
+
// Work may still LAND on a building version; its SCOPE may not grow. Those are
|
|
109
|
+
// two different questions and this pins that they give different answers.
|
|
110
|
+
assert.equal(versionAcceptsNewWork('building'), true, 'tasks may still be filed');
|
|
111
|
+
assert.equal(decide(V('building')).ok, false, 'but a new goal may not');
|
|
112
|
+
assert.equal(versionAcceptsNewWork('planning'), true);
|
|
113
|
+
assert.equal(decide(V('planning')).ok, true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ---- the route renders the decision and nothing else ------------------------
|
|
117
|
+
|
|
118
|
+
test('POST /goals consults the gate and returns its exact status + body', () => {
|
|
119
|
+
const src = readFileSync(new URL('../modules/lifecycle/routes/goals.js', import.meta.url), 'utf8');
|
|
120
|
+
const start = src.indexOf("router.post('/goals'");
|
|
121
|
+
assert.notEqual(start, -1);
|
|
122
|
+
const body = src.slice(start, src.indexOf('router.', start + 20));
|
|
123
|
+
assert.match(body, /const versionDecision = authorizeGoalCreate\(\{ version \}\);/);
|
|
124
|
+
assert.match(body, /return res\.status\(versionDecision\.status\)\.json\(versionDecision\.body\);/,
|
|
125
|
+
'the route renders the decision, it does not re-derive one');
|
|
126
|
+
assert.equal(/version\.status\s*[=!]==/.test(body), false,
|
|
127
|
+
'and it must not compare the status itself — that is the gate\'s job');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('the 404 moved INTO the gate, so the route has no second version check', () => {
|
|
131
|
+
const src = readFileSync(new URL('../modules/lifecycle/routes/goals.js', import.meta.url), 'utf8');
|
|
132
|
+
const start = src.indexOf("router.post('/goals'");
|
|
133
|
+
const body = src.slice(start, src.indexOf('router.', start + 20));
|
|
134
|
+
assert.equal(/if \(!version\)/.test(body), false,
|
|
135
|
+
'one ordered decision, not a null check here and a status check there');
|
|
136
|
+
});
|