@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.
- package/.bongos-core.json +74 -39
- package/clients/bongos-client/bongos-client.global.js +1 -1
- package/clients/bongos-client/index.cjs +1 -1
- package/clients/bongos-client/index.d.ts +2 -3
- package/clients/bongos-client/index.mjs +1 -1
- package/docs/adr/0297-a-tasks-creator-may-retire-it.md +128 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +5 -28
- package/docs/api-reference.md +2 -2
- package/docs/canonical-permissions.md +30 -1
- package/docs/copy-inventory.md +49 -45
- package/docs/copy-registry.json +86 -50
- package/docs/module-api-changelog.md +4 -0
- package/modules/agents/lib/validate.js +1 -1
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +1 -1
- package/modules/discord/discord-channels.js +80 -5
- package/modules/discord/routes/discord.js +14 -2
- package/modules/government/catalog.js +28 -1
- package/modules/government/migrations/government_015_task_abandon_own.sql +64 -0
- package/modules/hall-ui/public/task.js +50 -1
- package/modules/ideas/inbox.js +29 -1
- package/modules/lifecycle/abandon-authz.js +132 -0
- package/modules/lifecycle/cascade.js +134 -19
- package/modules/lifecycle/db-ship.js +59 -5
- package/modules/lifecycle/routes/abandon-handler.js +151 -0
- package/modules/lifecycle/routes/lifecycle.js +18 -0
- package/modules/lifecycle/routes/tasks.js +26 -38
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/route-rank-check.js +12 -1
- package/src/module-api.js +1 -1
- package/tests/abandon_creator_authz.mjs +176 -0
- package/tests/abandon_route.mjs +276 -0
- package/tests/abandon_task.mjs +145 -3
- package/tests/agents_validate.mjs +33 -2
- package/tests/discord_channels.mjs +166 -0
- package/tests/government_ownership_scope.mjs +13 -7
- package/tests/idea_cascade.mjs +211 -0
- package/tests/task_goal_required.mjs +15 -3
|
@@ -100,18 +100,24 @@ test('A2: every ".own"-suffixed key DECLARES scope own — the namespace never o
|
|
|
100
100
|
});
|
|
101
101
|
|
|
102
102
|
test('A3: the modelled families are the ones the ownership sweep names', () => {
|
|
103
|
+
// `task` joined the modelled families in task 1002443 (ADR 0297) — the first
|
|
104
|
+
// family added because a ROUTE needed it rather than to complete the model.
|
|
103
105
|
assert.deepEqual(Object.keys(RESOURCE_FAMILIES).sort(),
|
|
104
|
-
['box', 'builder_prefs', 'claim', 'credential', 'goal', 'memory']);
|
|
106
|
+
['box', 'builder_prefs', 'claim', 'credential', 'goal', 'memory', 'task']);
|
|
105
107
|
assert.deepEqual([...ownershipScopedKeys()].sort(),
|
|
106
|
-
['box.manage.own', 'credential.manage.own', 'goal.manage.own', 'me.wandering.set', 'memory.manage.own', 'task.act.own']);
|
|
107
|
-
// The cross-owner side is NOT derivable from spelling: box's is `box.fleet.manage
|
|
108
|
+
['box.manage.own', 'credential.manage.own', 'goal.manage.own', 'me.wandering.set', 'memory.manage.own', 'task.abandon.own', 'task.act.own']);
|
|
109
|
+
// The cross-owner side is NOT derivable from spelling: box's is `box.fleet.manage`,
|
|
110
|
+
// and `task.abandon` carries no suffix at all — it predates the axis and was given
|
|
111
|
+
// scope/resource/verb in place rather than renamed, because it is seeded in live
|
|
112
|
+
// rank rows (governance_002) and a key rename is a migration, not a declaration.
|
|
108
113
|
assert.deepEqual([...crossOwnerKeys()].sort(),
|
|
109
|
-
['box.fleet.manage', 'claim.release.any', 'memory.read.any']);
|
|
114
|
+
['box.fleet.manage', 'claim.release.any', 'memory.read.any', 'task.abandon']);
|
|
110
115
|
// The escalation path is DECLARED per key, never derived from the resource
|
|
111
|
-
// family (task 1003195).
|
|
112
|
-
// `
|
|
113
|
-
// genuinely subsumes
|
|
116
|
+
// family (task 1003195). TWO real pairs now: `box.fleet.manage` is the same
|
|
117
|
+
// `manage` verb over every owner's box, and `task.abandon` the same `act` verb
|
|
118
|
+
// over every creator's task, so each genuinely subsumes doing it to your own.
|
|
114
119
|
assert.deepEqual(crossOwnerKeysFor('box.manage.own'), ['box.fleet.manage']);
|
|
120
|
+
assert.deepEqual(crossOwnerKeysFor('task.abandon.own'), ['task.abandon']);
|
|
115
121
|
// claim and memory each HAVE an `any` key in their family and still declare no
|
|
116
122
|
// counterpart — the distinction the family-derived version could not make.
|
|
117
123
|
// `claim.release.any` carries one third of `act` (release, not cost or notes);
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// tests/idea_cascade.mjs — the SECOND cross-role cascade: an ideator lands a Full
|
|
2
|
+
// Idea, an engineer gets a scoping task (task 1003856, part 1's residue).
|
|
3
|
+
//
|
|
4
|
+
// The first cascade (artist-review-on-ship, task 1003549) is covered by
|
|
5
|
+
// modules/lifecycle/tests/cascade.mjs. This file covers what the second one added:
|
|
6
|
+
// a trigger that is not a ship. The mechanism was ship-only, and an idea reaching
|
|
7
|
+
// grade 'full' emitted nothing at all — so the rule could not be a table entry.
|
|
8
|
+
//
|
|
9
|
+
// The runner guarantees are the same four the ship runner's header names, and the
|
|
10
|
+
// one that matters most here is the first: an ideator grading their own idea must
|
|
11
|
+
// never be able to fail on a follow-up task's account.
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import test from 'node:test';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
21
|
+
const C = require(path.join(ROOT, 'modules', 'lifecycle', 'cascade.js'));
|
|
22
|
+
|
|
23
|
+
const idea = (over = {}) => ({
|
|
24
|
+
id: '1001030',
|
|
25
|
+
title: 'Price swaps from mechanism',
|
|
26
|
+
grade: 'full',
|
|
27
|
+
status: 'open',
|
|
28
|
+
completeness_score: 4,
|
|
29
|
+
captured_by: '1000025',
|
|
30
|
+
suggested_goal_id: '1000095',
|
|
31
|
+
suggested_version: 'BONGOS-V2',
|
|
32
|
+
...over,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const deps = (over = {}) => {
|
|
36
|
+
const created = [];
|
|
37
|
+
return {
|
|
38
|
+
created,
|
|
39
|
+
findTaskBySourceRef: async () => null,
|
|
40
|
+
createTask: async (spec) => { created.push(spec); return { id: '9001' }; },
|
|
41
|
+
...over,
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// ---------- the rule ----------
|
|
46
|
+
test('the declaration is well-formed — a malformed rule is a wiring bug, not a silent skip', () => {
|
|
47
|
+
assert.deepEqual(C.validateCascades(C.IDEA_CASCADES), []);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('a Full Idea matches, and files for the ENGINEER', () => {
|
|
51
|
+
const { matched, errors } = C.cascadesFor(idea(), C.IDEA_CASCADES, {}, C.TRIGGERS.idea);
|
|
52
|
+
assert.deepEqual(errors, []);
|
|
53
|
+
assert.equal(matched.length, 1);
|
|
54
|
+
assert.equal(matched[0].rule.discipline, 'engineer');
|
|
55
|
+
assert.match(matched[0].spec.title, /Scope the Full Idea: Price swaps from mechanism/);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('a spark or quick idea does NOT match — Full is the hand-off point', () => {
|
|
59
|
+
for (const grade of ['quick', 'spark', undefined]) {
|
|
60
|
+
const { matched } = C.cascadesFor(idea({ grade }), C.IDEA_CASCADES, {}, C.TRIGGERS.idea);
|
|
61
|
+
assert.equal(matched.length, 0, `grade=${grade} should not file a scoping task`);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('a CLOSED idea does not file — triage got there first', () => {
|
|
66
|
+
for (const status of ['discarded', 'merged', 'promoted']) {
|
|
67
|
+
const { matched } = C.cascadesFor(idea({ status }), C.IDEA_CASCADES, {}, C.TRIGGERS.idea);
|
|
68
|
+
assert.equal(matched.length, 0, `status=${status} should not file`);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('a cascade-sourced idea cannot file another cascade — the anti-recursion guard', () => {
|
|
73
|
+
const { matched } = C.cascadesFor(idea({ source: C.CASCADE_SOURCE }), C.IDEA_CASCADES, {}, C.TRIGGERS.idea);
|
|
74
|
+
assert.equal(matched.length, 0);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ---------- the generated task ----------
|
|
78
|
+
test('the task inherits the version but NEVER the advisory goal hint', () => {
|
|
79
|
+
// idea_inbox.suggested_goal_id documents itself as "Strictly advisory: no rank
|
|
80
|
+
// check, no scope wall, and no payout may read it... The BINDING home for
|
|
81
|
+
// 'which goal' is tasks.goal_id". A generated task's goal IS binding, so
|
|
82
|
+
// inheriting it from the hint would be exactly the read that column forbids.
|
|
83
|
+
// A version is a label and carries no such rule.
|
|
84
|
+
const { matched } = C.cascadesFor(idea(), C.IDEA_CASCADES, {}, C.TRIGGERS.idea);
|
|
85
|
+
const spec = matched[0].spec;
|
|
86
|
+
assert.equal(spec.versionId, 'BONGOS-V2');
|
|
87
|
+
assert.equal(spec.goalId, null, 'an advisory hint must not place a task');
|
|
88
|
+
assert.equal(spec.allowCatchAll, true, 'so it lands in the catch-all and gets triaged like any other homeless work');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('the hint is surfaced for a human rather than silently dropped', () => {
|
|
92
|
+
// Not acting on it is the rule; losing it would be a different mistake.
|
|
93
|
+
const body = C.fullIdeaScopingBody(idea());
|
|
94
|
+
assert.match(body, /routing hint/);
|
|
95
|
+
assert.match(body, /goal 1000095/);
|
|
96
|
+
assert.match(body, /advisory/);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('an idea with no hint says nothing about goals at all', () => {
|
|
100
|
+
const body = C.fullIdeaScopingBody(idea({ suggested_goal_id: null }));
|
|
101
|
+
assert.doesNotMatch(body, /routing hint/);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('a goal-less idea still opts into the catch-all rather than vanishing', () => {
|
|
105
|
+
// The exact regression task 1003598 closed for ships: under the goal guard a
|
|
106
|
+
// null goalId THROWS, and the throw is caught per-rule — so the task would
|
|
107
|
+
// disappear with only a console line.
|
|
108
|
+
const { matched } = C.cascadesFor(idea({ suggested_goal_id: null }), C.IDEA_CASCADES, {}, C.TRIGGERS.idea);
|
|
109
|
+
assert.equal(matched[0].spec.allowCatchAll, true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('the preamble says what actually happened — an idea was graded, not a task shipped', () => {
|
|
113
|
+
const { matched } = C.cascadesFor(idea(), C.IDEA_CASCADES, {}, C.TRIGGERS.idea);
|
|
114
|
+
const d = matched[0].spec.description;
|
|
115
|
+
assert.match(d, /idea 1001030 was graded Full/);
|
|
116
|
+
assert.doesNotMatch(d, /shipped/, 'the ship preamble would be a lie on this trigger');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('the body tells the engineer what is already settled and what is being asked', () => {
|
|
120
|
+
const body = C.fullIdeaScopingBody(idea());
|
|
121
|
+
assert.match(body, /reached \*\*Full\*\*/);
|
|
122
|
+
assert.match(body, /completeness 4/);
|
|
123
|
+
assert.match(body, /What would building it actually touch/);
|
|
124
|
+
// A scoping that concludes "don't build it" is a real answer; say so.
|
|
125
|
+
assert.match(body, /why it should not be built/);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('the source_ref pins one rule to one idea, so a re-grade cannot double-file', () => {
|
|
129
|
+
const { matched } = C.cascadesFor(idea(), C.IDEA_CASCADES, {}, C.TRIGGERS.idea);
|
|
130
|
+
assert.equal(matched[0].spec.sourceRef, C.cascadeSourceRef('engineer-scoping-on-full-idea', '1001030'));
|
|
131
|
+
assert.equal(matched[0].spec.source, C.CASCADE_SOURCE);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ---------- the runner ----------
|
|
135
|
+
test('the runner files the task from the event payload alone', async () => {
|
|
136
|
+
const d = deps();
|
|
137
|
+
const report = await C.runCascadesForIdea(idea(), d);
|
|
138
|
+
assert.deepEqual(report.errors, []);
|
|
139
|
+
assert.equal(report.created.length, 1);
|
|
140
|
+
assert.equal(d.created.length, 1);
|
|
141
|
+
assert.equal(d.created[0].discipline, 'engineer');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('an already-filed cascade is SKIPPED, not filed twice', async () => {
|
|
145
|
+
const d = deps({ findTaskBySourceRef: async () => ({ id: '8001' }) });
|
|
146
|
+
const report = await C.runCascadesForIdea(idea(), d);
|
|
147
|
+
assert.equal(report.created.length, 0);
|
|
148
|
+
assert.deepEqual(report.skipped.map((s) => s.reason), ['already filed']);
|
|
149
|
+
assert.equal(d.created.length, 0);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('a payload with no id is reported, never thrown', async () => {
|
|
153
|
+
const report = await C.runCascadesForIdea({}, deps());
|
|
154
|
+
assert.equal(report.created.length, 0);
|
|
155
|
+
assert.match(report.errors.join(' '), /carried no idea id/);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('createTask throwing costs that rule its task and NOTHING else', async () => {
|
|
159
|
+
// Property 1: the ideator's grade is already written. A follow-up task failing
|
|
160
|
+
// must never surface as a failure of the thing that triggered it.
|
|
161
|
+
const d = deps({ createTask: async () => { throw new Error('db down'); } });
|
|
162
|
+
const report = await C.runCascadesForIdea(idea(), d);
|
|
163
|
+
assert.equal(report.created.length, 0);
|
|
164
|
+
assert.match(report.errors.join(' '), /db down/);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('the runner never rejects, whatever the deps do', async () => {
|
|
168
|
+
const report = await C.runCascadesForIdea(idea(), { findTaskBySourceRef: async () => { throw new Error('boom'); }, createTask: async () => ({ id: '1' }) });
|
|
169
|
+
assert.ok(Array.isArray(report.errors) && report.errors.length > 0);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('an injected reader that finds nothing is an error, not a silent fall-back', async () => {
|
|
173
|
+
const report = await C.runCascadesForIdea(idea(), deps({ getIdea: async () => null }));
|
|
174
|
+
assert.match(report.errors.join(' '), /not readable after grading/);
|
|
175
|
+
assert.equal(report.created.length, 0);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// ---------- the wiring, which fails silently when it breaks ----------
|
|
179
|
+
const src = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
|
180
|
+
|
|
181
|
+
test('the event is in the catalog AND something dispatches it', () => {
|
|
182
|
+
// The catalog's own comment: widening it is deliberate and must land together
|
|
183
|
+
// with a real dispatcher. Both halves asserted, because either alone is dead.
|
|
184
|
+
assert.match(src('modules/agents/lib/validate.js'), /'idea\.graded\.full'/);
|
|
185
|
+
assert.match(src('modules/ideas/inbox.js'), /api\.emit\('idea\.graded\.full'/);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('the emit carries the whole idea, not just an id', () => {
|
|
189
|
+
// The listener lives in lifecycle, which owns no idea table and has no read
|
|
190
|
+
// port for one — so a bare {id} payload would leave the rules nothing to match.
|
|
191
|
+
const inbox = src('modules/ideas/inbox.js');
|
|
192
|
+
assert.match(inbox, /const idea = await getIdea\(ideaId\)/);
|
|
193
|
+
assert.match(inbox, /\.\.\.\(idea \|\| \{\}\)/);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('lifecycle subscribes to the idea trigger and tears it down again', () => {
|
|
197
|
+
const lc = src('modules/lifecycle/routes/lifecycle.js');
|
|
198
|
+
assert.match(lc, /api\.on\('idea\.graded\.full'/);
|
|
199
|
+
assert.match(lc, /if \(_ideaCascadeListenerOff\) _ideaCascadeListenerOff\(\)/);
|
|
200
|
+
// It must NOT reach into the ideas module's table to do it.
|
|
201
|
+
assert.doesNotMatch(lc, /getIdeaForCascade/, 'lifecycle must not query another module\'s rows');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('the emit only fires on a REAL transition', () => {
|
|
205
|
+
// The UPDATE carries `AND grade = 'spark'`, so a no-op re-grade returns null.
|
|
206
|
+
// The emit is inside `if (graded)`, so it cannot repeat for one idea.
|
|
207
|
+
const inbox = src('modules/ideas/inbox.js');
|
|
208
|
+
const at = inbox.indexOf("api.emit('idea.graded.full'");
|
|
209
|
+
assert.ok(at > 0);
|
|
210
|
+
assert.match(inbox.slice(0, at).split('\n').slice(-25).join('\n'), /if \(graded\) \{/);
|
|
211
|
+
});
|
|
@@ -134,11 +134,23 @@ test('cascade opts in ONLY when inheritance actually failed', () => {
|
|
|
134
134
|
// general. A blanket `true` here would quietly re-open the hole for every
|
|
135
135
|
// follow-up task the system files.
|
|
136
136
|
const body = src('modules/lifecycle/cascade.js');
|
|
137
|
-
|
|
137
|
+
// Pinned on the RULE, not on one spelling of it: task 1003856 added an
|
|
138
|
+
// idea-triggered cascade whose subject carries its goal as `suggested_goal_id`,
|
|
139
|
+
// so the condition legitimately grew a term. What must stay true is that the
|
|
140
|
+
// hatch is conditional and covers every source the spec can inherit from.
|
|
141
|
+
const hatch = (body.match(/allowCatchAll:.*$/m) || [''])[0];
|
|
142
|
+
assert.match(hatch, /task\.goal_id == null/, 'the hatch still turns on a missing inherited goal');
|
|
138
143
|
assert.equal(/allowCatchAll: true/.test(body), false,
|
|
139
144
|
'an unconditional hatch here would cover every cascade, not just the legacy ones');
|
|
140
|
-
|
|
141
|
-
|
|
145
|
+
|
|
146
|
+
// Every field the spec inherits a goal FROM must also be a field the hatch
|
|
147
|
+
// checks, or a subject shape could arrive with a real goal and still opt into
|
|
148
|
+
// the catch-all — the hole this test exists to keep shut.
|
|
149
|
+
const goalLine = (body.match(/^\s*goalId:[\s\S]*?,$/m) || [''])[0];
|
|
150
|
+
assert.match(goalLine, /task\.goal_id != null \? Number\(task\.goal_id\)/, 'inheritance stays the primary path');
|
|
151
|
+
for (const field of (goalLine.match(/task\.[a-z_]*goal[a-z_]*/g) || [])) {
|
|
152
|
+
assert.ok(hatch.includes(field), `${field} is inherited as a goal but is not checked by the catch-all hatch`);
|
|
153
|
+
}
|
|
142
154
|
});
|
|
143
155
|
|
|
144
156
|
test('a goal-less parent does not silently lose its cascade task', async () => {
|