@bongos/core 1.21.112 → 1.21.114

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 (265) hide show
  1. package/.bongos-core.json +616 -186
  2. package/.claude/skills/ask-for-help/SKILL.md +1 -1
  3. package/.claude/skills/bongos-feedback/SKILL.md +1 -1
  4. package/.claude/skills/builder-sequence/SKILL.md +1 -1
  5. package/.claude/skills/builder-ship/SKILL.md +1 -0
  6. package/GOVERNMENT.md +128 -11
  7. package/clients/bongos-client/README.md +1 -1
  8. package/clients/bongos-client/bongos-client.global.js +28 -0
  9. package/clients/bongos-client/index.cjs +28 -0
  10. package/clients/bongos-client/index.d.ts +45 -2
  11. package/clients/bongos-client/index.mjs +28 -0
  12. package/docs/adr/0372-an-ask-is-stored-per-docket-matter-and-the-server-refuses-slop.md +33 -0
  13. package/docs/adr/0373-an-ai-session-files-a-blocker-with-an-ask.md +31 -0
  14. package/docs/adr/0374-a-building-lead-is-a-held-position-appointed-by-a-people-matter.md +30 -0
  15. package/docs/adr/0375-who-may-build-without-a-vote-comes-from-the-form-of-government.md +38 -0
  16. package/docs/adr/0376-every-ask-is-routed-to-a-real-seat-on-the-server.md +30 -0
  17. package/docs/adr/0377-governance-is-a-held-position-read-on-me.md +24 -0
  18. package/docs/adr/0378-a-leads-build-outs-are-rows-built-at-once-or-by-a-passed-vote.md +33 -0
  19. package/docs/adr/0379-the-treasury-is-pots-per-goal-a-reserve-and-borrowing-over-real-spend.md +32 -0
  20. package/docs/adr/0380-a-strictness-preset-is-a-bundle-of-laws-switched-by-one-laws-vote.md +26 -0
  21. package/docs/adr/README.md +10 -1
  22. package/docs/api/openapi.json +989 -87
  23. package/docs/api-reference.md +18 -4
  24. package/docs/copy-inventory.md +343 -262
  25. package/docs/copy-registry.json +1223 -426
  26. package/docs/file-map.md +2 -1
  27. package/docs/module-api-changelog.md +4 -0
  28. package/docs/packs/engineer.md +2 -1
  29. package/docs/page-inventory.json +91 -6
  30. package/docs/page-readings.json +5017 -4458
  31. package/modules/builder-settings/law-locks.js +202 -0
  32. package/modules/builder-settings/routes/settings.js +66 -6
  33. package/modules/builder-settings/settings.js +7 -0
  34. package/modules/discord/ship-broadcast.js +22 -13
  35. package/modules/government/asks-db.js +54 -0
  36. package/modules/government/asks.js +269 -0
  37. package/modules/government/board-idea-reads.js +68 -0
  38. package/modules/government/board.js +21 -57
  39. package/modules/government/build-rights.js +148 -0
  40. package/modules/government/buildouts.js +160 -0
  41. package/modules/government/counts.js +81 -0
  42. package/modules/government/db.js +242 -0
  43. package/modules/government/decision-board.js +234 -8
  44. package/modules/government/decision-rules.js +2 -0
  45. package/modules/government/docket.js +114 -6
  46. package/modules/government/law-board.js +131 -0
  47. package/modules/government/laws.js +241 -0
  48. package/modules/government/leads.js +170 -0
  49. package/modules/government/migrations/government_027_laws.sql +143 -0
  50. package/modules/government/migrations/government_028_asks.sql +57 -0
  51. package/modules/government/migrations/government_029_leads.sql +135 -0
  52. package/modules/government/migrations/government_030_buildouts.sql +111 -0
  53. package/modules/government/migrations/government_031_treasury.sql +107 -0
  54. package/modules/government/module.json +2 -1
  55. package/modules/government/pollers/treasury-borrow.js +62 -0
  56. package/modules/government/positions.js +107 -0
  57. package/modules/government/presets.js +149 -0
  58. package/modules/government/routes/government.js +425 -2
  59. package/modules/government/routing.js +154 -0
  60. package/modules/government/treasury-db.js +166 -0
  61. package/modules/government/treasury.js +241 -0
  62. package/modules/hall-ui/public/atlas.html +2 -2
  63. package/modules/hall-ui/public/blockers.html +2 -2
  64. package/modules/hall-ui/public/board-room.html +2 -2
  65. package/modules/hall-ui/public/board-room.js +2 -0
  66. package/modules/hall-ui/public/city-draw.js +604 -11
  67. package/modules/hall-ui/public/city.css +157 -1
  68. package/modules/hall-ui/public/city.html +37 -7
  69. package/modules/hall-ui/public/city.js +441 -21
  70. package/modules/hall-ui/public/city.states.json +110 -5
  71. package/modules/hall-ui/public/collab.html +2 -2
  72. package/modules/hall-ui/public/commands.html +2 -2
  73. package/modules/hall-ui/public/copy-desk.html +2 -2
  74. package/modules/hall-ui/public/craft-mode.js +112 -29
  75. package/modules/hall-ui/public/deploy.html +2 -2
  76. package/modules/hall-ui/public/diagram-viewer.css +12 -0
  77. package/modules/hall-ui/public/diagram-viewer.js +4 -0
  78. package/modules/hall-ui/public/diagrams.html +2 -2
  79. package/modules/hall-ui/public/drachmae.html +2 -2
  80. package/modules/hall-ui/public/fleet.html +2 -2
  81. package/modules/hall-ui/public/gate.html +2 -2
  82. package/modules/hall-ui/public/goal-map.html +2 -2
  83. package/modules/hall-ui/public/goals.html +2 -2
  84. package/modules/hall-ui/public/gov-feeds.js +141 -0
  85. package/modules/hall-ui/public/gov-living.js +1002 -0
  86. package/modules/hall-ui/public/gov-positions.js +97 -0
  87. package/modules/hall-ui/public/government.html +2 -2
  88. package/modules/hall-ui/public/hall-render.js +3 -0
  89. package/modules/hall-ui/public/idea.html +2 -2
  90. package/modules/hall-ui/public/ideas.html +2 -2
  91. package/modules/hall-ui/public/index.html +3 -3
  92. package/modules/hall-ui/public/modules.html +2 -2
  93. package/modules/hall-ui/public/my-agents.css +334 -0
  94. package/modules/hall-ui/public/my-agents.html +72 -0
  95. package/modules/hall-ui/public/my-agents.js +603 -0
  96. package/modules/hall-ui/public/my-agents.states.json +37 -0
  97. package/modules/hall-ui/public/primer.html +2 -2
  98. package/modules/hall-ui/public/profile-connections.js +40 -28
  99. package/modules/hall-ui/public/profile.html +2 -2
  100. package/modules/hall-ui/public/project-settings.html +2 -2
  101. package/modules/hall-ui/public/ranks.html +2 -2
  102. package/modules/hall-ui/public/roadmap.html +2 -2
  103. package/modules/hall-ui/public/room.css +8 -0
  104. package/modules/hall-ui/public/roster.html +2 -2
  105. package/modules/hall-ui/public/sessions.html +2 -2
  106. package/modules/hall-ui/public/settings.html +2 -2
  107. package/modules/hall-ui/public/shell.js +116 -16
  108. package/modules/hall-ui/public/street-card.js +18 -1
  109. package/modules/hall-ui/public/street-deck.css +299 -0
  110. package/modules/hall-ui/public/street-deck.js +1145 -0
  111. package/modules/hall-ui/public/street-depth.js +712 -0
  112. package/modules/hall-ui/public/street-desk.js +5 -2
  113. package/modules/hall-ui/public/street-draw.js +109 -16
  114. package/modules/hall-ui/public/street-inside.js +137 -22
  115. package/modules/hall-ui/public/street-office.js +705 -0
  116. package/modules/hall-ui/public/street.css +161 -11
  117. package/modules/hall-ui/public/street.html +41 -11
  118. package/modules/hall-ui/public/street.js +78 -66
  119. package/modules/hall-ui/public/street.states.json +129 -21
  120. package/modules/hall-ui/public/studio.html +2 -2
  121. package/modules/hall-ui/public/style.css +5 -1
  122. package/modules/hall-ui/public/task.html +2 -2
  123. package/modules/hall-ui/public/thinking.html +2 -2
  124. package/modules/hall-ui/public/tweak-editor.html +2 -2
  125. package/modules/hall-ui/public/watch.html +2 -2
  126. package/modules/hall-ui/public/watch.js +14 -1
  127. package/modules/hall-ui/public/work.html +2 -2
  128. package/modules/hall-ui/records/living-city.md +57 -0
  129. package/modules/hall-ui/records/my-agents.md +21 -0
  130. package/modules/hall-ui/records/oversight-family.md +1 -0
  131. package/modules/hall-ui/records/profile-connections.md +10 -0
  132. package/modules/hall-ui/records/reading-rooms.md +2 -0
  133. package/modules/ideas/routes/blockers.js +92 -3
  134. package/modules/lifecycle/module.json +2 -1
  135. package/modules/lifecycle/moving.js +17 -1
  136. package/modules/lifecycle/routes/moving.js +6 -0
  137. package/modules/lifecycle/routes/project-settings.js +23 -0
  138. package/modules/onboarding/approval-broadcast.js +8 -0
  139. package/modules/onboarding/routes/access-requests.js +16 -6
  140. package/modules/platform-identity/connections.js +13 -2
  141. package/modules/platform-identity/routes/connections.js +7 -1
  142. package/modules/public-landing/public/account-private.states.json +2 -2
  143. package/modules/public-landing/public/account.html +65 -5
  144. package/modules/public-landing/public/account.states.json +3 -3
  145. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.jpg.json +1 -1
  146. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.webp +0 -0
  147. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.webp.json +13 -0
  148. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.jpg.json +1 -1
  149. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.webp +0 -0
  150. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.webp.json +13 -0
  151. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.jpg.json +1 -1
  152. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.webp +0 -0
  153. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.webp.json +13 -0
  154. package/modules/public-landing/public/assets/world.css +4 -4
  155. package/modules/public-landing/public/connections-empty.states.json +14 -0
  156. package/modules/public-landing/public/connections-inactive.states.json +14 -0
  157. package/modules/public-landing/public/connections.html +450 -0
  158. package/modules/public-landing/public/connections.probes.json +414 -0
  159. package/modules/public-landing/public/connections.states.json +17 -0
  160. package/modules/public-landing/public/contact.html +2 -2
  161. package/modules/public-landing/public/guild.html +2 -2
  162. package/modules/public-landing/public/index.html +2 -2
  163. package/modules/public-landing/public/invitations.html +2 -2
  164. package/modules/public-landing/public/privacy.html +2 -2
  165. package/modules/public-landing/public/projects.html +2 -2
  166. package/modules/public-landing/public/projects.probes.json +6 -2
  167. package/modules/public-landing/public/terms.html +2 -2
  168. package/modules/ui-design/kit/fixtures/connections-empty.json +12 -0
  169. package/modules/ui-design/kit/fixtures/connections-inactive.json +7 -0
  170. package/modules/ui-design/kit/fixtures/connections.json +22 -0
  171. package/modules/ui-design/kit/serve.js +95 -0
  172. package/package-lock.json +2 -2
  173. package/package.json +1 -1
  174. package/release-notes.json +128 -0
  175. package/scripts/discord/README.md +8 -5
  176. package/scripts/discord/post.js +12 -1
  177. package/scripts/gds/artifact-audit.js +20 -3
  178. package/scripts/gds/ask.js +164 -0
  179. package/scripts/gds/autobongos-run.js +20 -5
  180. package/scripts/gds/autobongos-tweak-lane.js +262 -0
  181. package/scripts/gds/autobongos-verify.js +21 -2
  182. package/scripts/gds/claim.js +2 -0
  183. package/scripts/gds/government-walk.js +638 -4
  184. package/scripts/gds/pulse.js +9 -6
  185. package/scripts/gds/ship-escalation.js +22 -3
  186. package/scripts/gds/ship-finish.js +5 -4
  187. package/scripts/gds/ship-merge-gate.js +18 -6
  188. package/scripts/gds/tweak-halt.js +1 -0
  189. package/scripts/hall-preview/README.md +41 -1
  190. package/scripts/hall-preview/pref-writes.js +140 -0
  191. package/scripts/hall-preview/refresh-fixtures.js +101 -7
  192. package/scripts/hall-preview/scrub-identity.js +366 -7
  193. package/scripts/hall-preview/server.js +27 -1
  194. package/scripts/render-diagrams.sh +4 -2
  195. package/src/bongos/auth-idp.js +6 -1
  196. package/src/bongos/project-settings.js +41 -0
  197. package/src/bongos/route-rank-check.js +27 -0
  198. package/src/bongos/routes/me.js +68 -7
  199. package/src/bongos/serve-internal.js +5 -1
  200. package/src/module-api.js +7 -1
  201. package/src/platform-server.js +10 -0
  202. package/tests/application_lifecycle.mjs +47 -5
  203. package/tests/autobongos_tweak_lane.mjs +208 -0
  204. package/tests/conductor_main_e2e.mjs +21 -0
  205. package/tests/connections_api.mjs +18 -0
  206. package/tests/craft_mode_nav.mjs +5 -3
  207. package/tests/craft_mode_tallies.mjs +52 -16
  208. package/tests/discord_approvals.mjs +11 -0
  209. package/tests/discord_channels.mjs +20 -0
  210. package/tests/fixtures/gov-seated.mjs +64 -0
  211. package/tests/fixtures/government-ask-cards.json +1284 -0
  212. package/tests/government_act_alone_buildings.mjs +1 -0
  213. package/tests/government_agent_asks.mjs +322 -0
  214. package/tests/government_asks.mjs +408 -0
  215. package/tests/government_build_rights.mjs +253 -0
  216. package/tests/government_buildouts.mjs +382 -0
  217. package/tests/government_counts.mjs +143 -0
  218. package/tests/government_decision_rules.mjs +5 -1
  219. package/tests/government_laws.mjs +488 -0
  220. package/tests/government_laws_session.mjs +321 -0
  221. package/tests/government_leads.mjs +375 -0
  222. package/tests/government_positions.mjs +127 -0
  223. package/tests/government_presets.mjs +215 -0
  224. package/tests/government_routes.mjs +8 -1
  225. package/tests/government_routing.mjs +118 -0
  226. package/tests/government_seed.mjs +5 -0
  227. package/tests/government_treasury.mjs +394 -0
  228. package/tests/governor_docket.mjs +26 -2
  229. package/tests/hall_buildouts.mjs +208 -0
  230. package/tests/hall_city.mjs +14 -6
  231. package/tests/hall_city_living.mjs +617 -0
  232. package/tests/hall_gov_counts.mjs +336 -0
  233. package/tests/hall_gov_living.mjs +636 -0
  234. package/tests/hall_gov_real_site.mjs +655 -0
  235. package/tests/hall_matter_card.mjs +6 -1
  236. package/tests/hall_my_agents.mjs +481 -0
  237. package/tests/hall_nav.mjs +107 -4
  238. package/tests/hall_page_gate_map.mjs +4 -0
  239. package/tests/hall_preview_governor_fixtures.mjs +30 -3
  240. package/tests/hall_preview_scrub.mjs +59 -1
  241. package/tests/hall_preview_security.mjs +169 -0
  242. package/tests/hall_reading_world.mjs +44 -0
  243. package/tests/hall_roster_world.mjs +6 -2
  244. package/tests/hall_street.mjs +18 -2
  245. package/tests/hall_street_amend.mjs +1 -1
  246. package/tests/hall_street_deck.mjs +609 -0
  247. package/tests/hall_street_depth.mjs +559 -0
  248. package/tests/hall_street_desk.mjs +1 -1
  249. package/tests/hall_street_inside.mjs +29 -5
  250. package/tests/hall_tweak_editor.mjs +4 -1
  251. package/tests/hub_account_page.mjs +94 -3
  252. package/tests/hub_connections_page.mjs +507 -0
  253. package/tests/hub_invite_badge.mjs +1 -1
  254. package/tests/idp_device_exchange.mjs +18 -0
  255. package/tests/landing_legal_pages.mjs +8 -5
  256. package/tests/landing_page.mjs +23 -4
  257. package/tests/nav_permission_atoms.mjs +4 -1
  258. package/tests/profile_connections_ui.mjs +88 -25
  259. package/tests/projects_hub.mjs +15 -0
  260. package/tests/render_check.mjs +2 -2
  261. package/tests/ship_broadcast_routing.mjs +57 -16
  262. package/tests/ship_broadcast_server.mjs +40 -7
  263. package/tests/ship_merge_gate.mjs +19 -0
  264. package/tests/ui_design_kit.mjs +2 -2
  265. package/tests/watch_applications_queue.mjs +23 -0
