@bongos/core 1.19.1015 → 1.19.1016

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.
Files changed (53) hide show
  1. package/.bongos-core.json +98 -73
  2. package/.claude/skills/planning-session/SKILL.md +1 -1
  3. package/clients/bongos-client/index.d.ts +1 -1
  4. package/docs/adr/0331-the-sky-is-the-ideators-home.md +12 -0
  5. package/docs/adr/0335-an-invite-is-a-pre-approved-row-on-the-projects-own-instance.md +113 -0
  6. package/docs/adr/0336-a-guilds-record-is-the-sum-of-what-its-members-already-show.md +201 -0
  7. package/docs/adr/README.md +2 -0
  8. package/docs/api/openapi.json +4 -0
  9. package/docs/api-reference.md +1 -1
  10. package/docs/architecture.md +4 -1
  11. package/docs/file-map.md +0 -1
  12. package/docs/module-api-changelog.md +3 -1
  13. package/migrations/core_253_idea_filed_via.sql +59 -0
  14. package/modules/agents/pollers/event-router.js +2 -0
  15. package/modules/discord/discord-bugs.js +2 -0
  16. package/modules/discord/discord-inbound.js +3 -0
  17. package/modules/hall-ui/public/sky-data.js +22 -8
  18. package/modules/hall-ui/public/sky-draw.js +3 -2
  19. package/modules/hall-ui/public/sky-panel.js +1 -1
  20. package/modules/ideas/born-of.js +55 -0
  21. package/modules/ideas/inbox.js +23 -8
  22. package/modules/ideas/projection.js +48 -0
  23. package/modules/ideas/routes/inbox.js +20 -0
  24. package/modules/lifecycle/sky.js +25 -4
  25. package/modules/platform-identity/activity-snapshots.js +47 -0
  26. package/modules/platform-identity/migrations/platform_identity_022_activity_snapshots.sql +46 -0
  27. package/modules/platform-identity/platform-identity.js +13 -0
  28. package/modules/platform-identity/tests/activity-snapshots.mjs +68 -0
  29. package/modules/platform-identity/tests/platform-identity.mjs +21 -0
  30. package/modules/ui-design/skills/impeccable/SKILL.md +1 -1
  31. package/package-lock.json +2 -2
  32. package/package.json +1 -1
  33. package/scripts/gds/capture.js +11 -0
  34. package/scripts/gds/run-unit-tests.js +4 -0
  35. package/src/module-api.js +1 -1
  36. package/tests/activity_snapshots_db.mjs +97 -0
  37. package/tests/discord_bugs.mjs +1 -0
  38. package/tests/discord_inbound.mjs +1 -0
  39. package/tests/goal_map_page.mjs +14 -0
  40. package/tests/guild_namespace_reserved.mjs +76 -0
  41. package/tests/hall_sky.mjs +33 -16
  42. package/tests/idea_filed_via.mjs +185 -0
  43. package/tests/landing_page.mjs +8 -0
  44. package/tests/profile_rollup_consent.mjs +5 -1
  45. package/tests/project_admin_console_model.mjs +105 -0
  46. package/tests/sky_read.mjs +19 -2
  47. package/tests/ui_design_skills.mjs +9 -0
  48. package/modules/public-landing/public/world/assets/clouds/cloud-island-large.png +0 -0
  49. package/modules/public-landing/public/world/assets/clouds/cloud-puff.png +0 -0
  50. package/modules/public-landing/public/world/assets/creatures/creature-whale-cargo.png +0 -0
  51. package/modules/public-landing/public/world/assets/creatures/creature-whale-glide.png +0 -0
  52. package/modules/public-landing/public/world/assets/plates/plate-clouds-near.png +0 -0
  53. package/modules/public-landing/public/world/assets/plates/scene-establishing.png +0 -0
@@ -21,10 +21,13 @@
21
21
  // instance's version ids; the goal layout; and the thoughts — their state,
22
22
  // their placement, their words.
23
23
  //
24
- // TWO LAYERS ARE HIDDEN UNTIL PART 5 makes them ledger facts (the README's
25
- // "must not ship" rows): the "claude layer" pill and every born-of mark. The
26
- // code that draws them is kept, and it reads `filed_via` / `bornOf` that are
27
- // simply absent today — so part 5 turns them on by shipping the facts.
24
+ // TWO LAYERS BECAME LEDGER FACTS IN PART 5 (task 1004233; the README's "must
25
+ // not ship" rows, shipped): the "claude layer" reads `filed_via` off each row of
26
+ // GET /sky/mine — cli, api and discord wait behind the pill, hall and planning
27
+ // always show, and a row from before the column (NULL) shows and says "filed
28
+ // before the ledger recorded where" — and every born-of mark reads `born` off
29
+ // GET /sky (which thoughts became goals or rings, from the ratify tables).
30
+ // Nothing here guesses either from text.
28
31
  //
29
32
  // ONE PAGE, TWO DOORS (part 4, task 1004232). /thinking and /goal-map are the
30
33
  // same seven files over the same markup; the body's `data-sky-lens` says which
@@ -73,6 +76,15 @@
73
76
  const STOP = new Set('a an the to of in on for and or is it this that with from by as at be are was not no we you your our into onto when what how why should could would can will every each all any some one two page let me without does did has have had its their them then than but so if'.split(' '));
74
77
  function tokens(s) { return String(s || '').toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 2 && !STOP.has(w)); }
75
78
  const toMs = (iso) => iso ? new Date(iso).getTime() : 0;
