@bongos/core 1.19.294 → 1.19.296

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.
@@ -782,6 +782,91 @@ async function countRoutedAwaitingNod(deps = {}) {
782
782
  return rows[0] ? rows[0].n : 0;
783
783
  }
784
784
 
785
+ // -------------------------------------------------------------------------
786
+ // The spark queue (task 1002921 / BV1.R145, goal 1000062)
787
+ //
788
+ // "What Big Idea Sparks are awaiting development?" — the ideator craft's pickup
789
+ // surface, and the first place in this codebase where one craft sources work
790
+ // from another craft's output.
791
+ //
792
+ // THE PREDICATE IS grade='spark' AND status='open', AND THAT IS THE WHOLE
793
+ // QUEUE. There is deliberately no `developed` flag and no separate queue table:
794
+ // R146 develops a spark into a Full Idea IN PLACE on the same idea_inbox row
795
+ // (same id, same captured_by), which flips its grade spark→full — so a developed
796
+ // spark leaves this queue as a CONSEQUENCE of being developed, not because
797
+ // anything remembered to remove it. A second source of truth for "still
798
+ // waiting" is a thing that can disagree with the grade; this cannot.
799
+ //
800
+ // AN INNER JOIN, WHICH INVERTS THIS MODULE'S USUAL RULE ON PURPOSE. Every other
801
+ // idea read LEFT JOINs ideas_grades and COALESCEs to 'quick', because an
802
+ // un-graded idea must still appear. Here the opposite is correct and safe:
803
+ // grade='spark' can never be implicit — absence of a row means quick — so a
804
+ // spark ALWAYS has a real row, and starting from ideas_grades lets
805
+ // ideas_grades_grade_score_idx (ideas_001, written for this very read) drive the
806
+ // scan instead of filtering a full idea_inbox pass afterwards.
807
+ //
808
+ // ORDERING — "newest-or-best first", and only the newest half is real. A spark
809
+ // carries no completeness score by construction (it has no template fields to
810
+ // score), so there is no "best" signal to sort by: upvotes live on builders as
811
+ // karma, not per-idea, and inventing a quality metric for an undeveloped thought
812
+ // is exactly the guess this queue should not be making. captured_at DESC, id
813
+ // DESC as a stable tiebreak for same-timestamp rows. When a real per-idea
814
+ // signal exists, this ORDER BY is the one line that changes.
815
+ //
816
+ // The skill-friction visibility gate (V3.R70) is applied here too, on the same
817
+ // title-prefix rule listOpenIdeas uses. A routine-filed friction row is unlikely
818
+ // to ever be sparked (marking requires an author), but the two idea listings
819
+ // agreeing by construction beats them agreeing by accident.
820
+ function sparkQueueWhere(viewerRank, params) {
821
+ const where = [`g.grade = 'spark'`, `i.status = 'open'`];
822
+ if (!rankCanSeeSkillFriction(viewerRank)) {
823
+ params.push(`${SKILL_FRICTION_TAG}%`);
824
+ where.push(`i.title NOT LIKE $${params.length}`);
825
+ }
826
+ return where;
827
+ }
828
+
829
+ async function listSparkQueue({ limit = 50, viewerRank = null } = {}, deps = {}) {
830
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
831
+ const params = [];
832
+ const where = sparkQueueWhere(viewerRank, params);
833
+ params.push(limit);
834
+ const { rows } = await activePool.query(
835
+ // `b.github_login` because attribution is the POINT of this surface: an
836
+ // ideator picking work up should see whose thought they are developing, and
837
+ // R146 has to preserve that name. LEFT JOIN — a builder row can be missing.
838
+ `SELECT ${IDEA_COLS_Q}, ${GRADE_COLS_Q},
839
+ b.github_login AS filed_by_login
840
+ FROM ideas_grades g
841
+ JOIN idea_inbox i ON i.id = g.idea_id
842
+ LEFT JOIN builders b ON b.id = i.captured_by
843
+ WHERE ${where.join(' AND ')}
844
+ ORDER BY i.captured_at DESC, i.id DESC
845
+ LIMIT $${params.length}`,
846
+ params
847
+ );
848
+ return rows;
849
+ }
850
+
851
+ // The count behind the queue's pill — the SAME predicate as the list (the
852
+ // countRoutedAwaitingNod / countOpenIdeas precedent), so the number a viewer is
853
+ // shown can never disagree with the rows they can actually see. It deliberately
854
+ // takes viewerRank too: a count that ignored the visibility gate would tell a
855
+ // Xenos there are sparks they cannot find.
856
+ async function countSparkQueue({ viewerRank = null } = {}, deps = {}) {
857
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
858
+ const params = [];
859
+ const where = sparkQueueWhere(viewerRank, params);
860
+ const { rows } = await activePool.query(
861
+ `SELECT COUNT(*)::int AS n
862
+ FROM ideas_grades g
863
+ JOIN idea_inbox i ON i.id = g.idea_id
864
+ WHERE ${where.join(' AND ')}`,
865
+ params
866
+ );
867
+ return rows[0] ? rows[0].n : 0;
868
+ }
869
+
785
870
  // -------------------------------------------------------------------------