@@ -30,7 +30,10 @@
30
30
  // NOT TOUCHED. Instance-local numeric ids (builder, task, goal) — the harness routes on
31
31
  // them (analytics/builder/:id, /profile?id=…) and they name nobody outside that one
32
32
  // instance. Free text (task titles, descriptions) — project content, not a person's
33
- // record. The repo's own coordinates (instance.repo.owner is where the code lives).
33
+ // record — EXCEPT where it names a person the run has already met in a field: a
34
+ // snapshot run ends with createTextScrubber (task 1004685, below), which replaces
35
+ // those people inside prose too. The repo's own coordinates (instance.repo.owner is
36
+ // where the code lives).
34
37
  //
35
38
  // FIXED POINT. Every stand-in is recognised on the way back in (`builder-3` is person
36
39
  // 3), so scrubbing an already-scrubbed fixture changes nothing. tests/hall_preview_scrub.mjs
@@ -69,8 +72,13 @@ const isByKey = (k) => k === 'by' || /_by$/.test(k);
69
72
  // login. Task 1004531: the constitution fixture carries that table. Only on a
70
73
  // table ROW (it names its building in `area`), so a login that happens to be one
71
74
  // of the three words anywhere else is still scrubbed.
75
+ // The live docket carries the same word on each building's `decision` (task 1004685),
76
+ // which names its rule rather than its area: { rule: 'decide', decided_by: 'gate' }.
77
+ // Scrubbed as a login there, "gate" and "board" became stand-ins and the prose pass
78
+ // then rewrote every "stealth gate" and "Board Room" in the snapshot.
72
79
  const DECISION_WORDS = new Set(['gate', 'board', 'nobody']);