79
+ // Where an idea was filed from (task 1004233): the five doors the ledger
80
+ // records, which of them a SESSION files through (the "claude layer" hides
81
+ // these until the pill is pressed), and the words the panel says for each.
82
+ const SESSION_VIAS = Object.freeze(['cli', 'api', 'discord']);
83
+ const VIA_WORDS = { hall: 'from the hall', cli: 'from the command line', discord: 'from Discord', planning: 'in a planning session', api: 'through the API' };
84
+ function viaWord(v) { return v == null ? 'filed before the ledger recorded where' : (VIA_WORDS[v] || 'through ' + v); }
85
+ // bornFromSky — GET /sky's `born` as string-keyed, string-valued maps; absent
86
+ // or half-shaped is empty (no mark is drawn on a guess).
87
+ function bornFromSky(b) { const m = (o) => { const out = {}; if (o && typeof o === 'object') for (const [k, v] of Object.entries(o)) if (v != null) out[String(k)] = String(v); return out; }; return { goals: m(b && b.goals), criteria: m(b && b.criteria) }; }
76
88
  // The hall's own doors — the prototype linked to a fixed hall origin; the
77
89
  // page links to the hall it is served from (apex /builders or the subdomain).
78
90
  const hallBase = () => (location.hostname.startsWith('builders.') ? '' : '/builders');
@@ -125,9 +137,10 @@
125
137
  size: i.size || null,
126
138
  state: st ? { ...st, wellGoalId: st.wellGoalId != null ? String(st.wellGoalId) : null } : null,
127
139
  mergedInto: i.merged_into_idea_id != null ? String(i.merged_into_idea_id) : null,
128
- // filed_via is part 5's fact. Until it exists every thought is a person's
129
- // (the owner: historic ideas with no filed_via SHOW by default).
130
- filedVia: i.filed_via ?? null, origin: i.filed_via === 'session' ? 'claude' : 'human',
140
+ // filed_via (part 5): a session's door puts the thought behind the claude
141
+ // layer; a person's door, and a row from before the column (NULL — the
142
+ // owner: historic ideas SHOW by default), do not.
143
+ filedVia: i.filed_via ?? null, origin: SESSION_VIAS.includes(i.filed_via) ? 'claude' : 'human',
131
144
  };
132
145
  }
133
146
 
@@ -184,6 +197,7 @@
184
197
  // Called once, after the reads; everything it derives lands on S.
185
198
  function buildWorld({ sky, mine, versions }) {
186
199
  S.ASPECT = clamp(innerWidth / Math.max(1, innerHeight), 1, 1.8);
200
+ S.BORN = bornFromSky(sky && sky.born);
187
201
  const BORN = S.BORN;
188
202
  const goals = ((sky && sky.goals) || []).map(goalFromSky).map((g) => ({ ...g, bornOf: BORN.goals[g.id] || null, born: toMs(g.createdAt) }));
189
203
  const goalById = new Map(goals.map((g) => [g.id, g]));
@@ -299,7 +313,7 @@
299
313
 
300
314
  Object.assign(SKY, {
301
315
  S, $, TAU, GOLDEN, reduce, NOW, hash, rng, clamp, lerp, smooth, ease, esc, fmtDate, fmtDay, ageDays, plain, short, store, tokens, toMs, hallBase, API, api,
302
- OBJ, objects, goalFromSky, ideaFromMine, bandsFor, wellRadius, ringR, taskAt, buildWorld, edgesOf,
316
+ OBJ, objects, SESSION_VIAS, viaWord, bornFromSky, goalFromSky, ideaFromMine, bandsFor, wellRadius, ringR, taskAt, buildWorld, edgesOf,
303
317
  applyRehearsal, inLayer, shown, diameter, positionWord, brightnessWord, reach, became, temp, placeIdeas, touched, load,
304
318
  });
305
319
  })();
@@ -7,8 +7,9 @@
7
7
  // thicker with credits), bodies are tasks past the zoom threshold and dust
8
8
  // below it, and a thought is one of the five objects — its SIZE the credits
9
9
  // its descent was paid (never smaller for being dead), its BRIGHTNESS how
10
- // much of that work still stands. Born-of marks and the "filed by a session"
11
- // accent read facts part 5 ships; until then they are simply never true.
10
+ // much of that work still stands. Born-of marks read GET /sky's `born` and the
11
+ // "filed by a session" accent reads `filed_via` — ledger facts since part 5
12
+ // (task 1004233), never a guess from text.
12
13
 