786
871
  // Grade writes (task 1002916 / BV1.R140)
787
872
  //
@@ -1109,6 +1194,9 @@ module.exports = {
1109
1194
  // the nod queue (task 1003066, BV1.R05)
1110
1195
  listRoutedAwaitingNod,
1111
1196
  countRoutedAwaitingNod,
1197
+ // the spark queue (task 1002921, BV1.R145) — sparks awaiting development
1198
+ listSparkQueue,
1199
+ countSparkQueue,
1112
1200
  // criterion-proposal ratification (V3.R31 / #252)
1113
1201
  ratifyCriterionProposal,
1114
1202
  parseProposalBody,
@@ -312,6 +312,43 @@ module.exports = function buildInboxRouter() {
312
312
  }
313
313
  });
314
314
 
315
+
316
+ // GET /inbox/sparks — THE SPARK QUEUE (task 1002921 / BV1.R145, goal 1000062).
317
+ //
318
+ // Big Idea Sparks awaiting development: the ideator craft's pickup surface,
319
+ // and the first place one craft sources work from another craft's output.
320
+ //
321
+ // READABLE BY ANY BUILDER, by design and by the done-when. A pickup surface
322
+ // that only trusted builders could read would defeat its own purpose — the
323
+ // whole point is that someone OTHER than the author comes along and develops
324
+ // the thought. The author's consent is already recorded (the R144 mark is what
325
+ // put the row here), so no further gate belongs on the read. Contrast
326
+ // /inbox/awaiting-nod directly above, which IS gated: that queue exists to be
327
+ // ACTED on by a triager, this one to be BROWSED by anyone.
328
+ //
329
+ // DECLARED BEFORE `GET /inbox/:id` DELIBERATELY — and this ordering is
330
+ // load-bearing, not stylistic. Express matches in declaration order, so with
331
+ // the routes the other way round `/inbox/sparks` binds `:id = 'sparks'` and
332
+ // parseId turns the whole queue into a 400 bad_id. Same reason
333
+ // /inbox/awaiting-nod sits above it. tests/idea_spark_queue.mjs pins the
334
+ // relative order so a future insertion cannot quietly re-shadow it.
335
+ //
336
+ // rank: any-builder — a pickup surface is public to builders (the R144 mark is
337
+ // the consent). Deliberately NOT pinned in route-rank-check: a read with no
338
+ // gate to protect has no downgrade to catch, and pinning it would only assert
339
+ // that a read stays open.
340
+ router.get('/inbox/sparks', api.requireBuilder, api.asyncHandler('GET /inbox/sparks', async (req, res) => {
341
+ const limitRaw = Number(req.query.limit);
342
+ const limit = Number.isInteger(limitRaw) && limitRaw > 0 && limitRaw <= 100 ? limitRaw : 50;
343
+ // Both halves take the viewer's rank so the pill can never claim sparks the
344
+ // list will not show (the countRoutedAwaitingNod precedent).
345
+ const viewerRank = req.builder && req.builder.rank;
346
+ const [sparks, count] = await Promise.all([
347
+ inbox.listSparkQueue({ limit, viewerRank }),
348
+ inbox.countSparkQueue({ viewerRank }),
349
+ ]);
350
+ return res.json({ sparks, count });
351
+ }, { errorCode: 'spark_queue_failed' }));
315
352
  // rank: any-builder — single idea read for the "#NNN" deep-link resolver
316
353
  // (#1047). Mirrors GET /blockers/:id. The hall resolves /#/idea/N by id, but
317
354
  // the /inbox LIST returns only the open-first-50, so a closed/older idea ref
@@ -443,6 +480,119 @@ module.exports = function buildInboxRouter() {
443
480
  }
444
481
  });
445
482
 