73
- const isDecisionWord = (k, v, obj) => k === 'decided_by' && DECISION_WORDS.has(v) && !!obj && typeof obj.area === 'string';
80
+ const isDecisionWord = (k, v, obj) => k === 'decided_by' && DECISION_WORDS.has(v) && !!obj
81
+ && (typeof obj.area === 'string' || typeof obj.rule === 'string');
74
82
  const looksLikeLogin = (v) => /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/.test(v) && !/^\d+$/.test(v);
75
83
 
76
84
  // The login a name-shaped key belongs to, or undefined when the key is not a person's
@@ -88,9 +96,27 @@ function nameOwnerKey(key, obj) {
88
96
  // createScrubber — one registry of people for one run, so a builder gets the same n in
89
97
  // every file the run touches. `firstNumber` lets a run start above stand-ins already on
90
98
  // disk, so a new person never collides with one a hand-written fixture already uses.
91
- function createScrubber({ firstNumber = 1 } = {}) {
99
+ //
100
+ // `seed` (task 1004685) is people whose stand-in is already fixed on disk —
101
+ // [{ login, name, n }], which refresh-fixtures.js reads off the committed roster by
102
+ // builder id — so a re-run keeps every builder's number instead of renumbering the
103
+ // whole snapshot, and a file added to the run lines up with the ones already there.
104
+ function createScrubber({ firstNumber = 1, seed = [] } = {}) {
92
105
  const people = new Map(); // 'login:<lower>' | 'name:<text>' | 'id:<github id>' | 'email:<lower>' → n
106
+ // A login as it was written (the registry keys it lowercased), for the free-text
107
+ // pass: `samRiverStone` shows where a first name ends, `samriverstone` does not.
108
+ const spelled = new Map();
109
+ const loginKeyOf = (v) => { spelled.set(v.toLowerCase(), v); return `login:${v.toLowerCase()}`; };
93
110
  let next = Math.max(1, Number(firstNumber) || 1);
111
+ for (const p of Array.isArray(seed) ? seed : []) {
112
+ const n = Number(p && p.n);
113
+ if (!Number.isInteger(n) || n <= 0) continue;
114
+ const login = typeof p.login === 'string' && p.login && !STAND_IN_LOGIN.test(p.login) ? p.login : '';
115
+ const name = typeof p.name === 'string' && p.name && !STAND_IN_NAME.test(p.name) ? p.name : '';
116
+ if (login || name) people.set(login ? loginKeyOf(login) : `name:${name}`, n);
117
+ if (login && name) people.set(`name:${name}`, n);
118
+ if ((login || name) && n >= next) next = n + 1;
119
+ }
94
120
 
95
121
  function numberFor(keys) {
96
122
  for (const k of keys) if (k && people.has(k)) return remember(keys, people.get(k));
@@ -109,7 +135,7 @@ function createScrubber({ firstNumber = 1 } = {}) {
109
135
  const name = nameKey && typeof obj[nameKey] === 'string' ? obj[nameKey] : '';
110
136
  const standIn = STAND_IN_LOGIN.exec(login) || STAND_IN_NAME.exec(name);
111
137
  if (standIn) return remember([], Number(standIn[1]));
112
- return numberFor([login && `login:${login.toLowerCase()}`, name && `name:${name}`]);
138
+ return numberFor([login && loginKeyOf(login), name && `name:${name}`]);
113
139
  }
114
140
 
115
141
  function scrubObject(obj, ctx) {
@@ -169,13 +195,13 @@ function createScrubber({ firstNumber = 1 } = {}) {
169
195
  }
170
196
  if (isByKey(key) && looksLikeLogin(value) && !isDecisionWord(key, value, obj)) {
171
197
  if (STAND_IN_LOGIN.test(value)) return value;
172
- return `builder-${numberFor([`login:${value.toLowerCase()}`])}`;
198
+ return `builder-${numberFor([loginKeyOf(value)])}`;
173
199
  }
174
200
  // A PR / commit author is a GitHub login; elsewhere `author` credits an
175
201
  // organisation (a module's author), which is nobody's personal record.
176
202
  if (key === 'author' && 'head_sha' in obj && looksLikeLogin(value)) {
177
203
  if (STAND_IN_LOGIN.test(value)) return value;
178
- return `builder-${numberFor([`login:${value.toLowerCase()}`])}`;
204
+ return `builder-${numberFor([loginKeyOf(value)])}`;
179
205
  }
180
206
  if (key in PLACEHOLDER_TEXT) return PLACEHOLDER_TEXT[key];
181
207
  if (isGithubIdKey(key)) return String(githubIdFor(obj, value, self, isPersonRecord));
@@ -225,7 +251,326 @@ function createScrubber({ firstNumber = 1 } = {}) {
225
251
  return data && typeof data === 'object' ? scrubObject(data, ctx) : data;
226
252
  }
227
253
 
228
- return { scrub };
254
+ // knownPeople — every REAL login and name this run has met in a structured field,
255
+ // with its stand-in n, for the free-text pass (createTextScrubber below). Logins come
256
+ // back as they were written. Stand-ins are not listed: a fixture already on disk
257
+ // teaches the registry nothing real.
258
+ function knownPeople() {
259
+ const logins = [];
260
+ const names = [];
261
+ for (const [key, n] of people) {
262
+ if (key.startsWith('login:')) {
263
+ const lower = key.slice('login:'.length);
264
+ logins.push({ login: spelled.get(lower) || lower, n });
265
+ } else if (key.startsWith('name:')) names.push({ name: key.slice('name:'.length), n });
266
+ }
267
+ return { logins, names };
268
+ }
269
+
270
+ return { scrub, knownPeople };
271
+ }
272
+
273
+ // ---------- free text (task 1004685) ----------
274
+ // The structured pass above replaces a person wherever a FIELD names them. Prose is
275
+ // left alone by design (task titles and descriptions are project content), but the
276
+ // governor docket carries prose that names people outright: "From <login>'s
277
+ // feedback", "Owner: tell <login> their project was torn down", "<name>: stop runner".
278
+ // So a snapshot run ends with one more pass over every string, replacing the people
279
+ // the run has already met: by login, by display name, by the first name a login
280
+ // visibly starts with (`alex42`, `AlexCode` → Alex; `willRiverStone` → Will), by a
281
+ // capitalised word a login starts with (`bobbytabl` → "Bobby", "Bobby Tables"), and by a
282
+ // capitalised nickname cut from the end of an all-caps login. It only ever knows
283
+ // people a structured field named; it cannot recognise a stranger in a sentence,
284
+ // which is why the structured pass still comes first.
285
+ //
286
+ // Kept on purpose: the repo's own coordinates (`github.com/<owner>/<repo>` and the
287
+ // manifest's `repo.owner`), exactly as the structured pass keeps them.
288
+ //
289
+ // A name that is also a common English word ("will") is replaced only where it is
290
+ // written as a name (capitalised), so "the agent will stop" survives the pass.
291
+ const COMMON_WORD_NAMES = new Set([
292
+ 'will', 'mark', 'max', 'may', 'bill', 'art', 'grant', 'rob', 'pat', 'sue', 'jack', 'don', 'ray', 'rose',
293
+ 'dawn', 'hope', 'joy', 'faith', 'grace', 'sky', 'king', 'frank', 'miles', 'chase', 'hunter', 'summer',
294
+ 'june', 'april', 'august', 'eve', 'cliff', 'glen', 'dean', 'victor', 'sam', 'nick', 'drew', 'lane',
295
+ ]);
296
+ // Capitalised words a login may start with that are not first names ("Large files…"
297
+ // is not `largefellow-x`). Only the start-of-login rule consults it.
298
+ const NOT_A_NAME = new Set([
299
+ 'large', 'small', 'open', 'code', 'data', 'super', 'mega', 'cloud', 'home', 'build', 'test', 'team',
300
+ 'labs', 'online', 'master', 'admin', 'user', 'real', 'other', 'scoot', 'mack', 'kind', 'line',
301
+ ]);
302
+ // a system account (`discord-bot-system`) is nobody, and its "first name" is a product
303
+ const SYSTEM_ACCOUNT = /(^|-)(system|bot)(-|$)|\(system\)/i;
304
+ const EMAIL_IN_TEXT = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
305
+ const KEEP_EMAIL = /^(git|noreply|no-reply)@|@example\.(com|org)$/i;
306
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
307
+ const titleCase = (s) => s[0].toUpperCase() + s.slice(1);
308
+
309
+ // The first name a login starts with, when the login shows where it ends: a digit,
310
+ // a hyphen or a camelCase step, after at least three letters. An all-lowercase login
311
+ // with no boundary (`jpetersen`) shows no first name, so none is guessed.
312
+ function firstNameOf(login) {
313
+ if (SYSTEM_ACCOUNT.test(login)) return null;
314
+ const m = /^([A-Z]?[a-z]{2,})(?=[A-Z0-9_-])/.exec(login);
315
+ return m ? m[1].toLowerCase() : null;
316
+ }
317
+
318
+ // createTextScrubber(known) → (data) => a deep copy with the known people replaced in
319
+ // every string. `known` is a scrubber's knownPeople(). A login is replaced by its
320
+ // login-shaped stand-in (`builder-3`), a name by its name-shaped one (`Builder 3`),
321
+ // and a first name by whichever shape it was written in.
322
+ function createTextScrubber({ logins = [], names = [] } = {}) {
323
+ const rules = []; // { re, len, to }, applied longest needle first
324
+ const add = (needle, n, { caseless, shape }) => {
325
+ // a decision word is vocabulary, whatever field once mistook it for a login
326
+ if (!needle || needle.length < 3 || DECISION_WORDS.has(needle.toLowerCase())) return;
327
+ // never inside a longer word, never the owner segment of a GitHub URL
328
+ const re = new RegExp(`(?<![A-Za-z0-9_-])(?<!github\\.com/)${escapeRe(needle)}(?![A-Za-z0-9_-])`, caseless ? 'gi' : 'g');
329
+ const to = shape === 'login' ? () => `builder-${n}`
330
+ : shape === 'name' ? () => `Builder ${n}`
331
+ : (hit) => (/^[A-Z]/.test(hit) ? `Builder ${n}` : `builder-${n}`);
332
+ rules.push({ re, len: needle.length, to });
333
+ };
334
+ // a nickname or a first name that is a common word only ever counts capitalised
335
+ const addWord = (word, n, shape) => {
336
+ if (COMMON_WORD_NAMES.has(word.toLowerCase())) add(titleCase(word.toLowerCase()), n, { caseless: false, shape: shape === 'login' ? 'name' : shape });
337
+ else add(word, n, { caseless: true, shape });
338
+ };
339
+ const capsLogins = [];
340
+ const prefixLogins = [];
341
+ for (const { login, n } of logins) {
342
+ if (!login || STAND_IN_LOGIN.test(login)) continue;
343
+ if (!SYSTEM_ACCOUNT.test(login)) prefixLogins.push({ lower: login.toLowerCase(), n });
344
+ addWord(login, n, 'login');
345
+ const first = firstNameOf(login);
346
+ if (first && first !== login.toLowerCase()) addWord(first, n, 'first');
347
+ if (/^[A-Z0-9]{8,}$/.test(login) && /[A-Z]{6,}/.test(login)) capsLogins.push({ lower: login.toLowerCase(), n });
348
+ }
349
+ for (const { name, n } of names) {
350
+ if (!name || STAND_IN_NAME.test(name) || SYSTEM_ACCOUNT.test(name)) continue;
351
+ if (name.length >= 4 && !COMMON_WORD_NAMES.has(name.toLowerCase())) add(name, n, { caseless: false, shape: 'name' });
352
+ const parts = name.split(/\s+/);
353
+ if (parts.length > 1 && /^[A-Z][a-z]{2,}$/.test(parts[0]) && !COMMON_WORD_NAMES.has(parts[0].toLowerCase())) add(parts[0], n, { caseless: false, shape: 'name' });
354
+ }
355
+ rules.sort((a, b) => b.len - a.len);
356
+
357
+ function text(s) {
358
+ let out = s;
359
+ for (const r of rules) out = out.replace(r.re, r.to);
360
+ // a capitalised first name a login starts with but does not mark off (`bobbytabl`
361
+ // → "Bobby", "Bobby Tables"), taking a capitalised surname with it
362
+ if (prefixLogins.length) {
363
+ out = out.replace(/(?<![A-Za-z0-9_-])([A-Z][a-z]{3,})(\s[A-Z][a-z]+)?(?![A-Za-z0-9_-])/g, (whole, w, surname) => {
364
+ const lower = w.toLowerCase();
365
+ if (COMMON_WORD_NAMES.has(lower) || NOT_A_NAME.has(lower)) return whole;
366
+ const hit = prefixLogins.find((p) => p.lower.startsWith(lower) && p.lower.length >= lower.length + 2);
367
+ return hit ? `Builder ${hit.n}` : whole;
368
+ });
369
+ }
370
+ // a capitalised nickname cut from the end of an all-caps login (IAMSOMEONE → "Someone")
371
+ if (capsLogins.length) {
372
+ out = out.replace(/(?<![A-Za-z0-9_-])[A-Z][a-z]{5,}(?![A-Za-z0-9_-])/g, (w) => {
373
+ const hit = capsLogins.find((c) => c.lower.endsWith(w.toLowerCase()) && c.lower !== w.toLowerCase());
374
+ return hit ? `Builder ${hit.n}` : w;
375
+ });
376
+ }
377
+ return out.replace(EMAIL_IN_TEXT, (e) => (KEEP_EMAIL.test(e) ? e : 'builder@example.com'));
378
+ }
379
+ function walk(v, key, parent) {
380
+ if (typeof v === 'string') {
381
+ // the manifest's repo coordinates stay, as the structured pass keeps them
382
+ if (key === 'owner' && parent && typeof parent.repo === 'string') return v;
383
+ return text(v);
384
+ }
385
+ if (Array.isArray(v)) return v.map((x) => walk(x, null, v));
386
+ if (v && typeof v === 'object') {
387
+ const out = {};
388
+ for (const [k, val] of Object.entries(v)) out[k] = walk(val, k, v);
389
+ return out;
390
+ }
391
+ return v;
392
+ }
393
+ return (data) => walk(data, null, null);
394
+ }
395
+
396
+ // ---------- security content (task 1004685, after its security review) ----------
397
+ // People are not the only thing a live snapshot must not commit. The governor
398
+ // docket is read at a rank floor, and it carries the project's open red-team
399
+ // reports (file and line references, exploit chains, proofs of concept) and the
400
+ // owner's blockers (host config diffs, recovery runbooks naming a bot token). A
401
+ // committed fixture hands all of that to anyone with read access to the repo. So
402
+ // every snapshot run, and --scrub-only, ends with a structural pass:
403
+ //
404
+ // • A RED-TEAM RECORD (source redteam_reports, a vulnerability/security-report
405
+ // ref, an id like `redteam_reports:N`, a "Security report:" title, a
406
+ // `repro_steps` field) keeps only REPORT_KEYS — id, state, severity bucket,
407
+ // where it stands, its acts — and its title becomes REPORT_TITLE. Every other
408
+ // field (detail, body, repro, target, proposer, facts…) is dropped.
409
+ // • A BLOCKER (source blockers, a blocker ref, a `blocking_task_ids` row) loses
410
+ // every body field, and keeps its title only when the title itself names no
411
+ // secret, config, path, token or customer instance.
412
+ // • The SECURITY BACKLOG — a task flagged security_sensitive or belonging to a
413
+ // goal of category `security` — keeps its shape but not its words: the title
414
+ // becomes TASK_TITLE and its description, summary and touched paths go; the
415
+ // security goal itself keeps its title and loses its description.
416
+ // • ANY other record: a prose field (description, detail, body, summary…) that
417
+ // names one is emptied, and a title that does becomes ITEM_TITLE.
418
+ // • A customer instance's own domain (any host not on PUBLIC_DOMAINS) becomes
419
+ // `instance.example`, and its bare name, wherever else it appears, `instance-name`.
420
+ //
421
+ // Over-redaction is the safe failure here: a withheld title costs a preview its
422
+ // colour; a leaked one cannot be taken back.
423
+ const REPORT_TITLE = 'Security report — details withheld';
424
+ const BLOCKER_TITLE = 'Blocker — details withheld';
425
+ const ITEM_TITLE = 'Details withheld';
426
+ const TASK_TITLE = 'Security task — details withheld';
427
+ const REPORT_KEYS = new Set([
428
+ 'id', 'source', 'building', 'rule', 'state', 'status', 'severity', 'short', 'where', 'desk', 'fact',
429
+ 'opened_at', 'created_at', 'closed_at', 'at', 'deadline', 'outcome', 'tally', 'href', 'ref', 'acts',
430
+ 'needs', 'tool', 'over', 'kind', 'label', 'count',
431
+ // a stored ask's card for a report (task 1004703; its words are walked like any prose)
432
+ 'question', 'options', 'recommend', 'waits_on', 'if_nobody_answers', 'routing_note', 'raw',
433
+ // who it waits on, as the docket routes it (task 1004705): a seat, never the report's words
434
+ 'route',
435
+ ]);
436
+ const TITLE_KEYS = new Set(['title', 'name', 'subject']);
437
+ const PROSE_KEYS = new Set([
438
+ 'description', 'detail', 'details', 'body', 'body_md', 'summary', 'value_summary', 'notes', 'note',
439
+ 'repro', 'repro_steps', 'impact', 'target', 'rationale_md', 'resolution_note', 'triage_note',
440
+ 'paused_reason', 'owner_paused_reason', 'reason', 'criterion_md', 'line', 'lines', 'facts', 'fact', 'text', 'tags',
441
+ ]);
442
+ const BLOCKER_BODY_KEYS = new Set(['body', 'body_md', 'notes', 'note', 'details', 'detail', 'description', 'resolution_note', 'steps', 'facts']);
443
+ // What a security write-up, a host runbook or a credential looks like in prose: the
444
+ // SPECIFIC marks of one (exploit and proof-of-concept talk, host config and paths,
445
+ // a named credential, the code identifiers a live report points at, a customer's
446
+ // project or instance). Plain words such as "security" or "token reward" are not
447
+ // on it, so ordinary project prose is left alone; a red-team record itself is caught
448
+ // by its shape (isReportRecord), not by its words.
449
+ const SENSITIVE_TEXT = new RegExp([
450
+ 'exploit', '\\bPoC\\b', 'proof[- ]of[- ]concept', '\\brepro_steps\\b', '\\bCVE-\\d', 'privilege escalation',
451
+ 'frame-ancestors', 'X-Frame-Options', 'Content-Security-Policy', '\\bCaddy', '\\bnginx\\b', 'systemctl', '\\bsudo\\b',
452
+ '/etc/', '/home/', 'WorkingDirectory=', '\\bweb\\.env\\b', '\\bbot token', '\\b(api|bearer|access|personal access|deploy) token\\b',
453
+ '\\bapi[ _-]?key\\b', 'private key', 'secret key', '\\b(credential|password|secret)s? (is |are |still )*(live|in git|leaked|exposed)',
454
+ 'committed_files', 'auth-idp', 'serverSideGuards', 'verifyHubAssertion', 'GRADER_BYPASS', 'reconcile-on-read',
455
+ '\\bproject [a-z0-9]+(?:-[a-z0-9]+)+\\b', '\\binstance \\d+\\b',
456
+ ].join('|'), 'i');
457
+ // Domains that name a public service or the platform itself, never a customer.
458
+ const PUBLIC_DOMAINS = [
459
+ 'example.com', 'example.org', 'example.net', 'github.com', 'githubusercontent.com', 'cloudbongos.com', 'claude.ai',
460
+ 'claude.com', 'anthropic.com', 'discord.com', 'discordapp.com', 'discordapp.net', 'w3.org', 'google.com',
461
+ 'googleapis.com', 'gstatic.com', 'onrender.com', 'npmjs.com', 'npmjs.org', 'nodejs.org', 'cloudflare.com', 'shields.io',
462
+ ];
463
+ const HOST_IN_TEXT = /\b((?:[a-z0-9-]+\.)*)([a-z0-9-]+)\.(com|net|org|io|app|dev|co|ai|xyz|cloud|site|tech)\b/gi;
464
+ const isPublicDomain = (base) => PUBLIC_DOMAINS.some((d) => base === d || base.endsWith('.' + d));
465
+
466
+ function isReportRecord(o) {
467
+ const ref = o.ref && typeof o.ref === 'object' ? o.ref : null;
468
+ return /^(redteam_reports?|security_reports?|vulnerability_reports?)$/.test(String(o.source || ''))
469
+ || (!!ref && /vulnerab|red_?team|security_report/i.test(String(ref.type || '')))
470
+ || (typeof o.id === 'string' && /^(redteam_reports?|security_reports?|vulnerability_reports?):/.test(o.id))
471
+ || /^security report\b/i.test(String(o.title || o.name || ''))
472
+ || 'repro_steps' in o
473
+ || /red_?team|vulnerab|security_report/i.test(String(o.kind || ''));
474
+ }
475
+ function isBlockerRecord(o) {
476
+ const ref = o.ref && typeof o.ref === 'object' ? o.ref : null;
477
+ return String(o.source || '') === 'blockers' || (!!ref && String(ref.type || '') === 'blocker')
478
+ || Array.isArray(o.blocking_task_ids) || (typeof o.id === 'string' && /^blockers:/.test(o.id));
479
+ }
480
+
481
+ // The customer instances a set of fixtures names: the registrable label of every
482
+ // host not on PUBLIC_DOMAINS (`builders.someone.com` → `someone`).
483
+ function instanceNamesIn(values) {
484
+ const names = new Set();
485
+ const visit = (v) => {
486
+ if (typeof v === 'string') {
487
+ for (const m of v.matchAll(HOST_IN_TEXT)) {
488
+ const base = `${m[2]}.${m[3]}`.toLowerCase();
489
+ if (!isPublicDomain(base) && m[2].length >= 4) names.add(m[2].toLowerCase());
490
+ }
491
+ } else if (Array.isArray(v)) v.forEach(visit);
492
+ else if (v && typeof v === 'object') Object.values(v).forEach(visit);
493
+ };
494
+ values.forEach(visit);
495
+ return [...names];
496
+ }
497
+
498
+ // The goals whose work IS the security backlog (category `security`): their tasks
499
+ // describe open findings in their titles, so they are withheld by shape too.
500
+ function securityGoalIdsIn(values) {
501
+ const ids = new Set();
502
+ const visit = (v) => {
503
+ if (Array.isArray(v)) v.forEach(visit);
504
+ else if (v && typeof v === 'object') {
505
+ if (v.id != null && v.category && typeof v.category === 'object' && v.category.slug === 'security' && 'scope_modules' in v) ids.add(String(v.id));
506
+ Object.values(v).forEach(visit);
507
+ }
508
+ };
509
+ values.forEach(visit);
510
+ return [...ids];
511
+ }
512
+
513
+ // A task (it has a goal and a title) that belongs to the security backlog or is
514
+ // flagged security-sensitive.
515
+ const TASK_PROSE_KEYS = new Set(['description', 'value_summary', 'touches', 'next_ready_title']);
516
+ function isSecurityTask(o, securityGoals) {
517
+ if (!('title' in o) || !('goal_id' in o || 'security_sensitive' in o)) return false;
518
+ return o.security_sensitive === true || (o.goal_id != null && securityGoals.has(String(o.goal_id)));
519
+ }
520
+
521
+ // createSecurityRedactor({ instanceNames, securityGoals }) → (data) => a redacted deep copy.
522
+ function createSecurityRedactor({ instanceNames = [], securityGoals = [] } = {}) {
523
+ const secGoals = new Set(securityGoals.map(String));
524
+ const bare = instanceNames.length
525
+ ? new RegExp(`(?<![A-Za-z0-9_])(${instanceNames.map(escapeRe).join('|')})(?![A-Za-z0-9_])`, 'gi')
526
+ : null;
527
+ const deDomain = (s) => {
528
+ let out = s.replace(HOST_IN_TEXT, (host, subs, label, tld) => (isPublicDomain(`${label}.${tld}`.toLowerCase()) ? host : `${subs}instance.example`));
529
+ if (bare) out = out.replace(bare, 'instance-name');
530
+ return out;
531
+ };
532
+ const sensitive = (v) => typeof v === 'string' ? SENSITIVE_TEXT.test(v)
533
+ : Array.isArray(v) ? v.some(sensitive) : (v && typeof v === 'object') ? Object.values(v).some(sensitive) : false;
534
+
535
+ function walk(v, key, parent) {
536
+ if (typeof v === 'string') {
537
+ if (key === 'owner' && parent && typeof parent.repo === 'string') return v; // the repo's coordinates
538
+ return deDomain(v);
539
+ }
540
+ if (Array.isArray(v)) return v.map((x) => walk(x, null, v));
541
+ if (!v || typeof v !== 'object') return v;
542
+ if (isReportRecord(v)) {
543
+ const out = {};
544
+ for (const [k, val] of Object.entries(v)) {
545
+ if (TITLE_KEYS.has(k) && typeof val === 'string') out[k] = REPORT_TITLE;
546
+ else if (REPORT_KEYS.has(k)) out[k] = walk(val, k, v);
547
+ }
548
+ return out;
549
+ }
550
+ const blocker = isBlockerRecord(v);
551
+ const secTask = !blocker && isSecurityTask(v, secGoals);
552
+ const secGoal = v.id != null && 'scope_modules' in v && secGoals.has(String(v.id));
553
+ const out = {};
554
+ for (const [k, val] of Object.entries(v)) {
555
+ if (blocker && BLOCKER_BODY_KEYS.has(k)) continue;
556
+ if (secGoal && k === 'description') { out[k] = ''; continue; }
557
+ if (secTask && k === 'title') { out[k] = TASK_TITLE; continue; }
558
+ if (secTask && TASK_PROSE_KEYS.has(k)) { out[k] = Array.isArray(val) ? [] : typeof val === 'string' ? '' : null; continue; }
559
+ if (TITLE_KEYS.has(k) && typeof val === 'string' && sensitive(val)) {
560
+ out[k] = blocker ? BLOCKER_TITLE : ITEM_TITLE;
561
+ } else if (PROSE_KEYS.has(k) && sensitive(val)) {
562
+ out[k] = Array.isArray(val) ? [] : typeof val === 'string' ? '' : null;
563
+ } else out[k] = walk(val, k, v);
564
+ }
565
+ return out;
566
+ }
567
+ return (data) => walk(data, null, null);
568
+ }
569
+
570
+ // securityRedactorFor(values) — a redactor that has learned, from every value it
571
+ // will be applied to, the customer instances they name and the security goals.
572
+ function securityRedactorFor(values) {
573
+ return createSecurityRedactor({ instanceNames: instanceNamesIn(values), securityGoals: securityGoalIdsIn(values) });
229
574
  }
230
575
 
231
576
  // A fixture named `me` or `me__…` is the snapshotting account's own record.
@@ -297,6 +642,20 @@ function rewriteScalars(text, next) {
297
642
 
298
643
  module.exports = {
299
644
  createScrubber,
645
+ createTextScrubber,
646
+ createSecurityRedactor,
647
+ instanceNamesIn,
648
+ securityGoalIdsIn,
649
+ securityRedactorFor,
650
+ TASK_TITLE,
651
+ isReportRecord,
652
+ isBlockerRecord,
653
+ REPORT_KEYS,
654
+ TITLE_KEYS,
655
+ REPORT_TITLE,
656
+ BLOCKER_TITLE,
657
+ ITEM_TITLE,
658
+ SENSITIVE_TEXT,
300
659
  isOwnAccountFixture,
301
660
  highestStandIn,
302
661
  rewriteScalars,
@@ -81,6 +81,9 @@ const { injectBrandingGlobal, mountInternalSurfaces, injectModuleHallWidgets } =
81
81
  const { hallWidgetScripts } = require(path.join(ROOT, 'src', 'module-loader', 'loader.js'));
82
82
  const { clientModules } = require(path.join(ROOT, 'src', 'modules.js'));
83
83
  const { overrideKey, resolveLiveIdentity } = require('./live-identity');
84
+ // task 1004685: the per-builder pref PATCHes, answered by the real shapers in memory.
85
+ const { createPrefRecorder, readJsonBody } = require('./pref-writes');
86
+ const PREFS = createPrefRecorder();
84
87
 
85
88
  // The session is the ONE thing this harness reads from outside its own tree, and
86
89
  // it reads it through the FAIL-OPEN helper on purpose: apiCall/cliClient exit the
@@ -247,7 +250,20 @@ function emptied(v) {
247
250
  // case that needs it (task 1003281) is the profile activity log, where the ROW
248
251
  // SHAPE is chosen by ?section=, not by the path, so one fixture per path would
249
252
  // render goal rows into the tasks ledger.
253
+ // ONE PATH, TWO READS (task 1004685). /tasks with no status is the ready list the
254
+ // goal pages read (tasks.json); /tasks?status=active is the work being built, which
255
+ // the living city counts as agents on the move. Query strings are otherwise ignored,
256
+ // so a read that differs only by query names its fixture here, ahead of the exact match.
257
+ const QUERY_FIXTURES = [
258
+ [/^tasks$/, (q) => (q.status === 'active' ? 'tasks__status-active' : null)],
259
+ ];
260
+
250
261
  function fixtureFor(apiPath, query = {}) {
262
+ for (const [re, pick] of QUERY_FIXTURES) {
263
+ const name = re.test(apiPath) ? pick(query) : null;
264
+ const hit = name && loadFixture(name);
265
+ if (hit !== undefined && hit !== null) return hit;
266
+ }
251
267
  const exact = loadFixture(apiPath.replace(/\//g, '__'));
252
268
  if (exact !== undefined) return exact;
253
269
  for (const [re, name] of FIXTURE_FALLBACKS) {
@@ -330,6 +346,16 @@ app.use((req, res, next) => {
330
346
  const READ_SHAPED_POSTS = new Set([
331
347
  'inbox/preview-landing', // the landing verdict for a draft (task 1004083)
332
348
  ]);
349
+ // task 1004685: RECORDED WRITES (pref-writes.js) — the five /me/* pref PATCHes get
350
+ // the real route's answer from the real shapers, and the state rides later reads.
351
+ if (PREFS.handles(req.method, apiPath)) {
352
+ return readJsonBody(req).then((body) => {
353
+ if (body === null) return res.status(400).json({ error: { code: 'bad_json', message: 'the body is not JSON' } });
354
+ const me = liveOverrides.get('me') ?? fixtureFor('me');
355
+ const r = PREFS.write(apiPath, body, { me: RANK_OVERRIDE && me && me.builder ? { ...me, builder: { ...me.builder, rank: RANK_OVERRIDE } } : me, skillFixture: fixtureFor('me/skill-prefs') });
356
+ return res.status(r.status).json(r.body);
357
+ });
358
+ }
333
359
  const canned = CANNED_WRITES.find(([method, re]) => method === req.method && re.test(apiPath));
334
360
  if (canned) {
335
361
  const hit = loadFixture(canned[2]);
@@ -353,7 +379,7 @@ app.use((req, res, next) => {
353
379
  if (payload.builder) payload.builder.rank = RANK_OVERRIDE;
354
380
  else if ('rank' in payload) payload.rank = RANK_OVERRIDE;
355
381
  }
356
- res.json(payload);
382
+ res.json(PREFS.overlay(apiPath, payload));
357
383
  });
358
384
 
359
385
  // The hall pages, pre-auth-gate, through the real seams: boot-time stamped
@@ -79,8 +79,10 @@ for src in "${SOURCES[@]}"; do
79
79
  png="${src%.mmd}.png"
80
80
  echo "render-diagrams: $src -> $svg + $png"
81
81
  # SVG first (vector, scale-independent — the interactive viewer's primary
82
- # asset), then PNG at 2x (the static fallback). White background so both read
83
- # correctly on the parchment page and don't show through as transparent.
82
+ # asset), then PNG at 2x (the static fallback). White background so an
83
+ # external embed never shows through as transparent. The hall drops the SVG's
84
+ # white and, in dark, inverts both (task 1003639) — ONE light render serves
85
+ # both themes, so do not add a second, dark render here.
84
86
  "${MMDC[@]}" -i "$src" -o "$svg" -b white "${CONFIG_ARGS[@]}" >/dev/null 2>&1
85
87
  "${MMDC[@]}" -i "$src" -o "$png" -s 2 -b white "${CONFIG_ARGS[@]}" >/dev/null 2>&1
86
88
  done
@@ -272,11 +272,16 @@ function applicationVouchIdentity(token, { githubLogin, publicKey, clientId, now
272
272
  try {
273
273
  payload = verifyHubApplicationVouch(token, { publicKey, clientId, nowMs });
274
274
  } catch (err) {
275
- log.info({ reason: err.message }, 'application vouch not accepted — the request is written unvouched');
275
+ // debug, not info: this runs on the PUBLIC unauthenticated POST /access-requests,
276
+ // so anyone with a garbage header can trigger the line.
277
+ log.debug({ reason: err.message }, 'application vouch not accepted — the request is written unvouched');
276
278
  return null;
277
279
  }
278
280
  // The body's login is a string anyone may type: a vouch for another account is no vouch.
279
281
  if (String(payload.applicant_login).toLowerCase() !== String(githubLogin || '').toLowerCase()) return null;
282
+ // The hub mints only for a numeric GitHub id; anything else is not one, and the id
283
+ // feeds readApplicantProfileFromHub (defence in depth, the ADR 0171 D2 guard).
284
+ if (!/^\d+$/.test(String(payload.sub))) return null;
280
285
  if (vouchJtiSeenAndRecord(payload.jti, payload.exp, nowMs)) return null;
281
286
  return {
282
287
  githubId: String(payload.sub),
@@ -52,6 +52,7 @@ const SETTING_KEYS = {
52
52
  SHIP_MODE: 'ship.mode',
53
53
  TWEAK_AUTOPICKUP: 'tweak.autopickup',
54
54
  TWEAK_AUTOPICKUP_CAP: 'tweak.autopickup_monthly_cap',
55
+ SPEND_CAP: 'treasury.spend_cap_usd',
55
56
  };
56
57
 
57
58
  // SHIP MODE (task 1004628, ADR 0369) — WHO starts a ship, never HOW it lands.
@@ -349,6 +350,41 @@ function describeTweakAutopickup(resolved) {
349
350
  return `On: a running Autobongos runner applies submitted pages on its own, up to ${r.cap} a month. The artist still approves every one.`;
350
351
  }
351
352
 
353
+ // THE SPEND CAP (task 1004712, ADR 0379). The owner's ruling: there is NO spend
354
+ // limit by default. A cap is an optional project setting, OFF until someone sets
355
+ // it — no row means off, and clearing it deletes the row, so "never set" and
356
+ // "cleared" are the same state (the getSetting null-is-meaningful rule). The
357
+ // treasury (modules/government/treasury.js) reads it: with a cap on, a month past
358
+ // it is said so and a new loan is recommended against. It never stops work.
359
+ const MAX_SPEND_CAP_USD = 1000000;
360
+ function parseSpendCap(raw) {
361
+ if (raw == null || raw === '') return { reason: 'empty' };
362
+ const n = Number(raw);
363
+ if (!Number.isFinite(n)) return { reason: 'not a number' };
364
+ if (n <= 0) return { reason: 'must be more than $0; clear the cap to turn it off' };
365
+ if (n > MAX_SPEND_CAP_USD) return { reason: `out of range (1..${MAX_SPEND_CAP_USD})` };
366
+ return { usd: Math.round(n * 100) / 100 };
367
+ }
368
+ // resolveSpendCap() — { on, usd, source, invalid }. Off (usd null) unless a usable
369
+ // value is stored; an unreadable database answers off, the reporting posture.
370
+ async function resolveSpendCap() {
371
+ const r = await resolveOne(SETTING_KEYS.SPEND_CAP, parseSpendCap, (g) => g.usd, null);
372
+ return { on: r.value != null, usd: r.value, source: r.source, invalid: r.invalid };
373
+ }
374
+ // setSpendCap(usd, builderId) — set it, or turn it off with null.
375
+ async function setSpendCap(usd, builderId = null) {
376
+ if (usd == null || usd === '') {
377
+ await pool.query('DELETE FROM project_settings WHERE key = $1', [SETTING_KEYS.SPEND_CAP]);
378
+ logger.info(`project spend cap turned off by builder ${builderId == null ? 'unknown' : builderId}`);
379
+ return null;
380
+ }
381
+ return setSetting(SETTING_KEYS.SPEND_CAP, usd, builderId);
382
+ }
383
+ function describeSpendCap(resolved) {
384
+ const r = resolved || {};
385
+ return r.on ? `On: the treasury holds each month to $${Math.round(r.usd)}. Work is never stopped by it.` : 'Off: there is no spend limit. This is the default.';
386
+ }
387
+
352
388
  // parseDays is exported for the tests only — the range check and the default are
353
389
  // asserted against the SAME constants the resolver reads, because a test that
354
390
  // re-typed 30 or 365 would be the second copy of the number this file exists to
@@ -378,4 +414,9 @@ module.exports = {
378
414
  parseAutopickupCap,
379
415
  resolveTweakAutopickup,
380
416
  describeTweakAutopickup,
417
+ MAX_SPEND_CAP_USD,
418
+ parseSpendCap,
419
+ resolveSpendCap,
420
+ setSpendCap,
421
+ describeSpendCap,
381
422
  };
@@ -293,6 +293,33 @@ const GOVERNMENT_PINS = {
293
293
  // writes the table; neither route does.
294
294
  'POST /government/board/decision-rules': 'perm:board.item.open',
295
295
  'POST /government/board/matters': 'perm:board.item.open',
296
+ // task 1004706: the project's laws — a change is an amendment by another door
297
+ // (same atom as proposing one); the law book is read where the room is. Only a
298
+ // passed amendment writes a law; neither route does.
299
+ 'POST /government/board/laws': 'perm:board.item.open',
300
+ 'GET /government/laws': 'perm:board.vote.cast',
301
+ 'GET /government/presets': 'perm:board.vote.cast',
302
+ // task 1004716: the government's counts are read where the docket they count is.
303
+ 'GET /government/counts': 'perm:board.vote.cast',
304
+ 'POST /government/board/presets': 'perm:board.item.open',
305
+ // task 1004709: the Lead of each building, read where the room is. A Lead is
306
+ // appointed and removed only by a passed People matter (the matters route).
307
+ 'GET /government/leads': 'perm:board.vote.cast',
308
+ // task 1004711: a Lead's build-out. The room's atom; the wall is server-side —
309
+ // only the building's holder may ask, and build_rights decides at once or a vote.
310
+ 'POST /government/buildouts': 'perm:board.vote.cast',
311
+ // task 1004712: the treasury. Read where the room is; a pot is set only by whoever
312
+ // answers for the Treasury, and a borrow answered only by the seat it waits on —
313
+ // both walls server-side, per request.
314
+ 'GET /government/treasury': 'perm:board.vote.cast',
315
+ 'PUT /government/treasury/pots/:goalId': 'perm:board.vote.cast',
316
+ 'POST /government/treasury/borrows/:borrowId/answer': 'perm:board.vote.cast',
317
+ // task 1004703: the ask, one shape for every request from an agent to a person.
318
+ // Checked and written where the docket is read; the write also passes the
319
+ // matter's own source gate (docket.findMatter), so an ask never outreaches its matter.
320
+ 'POST /government/asks/check': 'perm:board.vote.cast',
321
+ 'PUT /government/asks/:matterId': 'perm:board.vote.cast',
322
+ 'POST /government/asks/:matterId/pass': 'perm:board.vote.cast',
296
323
  // task 1004564: a proposer withdraws their own open sitting before any vote —
297
324
  // the proposer's half of putting one up, so the same atom. The proposer-only
298
325
  // and no-votes walls are in withdrawItem (board-withdraw.js) and its write.