@bongos/core 1.19.274 → 1.19.276

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.
@@ -0,0 +1,232 @@
1
+ 'use strict';
2
+
3
+ // modules/ideas/routing.js — THE LANDING MATRIX (task 1003065, BV1.R01,
4
+ // goal 1000071 "Idea routing — work that has a home lands in it").
5
+ // Spec: docs/specs/bongos-v1-idea-routing.md §"Landing matrix (C1/C2/C4)".
6
+ //
7
+ // THE PROBLEM THIS ANSWERS. Most filed "ideas" are really bug tasks or
8
+ // goal-bound feature tasks. Today they park in idea_inbox and wait for a human
9
+ // triage pass that, when it finally promotes them, drops the task into a version
10
+ // catch-all — because nothing in the idea path ever names a goal. Routing makes
11
+ // the home real: work that HAS a home lands in it at filing time, and the inbox
12
+ // keeps only work that doesn't.
13
+ //
14
+ // This file is the DECISION and nothing else: given what was filed and who filed
15
+ // it, does it route, and if so at what status. It is PURE — no DB, no clock, no
16
+ // rank lookup — so every cell of the matrix is pinned by a DB-free unit test
17
+ // (tests/idea_routing.mjs). The transaction that acts on the decision lives in
18
+ // inbox.captureIdea; keeping them apart is what makes the matrix auditable
19
+ // without a Postgres.
20
+ //
21
+ // FAIL-CLOSED IS THE POSTURE HERE, and it is the opposite of the advisory
22
+ // modules' fail-open. An advisory that misfires prints a wrong sentence; a
23
+ // landing decision that misfires puts UNVETTED WORK IN THE CLAIMABLE QUEUE.
24
+ // So every uncertainty resolves toward "don't route" or "route to backlog",
25
+ // never toward 'ready': an unrecognised kind soft-lands, an unknown/absent
26
+ // filer rank soft-lands, and anything the matrix has no opinion about stays in
27
+ // the inbox exactly as it does today.
28
+
29
+ // The one rule the owner interview (2026-08-17, Q1) settled: BUGS ARE
30
+ // URGENT-TRUE, THE REST GETS A NOD. A bug filed by a trusted builder is
31
+ // claimable the moment it is reported — that is the whole "no triage stop
32
+ // between reported and in the work queue" claim of criterion C1. Everything
33
+ // else — feature, cleanup, refactor, infra — soft-lands at 'backlog', one
34
+ // Metic+ promote from claimable, because those reshape what gets built and a
35
+ // filer should not be able to set the queue's agenda alone.
36
+ const HARD_LAND_KINDS = new Set(['bug']);
37
+
38
+ // Kinds that are JUDGMENT WORK and must never auto-route, no matter who files
39
+ // them with what goal. A criterion-proposal reshapes a version's scope-truth and
40
+ // is ratified by an Archon (POST /inbox/:id/ratify); a decision is a question
41
+ // awaiting an answer, not a unit of work. Converting either into a task would
42
+ // turn "we should think about this" into "someone should go do this".
43
+ const NEVER_ROUTE_KINDS = new Set(['criterion-proposal', 'decision']);
44
+
45
+ // The trusted tier, spelled here rather than imported so this file stays pure and
46
+ // DB-free. It is the same Metic+ boundary docs/canonical-permissions.md draws and
47
+ // the routes enforce; this module only READS a rank string it was handed. It
48
+ // grants nothing — the server-side rank floor and the goal's scope wall are
49
+ // separate, load-bearing gates (R05), and this decision is subordinate to both.
50
+ const METIC_PLUS = new Set(['metic', 'archon']);
51
+
52
+ // A Full Idea (grade='full') is the Board Room's subject — it is ratified by
53
+ // peers (goal 1000069), not converted into a task by its own filer. 'spark' and
54
+ // 'quick' carry no such meaning and route normally.
55
+ const NEVER_ROUTE_GRADE = 'full';
56
+
57
+ // decideLanding — the matrix, as one pure function.
58
+ //
59
+ // Returns { route: false, reason } when the thing belongs in the inbox, or
60
+ // { route: true, status, reason } when it becomes a task. `reason` is a stable
61
+ // machine token (not prose): the route echoes it, /idea-triage reads it, and the
62
+ // R15 reclassification sweep counts by it. Never throws.
63
+ function decideLanding(input) {
64
+ // Destructured defaults only fire on `undefined`, so an explicit `null` — what
65
+ // a caller spreading an absent object hands over — would throw. A landing
66
+ // decision that CRASHES is the worst of the three outcomes: it takes down the
67
+ // capture that was supposed to fail closed. Normalise first, decide after.
68
+ const { goalId = null, kind = null, grade = null, filerRank = null } = input || {};
69
+ // No goal named ⇒ the inbox is exactly right, and this is the DEFAULT PATH.
70
+ // Every capture vector that never learns about goals keeps working unchanged;
71
+ // routing is strictly additive (criterion C1 testable #5).
72
+ if (goalId == null || goalId === '') return { route: false, reason: 'no_goal' };
73
+
74
+ if (String(grade || '').toLowerCase() === NEVER_ROUTE_GRADE) {
75
+ return { route: false, reason: 'full_idea_never_routes' };
76
+ }
77
+ if (NEVER_ROUTE_KINDS.has(kind)) {
78
+ return { route: false, reason: 'judgment_kind' };
79
+ }
80
+
81
+ if (HARD_LAND_KINDS.has(kind)) {
82
+ // The rank split. An absent/unknown rank is NOT treated as trusted — see the
83
+ // fail-closed note above. This is the DECISION only; R05 adds the
84
+ // server-side enforcement (auto-derived requires_rank floor + scope wall).
85
+ return METIC_PLUS.has(String(filerRank || '').toLowerCase())
86
+ ? { route: true, status: 'ready', reason: 'bug_trusted_filer' }
87
+ : { route: true, status: 'backlog', reason: 'bug_awaiting_nod' };
88
+ }
89
+
90
+ // Everything else with a home: it lands IN that home, at backlog. Note this
91
+ // deliberately swallows an unrecognised kind into the soft landing rather than
92
+ // refusing it — a task in a real goal at backlog is strictly better than a row
93
+ // rotting in the inbox, and 'backlog' means a human still says go.
94
+ return { route: true, status: 'backlog', reason: 'soft_landing' };
95
+ }
96
+
97
+ // The default priority for a routed task when the filer suggested none. 3 is the
98
+ // middle of the 1–5 band — a routed task is not automatically urgent, and not
99
+ // automatically bottom-of-queue either. (Decided without interview and recorded
100
+ // in the spec: "routed-task priority defaults to the idea's suggested_priority
101
+ // else 3".)
102
+ const DEFAULT_ROUTED_PRIORITY = 3;
103
+
104
+ // routedPriority — the idea's own suggestion, clamped to the task band. An idea's
105
+ // suggested_priority column is validated 1..5 at capture, but a routed idea can
106
+ // arrive from a vector that never validated it (Discord inbound, a seed), so this
107
+ // re-clamps rather than trusting the input. Out-of-band or absent ⇒ the default.
108
+ function routedPriority(suggestedPriority) {
109
+ const p = Number(suggestedPriority);
110
+ if (!Number.isInteger(p) || p < 1 || p > 5) return DEFAULT_ROUTED_PRIORITY;
111
+ return p;
112
+ }
113
+
114
+ // -------------------------------------------------------------------------
115
+ // ENFORCEMENT (task 1003066, BV1.R05) — the matrix decides, this gate BINDS.
116
+ //
117
+ // R01 landed decideLanding: what status a filing lands at. That is a decision
118
+ // about the QUEUE. It is not authority, and on its own it left a real hole:
119
+ // a routed task declares no touches[], so createTask's ADR 0084/0085 rank floor
120
+ // derives from an empty list and comes out 'xenos' — meaning a bug routed into a
121
+ // goal scoped to PROTECTED modules (permission/ship-grade/deploy/migration core)
122
+ // was born claimable by a Xenos the moment any Metic+ nodded it. The matrix's
123
+ // rank split does not close that: it governs who can make work READY, not who
124
+ // can then take it.
125
+ //
126
+ // WHY THIS ISN'T goalScopeCheck.check() CALLED VERBATIM, which is what
127
+ // POST /goals/:id/tasks does. That function DENIES on `empty_touches` — by
128
+ // design, because an AUTHORED task whose blast radius nobody declared cannot be
129
+ // bounded. Applied to a route it would refuse every single filing, since a person
130
+ // reporting a bug does not know which files it lives in; demanding touches[] as
131
+ // the price of reporting is exactly the triage tax this goal removes. So the wall
132
+ // is applied where it has something to bite on:
133
+ //
134
+ // * touches DECLARED (a programmatic filer that does know) → the real wall,
135
+ // verbatim, refusals and all.
136
+ // * touches ABSENT (every human filing) → nothing to subset-check, so the
137
+ // protection moves to the CLAIM side: the goal's protected scope raises the
138
+ // task's rank floor, and no sub-Metic can claim it however it got promoted.
139
+ //
140
+ // That is the same protected-roster source (permission-path-check via
141
+ // goalScopeCheck / moduleScopeMap), just enforced at the gate that still exists
142
+ // when touches[] doesn't.
143
+ //
144
+ // MEMBERSHIP IS DELIBERATELY *NOT* REQUIRED, and this is the one place the routed
145
+ // path diverges from the authored one on purpose. authorizeGoalTaskCreate refuses
146
+ // a non-member with not_a_member, because authoring work inside someone's
147
+ // workspace is a member's privilege. REPORTING is not authoring: criterion C5's
148
+ // whole promise is that "the filer never has to know the goal map", and C1's is
149
+ // that a bug filed anywhere lands in its home. Requiring membership would mean
150
+ // only insiders could report against a goal — the opposite of the goal. The
151
+ // safety that membership was carrying is carried here instead by the rank floor
152
+ // (claim side) and the matrix (ready side). Pinned by a test, so a later change
153
+ // has to argue with the test rather than quietly reinstate the wall.
154
+
155
+ // The rank a routed task must require when its goal's scope reaches protected
156
+ // territory. Metic is the trust boundary docs/canonical-permissions.md draws for
157
+ // the permission/ship/deploy core; createTask's highestRank() clamps UPWARD only,
158
+ // so handing this in can raise a floor and never lower one.
159
+ const PROTECTED_SCOPE_FLOOR = 'metic';
160
+
161
+ // authorizeRoutedLanding — the FINAL, binding landing decision. PURE: it takes
162
+ // facts the caller already resolved (the matrix verdict, whether the goal's scope
163
+ // includes a protected module, and the scope wall's own verdict if touches were
164
+ // declared) and returns what to stamp on the task, or a refusal.
165
+ //
166
+ // Ordered + fail-closed, the authorizeGoalTaskCreate precedent:
167
+ // not routing → scope-wall verdict → the floor.
168
+ // Returns { ok: true, status, requiresRank } or { ok: false, code, reason, details }.
169
+ function authorizeRoutedLanding(input) {
170
+ const { landing = null, scopeProtected = false, scopeCheck = null, filerRank = null } = input || {};
171
+
172
+ // Defensive: only a routing verdict may reach here. A caller that lost track of
173
+ // its own matrix result must not fall through into "authorized".
174
+ if (!landing || landing.route !== true) {
175
+ return { ok: false, code: 'not_routing', reason: 'authorizeRoutedLanding was asked to authorize a filing the matrix did not route.', details: null };
176
+ }
177
+
178
+ // The wall, when touches gave it something to check. Its verdict is passed
179
+ // through UNCHANGED — code, reason and details — so a refusal reads identically
180
+ // whether it came from a routed filing or an authored task, and there is only
181
+ // ever one definition of "out of scope" / "protected path".
182
+ if (scopeCheck && scopeCheck.ok === false) {
183
+ return { ok: false, code: scopeCheck.code, reason: scopeCheck.reason, details: scopeCheck.details ?? null };
184
+ }
185
+
186
+ // PROTECTED SCOPE + SUB-METIC FILER ⇒ REFUSE, not merely floor-stamp.
187
+ //
188
+ // This closes the one place the first cut of R05 was genuinely weaker than the
189
+ // authored gate rather than differently-shaped. With no touches[] there is no
190
+ // path for the protected-path arm to inspect, so the earlier version let a
191
+ // sub-Metic CREATE a task in protected territory and relied on the rank floor to
192
+ // stop them CLAIMING it. But POST /goals/:id/tasks refuses that author outright
193
+ // (protected_path), and admission refuses them the goal entirely
194
+ // (authorizeInvitationAccept: a sub-Metic may not even join a protected-scope
195
+ // goal). Letting the routed path mint a row there was the odd one out.
196
+ //
197
+ // Refusing costs the filer nothing they had before: omit goal_id and the report
198
+ // files to the inbox exactly as it always did, where a Metic+ triages it. What
199
+ // it removes is a sub-Metic's ability to put rows — however unclaimable — into
200
+ // the permission/ship/deploy core's queue, which is precisely what "the scope
201
+ // wall runs on routed tasks as on authored ones" is asking for.
202
+ //
203
+ // The rank floor below still applies to Metic+ filings in protected scope; the
204
+ // two are layers, not alternatives.
205
+ if (scopeProtected && !METIC_PLUS.has(String(filerRank || '').toLowerCase())) {
206
+ return {
207
+ ok: false,
208
+ code: 'protected_scope',
209
+ reason: `This goal's scope reaches protected territory (the permission / ship-grade / deploy / migration core), so a filer below Metic cannot route work into it. File it without a goal — it lands in the inbox and a Metic+ routes it from there.`,
210
+ details: { required_rank: PROTECTED_SCOPE_FLOOR, filer_rank: filerRank ?? null },
211
+ };
212
+ }
213
+
214
+ return {
215
+ ok: true,
216
+ status: landing.status,
217
+ // null ⇒ say nothing and let createTask derive the floor exactly as it does
218
+ // for every other create vector. Only the protected case speaks up.
219
+ requiresRank: scopeProtected ? PROTECTED_SCOPE_FLOOR : null,
220
+ };
221
+ }
222
+
223
+ module.exports = {
224
+ decideLanding,
225
+ authorizeRoutedLanding,
226
+ PROTECTED_SCOPE_FLOOR,
227
+ routedPriority,
228
+ DEFAULT_ROUTED_PRIORITY,
229
+ HARD_LAND_KINDS,
230
+ NEVER_ROUTE_KINDS,
231
+ METIC_PLUS,
232
+ };
@@ -1421,7 +1421,7 @@ async function createTask({
1421
1421
  // (requiresRank stays null for the goal vector → derived purely from touches +
1422
1422
  // sensitivity), so goal_id never influences the requires_rank clamp.
1423
1423
  goalId = null,
1424
- }) {
1424
+ }, deps = {}) { // deps.client enlists this INSERT in the CALLER'S transaction (task 1003065, BV1.R01); the argument for it is at modules/ideas/inbox.js routeIdeaToGoal
1425
1425
  // task 1498 / ADR 0085: every task carries a deliberate rank floor. Derive it
1426
1426
  // from touches + sensitivity, then let an explicit requiresRank override only
1427
1427
  // UPWARD (highestRank) — a requested rank below the derived floor is clamped
@@ -1456,7 +1456,7 @@ async function createTask({
1456
1456
  if (goalIdEffective == null) {
1457
1457
  goalIdEffective = await generalGoalIdForVersion(versionId);
1458
1458
  }
1459
- const { rows } = await pool.query(
1459
+ const { rows } = await (deps.client || pool).query(
1460
1460
  `INSERT INTO tasks (version_id, title, description, status, touches,
1461
1461
  est_minutes, est_cost_usd, manual_degree, priority,
1462
1462
  automation_tag, credits_reward, source, source_ref, value_summary,
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.274",
3
+ "version": "1.19.276",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.274",
9
+ "version": "1.19.276",
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.274",
3
+ "version": "1.19.276",
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
@@ -975,8 +975,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
975
975
  // landed since 1.19.272 with no explicit bump. run 32441024959. (task 1002620)
976
976
  // 1.19.274 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
977
977
  // landed since 1.19.273 with no explicit bump. run 32490024209. (task 1002620)
978
+ // 1.19.275 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
979
+ // landed since 1.19.274 with no explicit bump. run 32492414871. (task 1002620)
980
+ // 1.19.276 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
981
+ // landed since 1.19.275 with no explicit bump. run 32499257959. (task 1002620)
978
982
  // ---------------------------------------------------------------------------
979
- const CORE_VERSION = '1.19.274'; // CI auto-patch carrier (ADR 0161); see changelog above
983
+ const CORE_VERSION = '1.19.276'; // CI auto-patch carrier (ADR 0161); see changelog above
980
984
 
981
985
  // A namespaced logger so a module's log lines are attributable + consistent.
982
986
  // Usage: const log = api.logger('dev-box'); log.info('mounted');