483
+ // -----------------------------------------------------------------------
484
+ // The Big Idea Spark mark (task 1002920 / BV1.R144, goal 1000062)
485
+ //
486
+ // "I have the spark but not the full picture — someone develop this."
487
+ //
488
+ // AUTHOR-ONLY, AND THAT IS THE WHOLE POINT. The owner's decision: this mark IS
489
+ // the consent that makes cross-builder development fair. Without it a Quick
490
+ // Idea is simply NOT available for someone else to develop — a quick idea is
491
+ // for turning a thought into a task fast, and it is explicitly *not* an
492
+ // unfinished Full Idea waiting to be finished by a stranger. So the gate is
493
+ // not "who is senior enough", it is "whose thought is this": enforced
494
+ // in-handler against `captured_by`, the same ownership posture as
495
+ // POST /inbox/:id/resubmit above and PATCH /inbox/:id/body below.
496
+ //
497
+ // A MARK, MODELLED AS A RESOURCE: POST puts it on, DELETE takes it off. The
498
+ // done-when requires the mark be REVERSIBLE, and un-sparking is the plain
499
+ // reading of that — an author who marked a thought as needing development may
500
+ // decide they had the full picture after all. Un-sparking writes an EXPLICIT
501
+ // grade='quick' row, which the R140 design states reads identically to having
502
+ // no row at all, so a reversal leaves no residue a reader has to interpret.
503
+ //
504
+ // ONLY A QUICK IDEA CAN BE SPARKED. A Full Idea already carries the developed
505
+ // picture (the five template fields + a score); asking for it to be developed
506
+ // is a contradiction, and sparking it would hand setIdeaGrade a `spark` write
507
+ // whose empty fieldValues would WIPE that work — exactly the stale/lost-field
508
+ // hazard setIdeaGrade's whole-row contract warns about. Refused 409, before
509
+ // any write.
510
+ //
511
+ // Both directions are IDEMPOTENT: sparking a spark, or un-sparking a quick,
512
+ // reports `unchanged` at 200 rather than erroring. A double-click on the R154
513
+ // hall control is not a failure, and the caller's intent is already satisfied.
514
+ //
515
+ // rank: any-builder (requireBuilder) + the in-handler author gate.
516
+ // Pinned in route-rank-check.MODULE_EXPECTED_RANKS['ideas'].
517
+
518
+ // Shared by both directions: load the idea, prove ownership, and refuse a
519
+ // grade this mark has no business touching. Returns the idea, or null when it
520
+ // has already answered the request (so the caller just returns).
521
+ async function loadSparkable(req, res, { requireOpen }) {
522
+ const id = parseId(req, res, { code: 'bad_id', positiveInt: true });
523
+ if (id === null) return null;
524
+ const idea = await inbox.getIdea(id);
525
+ if (!idea) { res.fail('idea_not_found', 404); return null; }
526
+ // Authority first, and it leaks nothing: any builder can already read this
527
+ // idea from the inbox, so a 403 here reveals only that they are not its author.
528
+ if (idea.captured_by == null || String(idea.captured_by) !== String(req.builder.id)) {
529
+ res.fail('not_your_idea', 403, {
530
+ reason: 'the spark mark is the AUTHOR\'s consent to have their idea developed; only they can give or withdraw it',
531
+ });
532
+ return null;
533
+ }
534
+ if (idea.grade === 'full') {
535
+ res.fail('not_a_quick_idea', 409, {
536
+ grade: idea.grade,
537
+ reason: 'a Full Idea already carries the developed picture; the spark mark asks for one',
538
+ });
539
+ return null;
540
+ }
541
+ // Only MARKING requires an open idea. A promoted or discarded idea is not
542
+ // awaiting development, and admitting it would seed the R145 spark queue
543
+ // with work nobody can pick up. WITHDRAWING the mark stays legal at any
544
+ // status — clearing a stale mark is housekeeping, and blocking it would be
545
+ // the one way to leave a permanent spark on a dead idea.
546
+ if (requireOpen && idea.status !== 'open') {
547
+ res.fail('idea_not_open', 409, {
548
+ status: idea.status,
549
+ reason: 'only an open idea can be marked as awaiting development',
550
+ });
551
+ return null;
552
+ }
553
+ return idea;
554
+ }
555
+
556
+ // Mark a Quick Idea as a Big Idea Spark — "I have the spark but not the full
557
+ // picture, someone develop this". AUTHOR-ONLY: the mark is the author's consent
558
+ // to have their idea developed, so only they can set it. A Full Idea is refused
559
+ // (409) and a non-open idea cannot be newly marked; re-marking is a 200 no-op.
560
+ router.post('/inbox/:id/spark', api.requireBuilder, api.asyncHandler('POST /inbox/:id/spark', async (req, res) => {
561
+ const idea = await loadSparkable(req, res, { requireOpen: true });
562
+ if (!idea) return;
563
+ if (idea.grade === 'spark') {
564
+ return res.json({ idea, sparked: true, unchanged: true });
565
+ }
566
+ // grade only — a spark carries no template fields and no score (the R140
567
+ // migration's own shape: field_values is for a Full Idea). setIdeaGrade's
568
+ // defaults say exactly that, so they are left to speak for themselves.
569
+ const row = await inbox.setIdeaGrade(idea.id, { grade: 'spark' });
570
+ return res.json({
571
+ idea: { ...idea, grade: row.grade, template_key: row.template_key, field_values: row.field_values, completeness_score: row.completeness_score },
572
+ sparked: true,
573
+ unchanged: false,
574
+ });
575
+ }, { errorCode: 'idea_spark_failed' }));
576
+
577
+ // Withdraw the Big Idea Spark mark — the reversibility half of R144. AUTHOR-ONLY
578
+ // like the mark itself, and legal at ANY status so a stale mark on a promoted or
579
+ // discarded idea can always be cleared. Writes an explicit grade='quick' row,
580
+ // which reads identically to no grade row at all. Un-marking a quick idea is a
581
+ // 200 no-op; a Full Idea is refused (409) — it is not a withdrawn spark.
582
+ router.delete('/inbox/:id/spark', api.requireBuilder, api.asyncHandler('DELETE /inbox/:id/spark', async (req, res) => {
583
+ const idea = await loadSparkable(req, res, { requireOpen: false });
584
+ if (!idea) return;
585
+ if (idea.grade !== 'spark') {
586
+ return res.json({ idea, sparked: false, unchanged: true });
587
+ }
588
+ const row = await inbox.setIdeaGrade(idea.id, { grade: 'quick' });
589
+ return res.json({
590
+ idea: { ...idea, grade: row.grade, template_key: row.template_key, field_values: row.field_values, completeness_score: row.completeness_score },
591
+ sparked: false,
592
+ unchanged: false,
593
+ });
594
+ }, { errorCode: 'idea_unspark_failed' }));
595
+
446
596
  // Metic+ (#360 / ADR 0018): idea triage (promote/discard/merge) shapes the