13
14
  (() => {
14
15
  'use strict';
@@ -41,7 +41,7 @@
41
41
  ${i.size ? fact('of which', esc(sizeWords(i))) : ''}
42
42
  ${bright ? fact('brightness — still alive', `<span class="bar" style="width:${Math.max(4, Math.round(i.lit * 70))}px;${i.lit > 0 ? '' : 'background:rgba(255,255,255,.2)'}"></span>${esc(bright)}`) : ''}
43
43
  ${pos ? fact('position', esc(pos)) : ''}
44
- ${fact('filed', esc(fmtDate(i.capturedAt)) + (i.kind ? ` · ${esc(i.kind)}` : '') + (i.grade === 'full' ? ' · full idea' : '') + (i.origin === 'claude' ? ' · by a session, under your name' : ''))}
44
+ ${fact('filed', esc(fmtDate(i.capturedAt)) + (i.kind ? ` · ${esc(i.kind)}` : '') + (i.grade === 'full' ? ' · full idea' : '') + ' · ' + esc(SKY.viaWord(i.filedVia)) + (i.origin === 'claude' ? ' · by a session, under your name' : ''))}
45
45
  </div>
46
46
  ${i.fields && FIELDS.length ? `<div><span class="lbl">the five answers · ${SKY.completeness(i.fields)} of ${FIELDS.length} above the floor</span><ul class="orbit-list">${FIELDS.map((f) => `<li><span class="st">${esc(f.key.replace('_', ' '))}</span><span>${esc(i.fields[f.key] || '— skipped')}</span></li>`).join('')}</ul></div>` : ''}
47
47
  ${i.lineage.length ? `<div><span class="lbl">the descent — everything that fell out of it</span><ul class="orbit-list">${[...i.lineage, ...i.descentTasks].map((t) => `<li><span class="st${t.status === 'active' ? ' live' : ''}">${esc(t.status)}</span><span>${esc(t.title)}${t.goalId && t.goalId !== i.well && goalById.get(t.goalId) ? ` <span class="lbl">in ${esc(short(goalById.get(t.goalId).title))}</span>` : ''}</span></li>`).join('')}</ul></div>` : ''}
@@ -0,0 +1,55 @@
1
+ 'use strict';
2
+
3
+ // modules/ideas/born-of.js — WHICH THOUGHTS BECAME THE STRUCTURE (task 1004233,
4
+ // sky part 5 of 5; goal 1000095; ADR 0331 §4).
5
+ //
6
+ // The sky draws a goal born of a thought with a nucleus ring, a ring (a
7
+ // criterion) born of one with an accent notch, and places the thought itself at
8
+ // the well's core or on its ring. The prototype found "born of" by scanning goal
9
+ // and criterion text for idea ids; this read answers it from the two tables the
10
+ // ratify paths already write, so it is a ledger fact and never a guess:
11
+ // goals — ideas_goal_ratifications (ratify.js ratifyIdeaAsGoal): one row
12
+ // per idea → goal
13
+ // criteria — criterion_proposal_audit (ratify.js ratifyCriterionProposal):
14
+ // one row per idea → criterion
15
+ // The FIRST ratification names the thought (ordered by ratified_at, then id);
16
+ // ids come back as strings keyed by the thing born, so a page looks a well or a
17
+ // ring up directly:
18
+ // { goals: { [goal_id]: idea_id }, criteria: { [criterion_id]: idea_id } }
19
+ //
20
+ // Served through the `ideas.bornOf` PORT (registered beside `ideas.capture` in
21
+ // routes/inbox.js) so the lifecycle module's GET /sky can fold it in as `born`
22
+ // without importing this module or reading an ideas_-prefixed table itself
23
+ // (ADR 0083's one-way rule). An instance without the port gets an empty map,
24
+ // which the pages draw as "nothing is known", not "nothing was born".
25
+
26
+ const api = require('../../src/module-api');
27
+ const { pool } = api;
28
+
29
+ function emptyBorn() { return { goals: {}, criteria: {} }; }
30
+
31
+ // readBornOf — the two reads, folded first-wins into plain objects keyed by the
32
+ // born thing's id. deps.pool honours the test seam.
33
+ async function readBornOf(deps = {}) {
34
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
35
+ const [goals, criteria] = await Promise.all([
36
+ activePool.query(
37
+ `SELECT goal_id::text AS goal_id, idea_id::text AS idea_id
38
+ FROM ideas_goal_ratifications
39
+ ORDER BY ratified_at, id`
40
+ ),
41
+ activePool.query(
42
+ `SELECT criterion_id::text AS criterion_id, idea_id::text AS idea_id
43
+ FROM criterion_proposal_audit
44
+ ORDER BY ratified_at, id`
45
+ ),
46
+ ]);
47
+ const firstWins = (rows, key) => {
48
+ const m = new Map();
49
+ for (const r of rows) if (r && r[key] != null && r.idea_id != null && !m.has(String(r[key]))) m.set(String(r[key]), String(r.idea_id));
50
+ return Object.fromEntries(m);
51
+ };
52
+ return { goals: firstWins(goals.rows, 'goal_id'), criteria: firstWins(criteria.rows, 'criterion_id') };
53
+ }
54
+
55
+ module.exports = { readBornOf, emptyBorn };
@@ -32,6 +32,7 @@ const {
32
32
  IDEA_COLS, IDEA_COLS_Q, GRADE_COLS_Q, IDEA_JOIN_GRADE, ungradedProjection,
33
33
  DEFAULT_GRADE, VALID_GRADES, DEFAULT_TEMPLATE_KEY, TEMPLATE_KEY_MAX, FIELD_VALUES_MAX,
34
34
  VALID_RECOMMENDATIONS, isValidRecommendation, normalizeRecommendation,
35
+ FILED_VIA_VALUES, isValidFiledVia, filedViaForSource, resolveFiledVia,
35
36
  } = require('./projection');
36
37
  // The carved domains. Each is re-exported at the bottom of this file, so every
37
38
  // existing caller of `require('./inbox')` keeps working unchanged — the same
@@ -170,6 +171,13 @@ async function captureIdea({
170
171
  // anything). Ignored when `goalId` is already set, so every existing caller
171
172
  // is untouched.
172
173
  suggestedGoalId = null,
174
+ // task 1004233 (sky part 5): WHICH DOOR the idea came through, as a ledger
175
+ // fact on the row — hall | cli | discord | planning | api (projection.js
176
+ // FILED_VIA_VALUES). The route reads it off the session; a port caller (the
177
+ // Discord bot, an agent poller) names its own. 'api' is the catch-all for a
178
+ // program that says nothing: a NEW row always records a door — NULL is
179
+ // reserved for rows older than the column and is never written here.
180
+ filedVia = 'api',
173
181
  // WHICH DOOR this filing came through; absent is UNTRUSTED and stays an inbox
174
182
  // row. Read by one rule — see routing.HOMELESS_ROUTING_VECTORS (ADR 0262).
175
183
  vector = null,
@@ -187,6 +195,9 @@ async function captureIdea({
187
195
  ) {
188
196
  throw { code: 'BAD_PRIORITY', priority: suggestedPriority };
189
197
  }
198
+ if (!isValidFiledVia(filedVia)) {
199
+ throw { code: 'BAD_FILED_VIA', filedVia, allowed: [...FILED_VIA_VALUES] };
200
+ }
190
201
  const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
191
202
 
192
203
  // The decision, before any write. Pure — see ./routing.js.
@@ -225,7 +236,7 @@ async function captureIdea({
225
236
  if (landing.route) {
226
237
  return routeIdeaToGoal({
227
238
  title, bodyMd, resolvedKind, suggestedVersion, suggestedPriority,
228
- capturedBy, goalId, landing, filerRank, touches,
239
+ capturedBy, goalId, landing, filerRank, touches, filedVia,
229
240
  }, activePool);
230
241
  }
231
242
 
@@ -236,12 +247,12 @@ async function captureIdea({
236
247
  const { rows } = await activePool.query(
237
248
  `INSERT INTO idea_inbox
238
249
  (title, body_md, kind, suggested_version, suggested_priority, captured_by, status,
239
- suggested_goal_id)
250
+ suggested_goal_id, filed_via)
240
251
  VALUES ($1, $2, $3, $4, $5, $6, 'open',
241
- $7)
252
+ $7, $8)
242
253
  RETURNING ${IDEA_COLS}`,
243
254
  [title, bodyMd, resolvedKind, suggestedVersion, suggestedPriority, capturedBy,
244
- goalId ?? suggestedGoalId ?? null]
255
+ goalId ?? suggestedGoalId ?? null, filedVia]
245
256
  );
246
257
  // A brand-new idea has no ideas_grades row, so it is quick by definition —
247
258
  // no join needed to know that. Grading it (R143's Full Idea capture) is a
@@ -266,7 +277,7 @@ async function captureIdea({
266
277
  // the same two-step ratifyCriterionProposal takes.
267
278
  async function routeIdeaToGoal({
268
279
  title, bodyMd, resolvedKind, suggestedVersion, suggestedPriority,
269
- capturedBy, goalId, landing, filerRank = null, touches = null,
280
+ capturedBy, goalId, landing, filerRank = null, touches = null, filedVia = 'api',
270
281
  }, activePool) {
271
282
  const client = await activePool.connect();
272
283
  try {
@@ -326,12 +337,12 @@ async function routeIdeaToGoal({
326
337
  const { rows: ideaRows } = await client.query(
327
338
  `INSERT INTO idea_inbox
328
339
  (title, body_md, kind, suggested_version, suggested_priority, captured_by, status,
329
- suggested_goal_id, reviewed_at)
340
+ suggested_goal_id, reviewed_at, filed_via)
330
341
  VALUES ($1, $2, $3, $4, $5, $6, 'promoted',
331
- $7, now())
342
+ $7, now(), $8)
332
343
  RETURNING ${IDEA_COLS}`,
333
344
  [title, bodyMd, resolvedKind, suggestedVersion, suggestedPriority, capturedBy,
334
- effectiveGoalId]
345
+ effectiveGoalId, filedVia]
335
346
  );
336
347
  const idea = ideaRows[0];
337
348
 
@@ -802,6 +813,10 @@ module.exports = {
802
813
  // the same place the SQL CHECK mirrors
803
814
  VALID_RECOMMENDATIONS,
804
815
  isValidRecommendation,
816
+ FILED_VIA_VALUES,
817
+ isValidFiledVia,
818
+ filedViaForSource,
819
+ resolveFiledVia,
805
820
  normalizeRecommendation,
806
821
  // skill-friction marker + rank visibility gate (V3.R70 / #289; the kind
807
822
  // itself landed in task 1003105 — both markers are live, see above)
@@ -25,6 +25,11 @@ const IDEA_COL_NAMES = [
25
25
  'id', 'title', 'body_md', 'kind', 'suggested_version', 'suggested_priority',
26
26
  'captured_at', 'captured_by', 'status', 'promoted_to_task_id', 'reviewed_at',
27
27
  'merged_into_idea_id', 'triage_note',
28
+ // core_253_idea_filed_via (task 1004233, sky part 5): WHICH DOOR the idea came through
29
+ // (hall | cli | discord | planning | api), or NULL for a row older than the
30
+ // column. On the shared list so every read carries it — GET /sky/mine is the
31
+ // trail's own projection and the sky's "claude layer" reads it off the row.
32
+ 'filed_via',
28
33
  // core_222 (task 1003060, BV1.R02): the routing HINT — "this probably belongs
29
34
  // to goal N" — on a row that did NOT convert. Added to the shared list because
30
35
  // R02 shipped the column and nothing read it: /idea-triage (R13) cannot walk
@@ -34,6 +39,44 @@ const IDEA_COL_NAMES = [
34
39
  'suggested_goal_id',
35
40
  ];
36
41
  const IDEA_COLS = IDEA_COL_NAMES.join(', ');
42
+
43
+ // -------------------------------------------------------------------------
44
+ // Where an idea was filed from (task 1004233 / sky part 5, goal 1000095; ADR 0331 §4)
45
+ //
46
+ // The closed set idea_inbox_filed_via_known mirrors (core_253_idea_filed_via). NULL — a row
47
+ // older than the column — is a sixth, honest answer the sky speaks as "filed
48
+ // before the ledger recorded where"; it is never backfilled from text, which
49
+ // would put the prototype's guess into the ledger.
50
+ //
51
+ // THE DOOR IS READ OFF THE SESSION, not claimed in the body. A web cookie is the
52
+ // hall (the ideas form, the sky's composer), a device-flow token is the CLI
53
+ // (capture.js, spark.js), anything else (a box token, a program on the port) is
54
+ // the API — so a filer cannot say they came through a door they did not. The one
55
+ // value a body may ADD is 'planning': a planning session's capture is a CLI
56
+ // filing no session can tell apart, so capture.js --via planning names it. The
57
+ // Discord bot names 'discord' itself: it calls the port, not the route.
58
+ const FILED_VIA_VALUES = Object.freeze(['hall', 'cli', 'discord', 'planning', 'api']);
59
+ const isValidFiledVia = (v) => typeof v === 'string' && FILED_VIA_VALUES.includes(v);
60
+ function filedViaForSource(source) {
61
+ return source === 'web' ? 'hall' : source === 'cli' ? 'cli' : 'api';
62
+ }
63
+ // resolveFiledVia — the session's door, unless the body names 'planning' (or
64
+ // repeats the session's own door, which is harmless). Anything else is refused
65
+ // with the reason and the one value that may be named.
66
+ function resolveFiledVia(source, claimed) {
67
+ const own = filedViaForSource(source);
68
+ if (claimed === undefined || claimed === null || claimed === own) return { ok: true, value: own };
69
+ if (claimed === 'planning') return { ok: true, value: 'planning' };
70
+ return {
71
+ ok: false,
72
+ detail: {
73
+ filed_via: claimed, session_door: own, allowed: ['planning', own],
74
+ reason: isValidFiledVia(claimed)
75
+ ? `filed_via is read off your session (${own}); the only door a filing may add is 'planning'.`
76
+ : `filed_via must be one of ${FILED_VIA_VALUES.join(', ')} — and only 'planning' may be named by a filing.`,
77
+ },
78
+ };
79
+ }
37
80
  const IDEA_COLS_Q = IDEA_COL_NAMES.map((c) => `i.${c}`).join(', ');
38
81
 
39
82
  // -------------------------------------------------------------------------
@@ -153,6 +196,11 @@ module.exports = {
153
196
  VALID_RECOMMENDATIONS,
154
197
  isValidRecommendation,
155
198
  normalizeRecommendation,
199
+ FILED_VIA_VALUES,
200
+ isValidFiledVia,
201
+ filedViaForSource,
202
+ resolveFiledVia,
203
+ IDEA_COL_NAMES,
156
204
  GRADE_COLS_Q,
157
205
  IDEA_JOIN_GRADE,
158
206
  ungradedProjection,
@@ -35,6 +35,7 @@
35
35
  const express = require('express');
36
36
  const api = require('../../../src/module-api');
37
37
  const inbox = require('../inbox');
38
+ const bornOf = require('../born-of');
38
39
  // The trail read to the FULL depth of the descent (task 1004089, BV2.R09). Its
39
40
  // own file because inbox.js is AT the size ratchet and because a task->task
40
41
  // walk is a different relation from the idea->task trail.
@@ -95,6 +96,12 @@ function registerSeams() {
95
96
  if (!api.hasProvider('ideas.capture')) {
96
97
  api.registerProvider('ideas.capture', inbox.captureIdea);
97
98
  }
99
+ // task 1004233 (sky part 5): which thoughts became goals or rings, for the
100
+ // lifecycle module's GET /sky to fold in as `born` — through the port, so
101
+ // lifecycle never reads an ideas_ table itself (./born-of.js).
102
+ if (!api.hasProvider('ideas.bornOf')) {
103
+ api.registerProvider('ideas.bornOf', bornOf.readBornOf);
104
+ }
98
105
  if (!api.hasProvider('ideas.descent')) {
99
106
  api.registerProvider('ideas.descent', {
100
107
  ancestorsOfTask: descent.ancestorsOfTask,
@@ -176,8 +183,19 @@ module.exports = function buildInboxRouter() {
176
183
  // turned out: 'goal' or 'criterion'. OPTIONAL, permanently (see the
177
184
  // refusal below and ideas_009's header).
178
185
  recommendation: { type: 'string', maxLength: 16 },
186
+ // task 1004233 (sky part 5): WHICH DOOR. Read off the session — never a
187
+ // claim — except 'planning' (capture.js --via planning); see below.
188
+ filed_via: { type: 'string', maxLength: 16 },
179
189
  })) return;
180
190
  const body = req.body || {};
191
+ // task 1004233 (sky part 5): the door is a FACT read off the session that
192
+ // made the request (projection.js resolveFiledVia: a web cookie is the hall,
193
+ // a device-flow token is the CLI, anything else the API). The body may add
194
+ // only 'planning' — the one door no session can tell from a CLI capture —
195
+ // and any other claim is refused, so a filer cannot say they came through a
196
+ // door they did not.
197
+ const door = inbox.resolveFiledVia(req.gdsSession && req.gdsSession.source, body.filed_via);
198
+ if (!door.ok) return res.fail('bad_filed_via', 400, door.detail);
181
199
  if (body.kind === 'criterion-proposal') {
182
200
  // FAIL CLOSED (task 1003197 (D)). This was inline and read
183
201
  // `if (rank && rank !== 'metic' && …)`, so an ABSENT rank ADMITTED. The
@@ -253,6 +271,7 @@ module.exports = function buildInboxRouter() {
253
271
  goalId: body.goal_id ?? null,
254
272
  filerRank: req.builder?.rank ?? null,
255
273
  grade,
274
+ filedVia: door.value,
256
275
  // task 1003691 (ADR 0262): the vector, hardcoded — NEVER read from the
257
276
  // body. This route is the authenticated API door, so a bug filed here
258
277
  // with no goal routes to the version's maintenance goal instead of
@@ -367,6 +386,7 @@ module.exports = function buildInboxRouter() {
367
386
  err.code === 'TITLE_REQUIRED' ? 400 :
368
387
  err.code === 'BAD_KIND' ? 400 :
369
388
  err.code === 'BAD_PRIORITY' ? 400 :
389
+ err.code === 'BAD_FILED_VIA' ? 400 :
370
390
  // task 1003065 (BV1.R01) — the routing refusals. A named goal that is
371
391
  // not a live home is the FILER'S mistake to fix (pick another goal, or
372
392
  // omit it and file to the inbox), so these are 4xx with the offending
@@ -137,12 +137,17 @@ function foldCriteria(rows = []) {
137
137
  // satisfied, pending_review, task_ids }], tasks: [{ id, title, status,
138
138
  // credits_reward, created_on, idea_id }] }]
139
139
  // edges: [{ from, to, count }] — `from` waits on `to`, the graph's own
140
+ // born: { goals: { goal_id: idea_id }, criteria: { criterion_id: idea_id } }
141
+ // — which thoughts became the structure (task
142
+ // 1004233): the ideas module's own read through the
143
+ // `ideas.bornOf` port, ids as strings; an absent
144
+ // port is an EMPTY map, never a guess
140
145
  // counts: { goals, edges, criteria, tasks }
141
146
  //
142
147
  // A criterion or a task whose goal is not among the wells is DROPPED — the wells
143
148
  // are the graph's nodes (goals carrying at least one task, in the version asked
144
149
  // for), and a ring drawn around no well is a line pointing at nothing.
145
- function shapeSky({ goals = [], edges = [], extras = [], criteria = [], tasks = [] } = {}, categoryById = new Map()) {
150
+ function shapeSky({ goals = [], edges = [], extras = [], criteria = [], tasks = [], born = null } = {}, categoryById = new Map()) {
146
151
  const graph = goalGraph.shapeGraph({ goals, edges }, categoryById);
147
152
  const visibleById = new Map(goals.map((g) => [String(g.goal_id), g.visible !== false]));
148
153
  const extraById = new Map(extras.map((x) => [String(x.goal_id), x]));
@@ -189,18 +194,34 @@ function shapeSky({ goals = [], edges = [], extras = [], criteria = [], tasks =
189
194
  return {
190
195
  goals: out,
191
196
  edges: graph.edges,
197
+ born: normalizeBorn(born),
192
198
  counts: { goals: out.length, edges: graph.edges.length, criteria: criteriaCount, tasks: taskCount },
193
199
  };
194
200
  }
195
201
 
202
+ // normalizeBorn — the port's answer as string-keyed, string-valued maps; a null,
203
+ // a throw or a half-shaped answer is the empty map (the page draws no mark).
204
+ function normalizeBorn(born) {
205
+ const strMap = (o) => {
206
+ const out = {};
207
+ if (o && typeof o === 'object') for (const [k, v] of Object.entries(o)) if (v != null) out[String(k)] = String(v);
208
+ return out;
209
+ };
210
+ return { goals: strMap(born && born.goals), criteria: strMap(born && born.criteria) };
211
+ }
212
+
196
213
  // sky — read + shape. `categoryIndex` is injectable so the whole path runs in a
197
214
  // test with a fake pool and no category table (the goal-graph.js precedent).
198
215
  async function sky(opts = {}, deps = {}) {
199
- const [rows, byId] = await Promise.all([
216
+ // task 1004233: born of a thought is the IDEAS module's read, reached through
217
+ // its port — one of the two reads is enough to fail quietly into an empty map.
218
+ const readBorn = 'readBornOf' in deps ? deps.readBornOf : (api.resolveOptional ? api.resolveOptional('ideas.bornOf') : null);
219
+ const [rows, byId, born] = await Promise.all([
200
220
  skyRows(opts, deps),
201
221
  (deps.categoryIndex || goalCategory.categoryIndex)(deps),
222
+ typeof readBorn === 'function' ? readBorn().catch(() => null) : null,
202
223
  ]);
203
- return shapeSky(rows, byId);
224
+ return shapeSky({ ...rows, born }, byId);
204
225
  }
205
226
 
206
227
  // skyCache — the 60-second memo, keyed by what decides what the viewer may see.
@@ -258,4 +279,4 @@ function skyHandler({ read = sky, log = console, cache = skyCache() } = {}) {
258
279
  };
259
280
  }
260
281
 
261
- module.exports = { sky, skyRows, shapeSky, foldCriteria, skyCache, skyHandler, SKY_CACHE_TTL_MS };
282
+ module.exports = { sky, skyRows, shapeSky, foldCriteria, normalizeBorn, skyCache, skyHandler, SKY_CACHE_TTL_MS };
@@ -0,0 +1,47 @@
1
+ // modules/platform-identity/activity-snapshots.js — the windowed data plane's
2
+ // append-only history (community spec D2, task 1002977): one row per rollup
3
+ // report WHOSE TOTALS CHANGED, so a leaderboard window (7d/30d/...) can compute
4
+ // "latest minus the last snapshot at-or-before the window start" without a core
5
+ // change (option B) or an unbounded event feed (option C) — both rejected for V1.
6
+ //
7
+ // Reporting-time semantics, not earning-time: a builder who signs in fortnightly
8
+ // lands two weeks of credits in the sign-in week. Honest, accepted V1 limitation
9
+ // (see the spec); self-correcting on the builder's next report.
10
+ //
11
+ // Compaction (daily granularity after 90 days) is a later, volume-triggered task
12
+ // — nothing here prunes. A disconnected/re-registered project's snapshots are
13
+ // likewise not purged here — platform_identity_builder_project_activity (the
14
+ // table this mirrors) has the identical gap today; the future leaderboard reader
15
+ // is where consent (rollupVisibleSql) is enforced, per that table's own precedent.
16
+ 'use strict';
17
+
18
+ const { pool: defaultPool } = require('../../src/module-api');
19
+
20
+ // Append one snapshot row iff the totals differ from the last one on record for
21
+ // this (client, builder) pair — ONE statement, not a read then a write: the CTE
22
+ // and the conditional INSERT run in the same round trip, closing the two-round-
23
+ // trip check-then-insert race the earlier design had. A DB-level race remains
24
+ // under READ COMMITTED — two truly concurrent statements for the same pair can
25
+ // each see no conflicting row and both insert — but the worst case is a harmless
26
+ // duplicate snapshot with IDENTICAL totals, which reads the same either way in
27
+ // the windowed-delta arithmetic. A first-ever report (no prior row) always
28
+ // inserts — NOT EXISTS is true against an empty CTE regardless of the
29
+ // comparison. `id DESC` breaks a tie on an identical `reported_at` so "the last
30
+ // snapshot" is always one specific row.
31
+ async function recordActivitySnapshot({ clientId, githubId, credits = 0, tasksShipped = 0, karma = 0 }, { pool = defaultPool } = {}) {
32
+ const { rows } = await pool.query(
33
+ `WITH last AS (
34
+ SELECT credits, tasks_shipped, karma FROM platform_identity_activity_snapshots
35
+ WHERE client_id = $1 AND github_id = $2
36
+ ORDER BY reported_at DESC, id DESC LIMIT 1
37
+ )
38
+ INSERT INTO platform_identity_activity_snapshots (client_id, github_id, credits, tasks_shipped, karma, reported_at)
39
+ SELECT $1, $2, $3, $4, $5, now()
40
+ WHERE NOT EXISTS (SELECT 1 FROM last WHERE credits = $3 AND tasks_shipped = $4 AND karma = $5)
41
+ RETURNING id, reported_at`,
42
+ [clientId, githubId, credits, tasksShipped, karma],
43
+ );
44
+ return rows[0] || null;
45
+ }
46
+
47
+ module.exports = { recordActivitySnapshot };
@@ -0,0 +1,46 @@
1
+ -- platform_identity_022_activity_snapshots.sql — the windowed data plane's ONE
2
+ -- new table (community spec D2, task 1002977): an append-only history of each
3
+ -- (project, builder)'s reported totals, one row per rollup report WHOSE TOTALS
4
+ -- CHANGED (a no-op re-report inserts nothing — upsertActivity skips it via
5
+ -- activity-snapshots.js:recordActivitySnapshot).
6
+ --
7
+ -- A window's value = latest totals (platform_identity_builder_project_activity)
8
+ -- minus the last snapshot at-or-before the window start, summed across projects;
9
+ -- missing baseline counts as zero (D2 option A, chosen over B/C — see the spec).
10
+ -- Reporting-time semantics, not earning-time: the honest, accepted V1 limitation.
11
+ --
12
+ -- Retention: nothing pruned here. A compaction pass (daily granularity after 90
13
+ -- days) is a later, volume-triggered task, not built now.
14
+ --
15
+ -- Additive + platform_identity_-namespaced (ADR 0083 §5). Idempotent.
16
+
17
+ BEGIN;
18
+
19
+ CREATE TABLE IF NOT EXISTS platform_identity_activity_snapshots (
20
+ id bigserial PRIMARY KEY,
21
+
22
+ -- The project (the sso_client that reported) + the builder (stable github id) —
23
+ -- the same pair platform_identity_builder_project_activity is keyed on.
24
+ client_id text NOT NULL,
25
+ github_id bigint NOT NULL,
26
+
27
+ -- The reported totals AT this point in time (a snapshot, not a delta).
28
+ credits integer NOT NULL DEFAULT 0,
29
+ tasks_shipped integer NOT NULL DEFAULT 0,
30
+ karma integer NOT NULL DEFAULT 0,
31
+
32
+ reported_at timestamptz NOT NULL DEFAULT now()
33
+ );
34
+
35
+ -- The windowed read's whole access path: "the last snapshot at-or-before a given
36
+ -- timestamp, for this (project, builder)". DESC so the common query (the most
37
+ -- recent snapshot <= window start) hits the front of the index; `id DESC`
38
+ -- matches activity-snapshots.js's own tiebreak, so its top-1 probe is a pure
39
+ -- index-order LIMIT 1 rather than relying on an incremental sort.
40
+ CREATE INDEX IF NOT EXISTS platform_identity_activity_snapshots_builder_time
41
+ ON platform_identity_activity_snapshots (client_id, github_id, reported_at DESC, id DESC);
42
+
43
+ COMMENT ON TABLE platform_identity_activity_snapshots IS
44
+ 'Append-only history of reported totals per (client_id, github_id), one row per rollup report whose totals changed (community spec D2, task 1002977). A leaderboard window computes latest minus the last snapshot at-or-before the window start; missing baseline counts as zero. Reporting-time semantics, not earning-time. Nothing pruned in V1 — a later, volume-triggered compaction task handles retention.';
45
+
46
+ COMMIT;
@@ -24,6 +24,9 @@ const terms = require('./terms-acceptance');
24
24
  // reads the SAME rule as a ship link instead of the "different host" check it had —
25
25
  // see origin-containment.js for why it is downward only and why it lives there.
26
26
  const { urlWithinOrigin } = require('./origin-containment');
27
+ // The windowed data plane's append-only snapshot history (task 1002977, community
28
+ // spec D2) — one call from upsertActivity below; the mechanism itself lives there.
29
+ const { recordActivitySnapshot } = require('./activity-snapshots');
27
30
 
28
31
  const { pool: defaultPool } = api;
29
32
  const { ACCOUNT_VISIBILITY, accountVisibleSql, rollupVisibleSql, isRollupVisible, scoutingListableSql } = visibility;
@@ -677,6 +680,16 @@ async function upsertActivity({ clientId, githubId, credits = 0, tasksShipped =
677
680
  // Postgres ARRAY literal, which the jsonb column would reject.
678
681
  [clientId, githubId, credits | 0, tasksShipped | 0, karma | 0, safeRank, disc, JSON.stringify(ships)],
679
682
  );
683
+ // Windowed data plane (task 1002977): one snapshot per report whose totals
684
+ // changed, skipped on a no-op re-report — see activity-snapshots.js.
685
+ // Best-effort, the application-echo.js precedent: descriptive analytics, never
686
+ // authority, so a fault here (e.g. migration 022 not yet applied on this
687
+ // instance) must never fail the rollup report that already committed above.
688
+ try {
689
+ await recordActivitySnapshot({ clientId, githubId, credits: credits | 0, tasksShipped: tasksShipped | 0, karma: karma | 0 }, { pool });
690
+ } catch (err) {
691
+ api.logger('platform-identity').error(`activity snapshot write failed (non-fatal): ${err && err.message}`);
692
+ }
680
693
  return rows[0];
681
694
  }
682
695
 
@@ -0,0 +1,68 @@
1
+ // modules/platform-identity/tests/activity-snapshots.mjs
2
+ //
3
+ // DB-free unit suite for the windowed data plane's append-only snapshot history
4
+ // (community spec D2, task 1002977). Recording-fake-pool style, matching
5
+ // tests/platform-identity.mjs: proves the SQL SHAPE (one statement, correct
6
+ // table/columns, bound params, the tiebreak) — a fake pool cannot execute the
7
+ // `WHERE NOT EXISTS` itself, so the skip-on-unchanged BEHAVIOUR is proved
8
+ // against real Postgres in tests/activity_snapshots_db.mjs instead.
9
+ //
10
+ // Run standalone: node modules/platform-identity/tests/activity-snapshots.mjs
11
+
12
+ import { strict as assert } from 'node:assert';
13
+ import { createRequire } from 'node:module';
14
+
15
+ const require = createRequire(import.meta.url);
16
+ const { recordActivitySnapshot } = require('../activity-snapshots.js');
17
+
18
+ let passed = 0, failed = 0;
19
+ async function ta(name, fn) {
20
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
21
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
22
+ }
23
+
24
+ // A recording fake pool: returns queued responses in order and records every call.
25
+ function mockPool(responses = []) {
26
+ const calls = [];
27
+ let i = 0;
28
+ return {
29
+ calls,
30
+ query: async (text, params) => {
31
+ calls.push({ text: String(text), params: params || [] });
32
+ const r = responses[i++];
33
+ return r || { rows: [], rowCount: 0 };
34
+ },
35
+ };
36
+ }
37
+
38
+ console.log('recordActivitySnapshot (community spec D2, task 1002977):');
39
+
40
+ await ta('one statement: a CTE for the last snapshot, guarding a conditional INSERT', async () => {
41
+ const pool = mockPool([{ rows: [{ id: '1', reported_at: 'T1' }] }]);
42
+ const out = await recordActivitySnapshot({ clientId: 'c1', githubId: 42, credits: 100, tasksShipped: 5, karma: 3 }, { pool });
43
+ assert.equal(pool.calls.length, 1, 'one round trip, not a read then a write');
44
+ const c = pool.calls[0];
45
+ assert.match(c.text, /WITH last AS/);
46
+ assert.match(c.text, /FROM platform_identity_activity_snapshots/);
47
+ assert.match(c.text, /ORDER BY reported_at DESC, id DESC LIMIT 1/, 'a deterministic tiebreak on an identical reported_at');
48
+ assert.match(c.text, /INSERT INTO platform_identity_activity_snapshots/);
49
+ assert.match(c.text, /WHERE NOT EXISTS \(SELECT 1 FROM last WHERE credits = \$3 AND tasks_shipped = \$4 AND karma = \$5\)/);
50
+ assert.deepEqual(c.params, ['c1', 42, 100, 5, 3]);
51
+ assert.deepEqual(out, { id: '1', reported_at: 'T1' });
52
+ });
53
+
54
+ await ta('a skipped report (the fake pool answers no rows, as a real WHERE NOT EXISTS would on a match) returns null', async () => {
55
+ const pool = mockPool([{ rows: [] }]);
56
+ const out = await recordActivitySnapshot({ clientId: 'c1', githubId: 42, credits: 100, tasksShipped: 5, karma: 3 }, { pool });
57
+ assert.equal(pool.calls.length, 1);
58
+ assert.equal(out, null);
59
+ });
60
+
61
+ await ta('missing totals default to zero, like upsertActivity\'s own defaults', async () => {
62
+ const pool = mockPool([{ rows: [{ id: '3', reported_at: 'T3' }] }]);
63
+ await recordActivitySnapshot({ clientId: 'c1', githubId: 42 }, { pool });
64
+ assert.deepEqual(pool.calls[0].params, ['c1', 42, 0, 0, 0]);
65
+ });
66
+
67
+ console.log(`\n${passed} passed, ${failed} failed`);
68
+ if (failed > 0) process.exit(1);