447
597
  // backlog. Criterion C2 assigns triage to Metic, so this widened from
448
598
  // archon-only to metic+archon when the three-rank model went live. Any
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.294",
3
+ "version": "1.19.296",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.294",
9
+ "version": "1.19.296",
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.294",
3
+ "version": "1.19.296",
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",
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env node
2
+ // Mark one of YOUR OWN quick ideas as a Big Idea Spark — "I have the spark but
3
+ // not the full picture, someone develop this" (task 1002920 / BV1.R144, goal
4
+ // 1000062).
5
+ //
6
+ // The mark is the CONSENT that makes cross-builder development fair: until an
7
+ // author sets it, their quick idea is not available for someone else to develop.
8
+ // So this command only ever works on ideas you filed — the server enforces that
9
+ // against idea_inbox.captured_by, and no rank can override it.
10
+ //
11
+ // It exists as its own command rather than a flag on capture.js because sparking
12
+ // is an act on an EXISTING idea, not a way to file one (capture refuses
13
+ // grade='spark' for the same reason). The builders-hall control is R154; this is
14
+ // the terminal path, and the two hit the same route.
15
+ //
16
+ // --list is the other side of the same idea: the SPARK QUEUE (task 1002921 /
17
+ // BV1.R145) — what other builders have marked as needing development. It lives
18
+ // here rather than in its own script because "mark it" and "see what is marked"
19
+ // are one mental model, and a queue nobody can read is not a pickup surface. The
20
+ // queue is readable by ANY builder: the author's mark is already the consent.
21
+ //
22
+ // Usage:
23
+ // node scripts/gds/spark.js --list # the spark queue: what awaits development
24
+ // node scripts/gds/spark.js --list --limit 10
25
+ // node scripts/gds/spark.js 123 # mark idea 123 as a spark
26
+ // node scripts/gds/spark.js 123 --undo # withdraw the mark (back to quick)
27
+ //
28
+ // Exit codes:
29
+ // 0 the idea now carries the grade you asked for (including when it already did)
30
+ // 1 api / network error, or the server refused (not yours / not a quick idea)
31
+ // 2 bad invocation
32
+
33
+ const { cliClient, requireSession, hasFlag, arg, hallBase } = require('./cli-lib');
34
+
35
+ // The server's refusals, in the ideator's own words. Anything unmapped falls
36
+ // through to the raw envelope — a new server-side rule should be visible, not
37
+ // swallowed by a generic "failed".
38
+ const REFUSALS = {
39
+ not_your_idea: (d) =>
40
+ 'That idea is not yours to mark. The spark mark is the author\'s consent to have their\n'
41
+ + 'idea developed, so only the builder who filed it can give or withdraw it.',
42
+ not_a_quick_idea: (d) =>
43
+ `That idea is already a Full Idea (grade=${d.grade || 'full'}). It carries the developed\n`
44
+ + 'picture the spark mark asks for — there is nothing to hand off.',
45
+ idea_not_open: (d) =>
46
+ `That idea is ${d.status || 'no longer open'}, so it is not awaiting development.\n`
47
+ + '(You can still withdraw an existing mark with --undo.)',
48
+ idea_not_found: () => 'No idea with that id.',
49
+ };
50
+
51
+ // The envelope is ADR 0116's { error: { code, message, details } } — the extras a
52
+ // route passes as res.fail's third argument land under `details`, NOT spread onto
53
+ // `error`. Reading them off the wrong level is a silent "undefined" in the
54
+ // message rather than a crash, so tests/idea_spark_mark.mjs pins explain()
55
+ // against the real envelope() builder.
56
+ function explain(status, data) {
57
+ const err = data && data.error;
58
+ const code = err && typeof err === 'object' ? err.code : err;
59
+ const detail = (err && typeof err === 'object' && err.details) || {};
60
+ const say = typeof code === 'string' ? REFUSALS[code] : null;
61
+ if (say) return say(detail);
62
+ let rendered;
63
+ try { rendered = JSON.stringify(data, null, 2); } catch { rendered = String(data); }
64
+ return `spark failed (${status}): ${rendered}`;
65
+ }
66
+
67
+ // Render the queue. Kept separate from the fetch so it is testable without a
68
+ // session, and so the empty case reads as an answer ("nothing is waiting")
69
+ // rather than as a blank screen that looks like a failure.
70
+ function renderQueue({ sparks, count }) {
71
+ const lines = [];
72
+ if (!sparks || sparks.length === 0) {
73
+ lines.push('No sparks are awaiting development.');
74
+ lines.push('A spark is a quick idea whose AUTHOR marked it as needing development —');
75
+ lines.push('mark one of your own with: node scripts/gds/spark.js <idea-id>');
76
+ return lines.join('\n');
77
+ }
78
+ const shown = sparks.length;
79
+ const total = Number.isInteger(count) ? count : shown;
80
+ lines.push(`${total} spark${total === 1 ? '' : 's'} awaiting development${shown < total ? ` (showing ${shown})` : ''}:`);
81
+ lines.push('');
82
+ for (const s of sparks) {
83
+ // The hall link, never a bare "#NNN" — that autolinks to a GitHub issue and
84
+ // 404s, since our idea ids are not GitHub's (the CLAUDE.md refs rule).
85
+ lines.push(` ${hallBase()}/#/idea/${s.id}`);
86
+ lines.push(` ${s.title}`);
87
+ const who = s.filed_by_login ? `@${s.filed_by_login}` : 'an unnamed builder';
88
+ lines.push(` had by ${who}${s.captured_at ? ` · ${String(s.captured_at).slice(0, 10)}` : ''}`);
89
+ lines.push('');
90
+ }
91
+ lines.push('Develop one into a Full Idea and the author keeps the attribution.');
92
+ return lines.join('\n');
93
+ }
94
+
95
+ async function listQueue() {
96
+ const api = await cliClient();
97
+ const limitRaw = arg('--limit');
98
+ const limit = limitRaw != null && /^[0-9]+$/.test(limitRaw) ? Number(limitRaw) : null;
99
+ const r = await api.inbox.getInboxSparks(limit ? { query: { limit } } : {});
100
+ if (!r.ok) {
101
+ console.error(explain(r.status, r.data));
102
+ process.exit(1);
103
+ }
104
+ console.log(renderQueue(r.data || {}));
105
+ }
106
+
107
+ async function main() {
108
+ await requireSession();
109
+
110
+ if (hasFlag('--list')) return listQueue();
111
+
112
+ const positional = process.argv.slice(2).filter((a) => !a.startsWith('-'));
113
+ const id = Number(positional[0]);
114
+ const undo = hasFlag('--undo');
115
+
116
+ if (!Number.isInteger(id) || id <= 0) {
117
+ console.error('usage: node scripts/gds/spark.js <idea-id> [--undo]');
118
+ console.error(' node scripts/gds/spark.js --list [--limit N]');
119
+ process.exit(2);
120
+ }
121
+
122
+ const api = await cliClient();
123
+ const r = undo
124
+ ? await api.inbox.deleteInboxIdSpark({ id })
125
+ : await api.inbox.postInboxIdSpark({ id, body: {} });
126
+
127
+ if (!r.ok) {
128
+ console.error(explain(r.status, r.data));
129
+ process.exit(1);
130
+ }
131
+
132
+ const { idea, sparked, unchanged } = r.data;
133
+ const title = idea && idea.title ? ` — ${idea.title}` : '';
134
+ if (unchanged) {
135
+ console.log(`Idea ${id} was already ${sparked ? 'a spark' : 'a quick idea'}${title}. Nothing changed.`);
136
+ } else if (sparked) {
137
+ console.log(`Idea ${id} is now a Big Idea Spark${title}.`);
138
+ console.log('Another builder can develop it into a Full Idea; you keep the attribution.');
139
+ } else {
140
+ console.log(`Idea ${id} is a quick idea again${title}. The mark is withdrawn.`);
141
+ }
142
+ }
143
+
144
+ // explain() and renderQueue() are exported because both are pinned by tests —
145
+ // explain against the real envelope shape, renderQueue for the empty case and the
146
+ // hall-link form. REFUSALS stays private: it is the message table, reachable
147
+ // through explain(), and a third export would be dead surface.
148
+ module.exports = { explain, renderQueue };
149
+
150
+ if (require.main === module) {
151
+ main().catch((err) => {
152
+ console.error('fatal:', err);
153
+ process.exit(1);
154
+ });
155
+ }
@@ -416,6 +416,15 @@ const MODULE_EXPECTED_RANKS = {
416
416
  // requireBuilder + an in-handler author|triage gate. Deliberately a separate
417
417
  // route from the triage-gated PATCH /inbox/:id, which keeps its own rank.
418
418
  'PATCH /inbox/:id/body': 'any-builder',
419
+ // task 1002920 (BV1.R144): the Big Idea Spark mark is AUTHOR-ONLY, which is a
420
+ // narrower rule than any rank expresses — the gate is "whose thought is this",
421
+ // enforced in-handler against captured_by. Pinned 'any-builder' for the same
422
+ // reason POST /goals/:id/members is: the pin's job here is to catch an
423
+ // accidental TIGHTENING to a rank floor (which would lock authors out of their
424
+ // own ideas and break the consent the mark exists to record) as loudly as it
425
+ // catches a gate being dropped.
426
+ 'POST /inbox/:id/spark': 'any-builder',
427
+ 'DELETE /inbox/:id/spark': 'any-builder',
419
428
  'POST /inbox/:id/ratify': 'perm:idea.ratify',
420
429
  },
421
430
  // Cross-builder memory READS — the counterpart to the BFG cross-builder WRITE the
package/src/module-api.js CHANGED
@@ -1015,8 +1015,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1015
1015
  // landed since 1.19.292 with no explicit bump. run 32586174253. (task 1002620)
1016
1016
  // 1.19.294 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1017
1017
  // landed since 1.19.293 with no explicit bump. run 32588977219. (task 1002620)
1018
+ // 1.19.295 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1019
+ // landed since 1.19.294 with no explicit bump. run 32590508145. (task 1002620)
1020
+ // 1.19.296 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1021
+ // landed since 1.19.295 with no explicit bump. run 32591155887. (task 1002620)
1018
1022
  // ---------------------------------------------------------------------------
1019
- const CORE_VERSION = '1.19.294'; // CI auto-patch carrier (ADR 0161); see changelog above
1023
+ const CORE_VERSION = '1.19.296'; // CI auto-patch carrier (ADR 0161); see changelog above
1020
1024
 
1021
1025
  // A namespaced logger so a module's log lines are attributable + consistent.
1022
1026
  // Usage: const log = api.logger('dev-box'); log.info('mounted');