@bongos/core 1.19.1073 → 1.19.1074

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 (297) hide show
  1. package/.bongos-core.json +320 -465
  2. package/.claude/skills/feedback/SKILL.md +2 -2
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +4 -60
  5. package/clients/bongos-client/index.cjs +4 -60
  6. package/clients/bongos-client/index.d.ts +5 -90
  7. package/clients/bongos-client/index.mjs +4 -60
  8. package/config/branding.neutral.json +1 -5
  9. package/config/modules.neutral.json +2 -3
  10. package/config/scheduled-routines.json +0 -2
  11. package/docs/adr/0145-free-hosted-project-tier-isolation-and-domain-separation.md +1 -0
  12. package/docs/adr/0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md +1 -0
  13. package/docs/adr/0323-hosting-is-three-shapes-and-we-are-not-the-landlord.md +1 -1
  14. package/docs/adr/0327-a-cloud-host-runs-on-the-owners-account-and-the-key-is-borrowed.md +1 -1
  15. package/docs/adr/0341-the-page-is-the-unit-of-tweak-mode.md +2 -0
  16. package/docs/adr/0345-we-host-every-projects-hall-and-its-app-deploys-where-the-owner-chooses.md +94 -0
  17. package/docs/adr/README.md +1 -0
  18. package/docs/api/openapi.json +171 -1661
  19. package/docs/api-reference.md +9 -41
  20. package/docs/architecture.md +16 -1
  21. package/docs/branding-contract.md +0 -3
  22. package/docs/copy-inventory.md +454 -479
  23. package/docs/copy-registry.json +746 -978
  24. package/docs/file-map.md +5 -34
  25. package/docs/module-api-changelog.md +2 -0
  26. package/docs/modules-contract.md +13 -1
  27. package/docs/onboarding/diagrams/04-architecture.mmd +11 -22
  28. package/docs/onboarding/diagrams/README.md +3 -3
  29. package/docs/onboarding/diagrams/assertions.json +2 -22
  30. package/docs/onboarding/primer.md +9 -28
  31. package/docs/page-inventory.json +4 -1
  32. package/docs/page-readings.json +1032 -1077
  33. package/docs/recipes/render-pilot.md +29 -10
  34. package/migrations/core_257_module_store_registry.sql +87 -0
  35. package/migrations/core_258_drop_dev_box_tables.sql +55 -0
  36. package/modules/agents/module.json +1 -0
  37. package/modules/autonomy/module.json +1 -0
  38. package/modules/builder-settings/module.json +1 -0
  39. package/modules/builder-settings/render-prefs.js +4 -5
  40. package/modules/copy-desk/docx.js +78 -5
  41. package/modules/copy-desk/module.json +1 -0
  42. package/modules/copy-desk/routes/copy-desk.js +9 -9
  43. package/modules/copy-desk/tests/copy_docx.mjs +107 -1
  44. package/modules/copy-desk/tests/fixtures/word-docx.mjs +30 -0
  45. package/modules/discord/module.json +1 -0
  46. package/modules/economy/module.json +1 -0
  47. package/modules/economy/routes/credits.js +1 -2
  48. package/modules/government/catalog.js +13 -17
  49. package/modules/government/migrations/government_019_retire_box_permissions.sql +33 -0
  50. package/modules/government/module.json +1 -0
  51. package/modules/government/protected-surfaces.json +3 -11
  52. package/modules/government/resolver.js +3 -46
  53. package/modules/government/routes/government.js +0 -2
  54. package/modules/government/session-scopes.js +43 -83
  55. package/modules/government/session-scopes.json +8 -18
  56. package/modules/grading/module.json +1 -0
  57. package/modules/hall-ui/module.json +1 -0
  58. package/modules/hall-ui/public/board-lib.js +2 -2
  59. package/modules/hall-ui/public/builders.js +29 -40
  60. package/modules/hall-ui/public/collab.html +1 -1
  61. package/modules/hall-ui/public/collab.js +1 -1
  62. package/modules/hall-ui/public/diagrams.html +3 -3
  63. package/modules/hall-ui/public/dom-utils.js +1 -1
  64. package/modules/hall-ui/public/gate.js +2 -2
  65. package/modules/hall-ui/public/gate.states.json +1 -1
  66. package/modules/hall-ui/public/hall-render.js +43 -79
  67. package/modules/hall-ui/public/index.html +5 -1
  68. package/modules/hall-ui/public/modules.js +1 -0
  69. package/modules/hall-ui/public/oversight.css +5 -5
  70. package/modules/hall-ui/public/palette.js +0 -11
  71. package/modules/hall-ui/public/primer.js +1 -1
  72. package/modules/hall-ui/public/sessions.html +1 -1
  73. package/modules/hall-ui/public/settings-sessions.js +1 -1
  74. package/modules/hall-ui/public/settings.css +0 -1
  75. package/modules/hall-ui/public/settings.html +4 -33
  76. package/modules/hall-ui/public/settings.js +3 -33
  77. package/modules/hall-ui/public/settings.states.json +2 -2
  78. package/modules/hall-ui/public/studio.html +1 -1
  79. package/modules/hall-ui/public/style.css +10 -13
  80. package/modules/hall-ui/public/task.html +4 -1
  81. package/modules/hall-ui/public/task.js +36 -1
  82. package/modules/hall-ui/public/tweak-editor-lib.js +18 -1
  83. package/modules/hall-ui/public/tweak-editor.css +16 -0
  84. package/modules/hall-ui/public/tweak-editor.html +26 -3
  85. package/modules/hall-ui/public/tweak-editor.js +32 -1
  86. package/modules/hall-ui/public/work.js +1 -1
  87. package/modules/hall-ui/records/tweak-editor.md +1 -0
  88. package/modules/ideas/module.json +1 -0
  89. package/modules/lifecycle/db-claim-reads.js +1 -58
  90. package/modules/lifecycle/db-ship.js +0 -5
  91. package/modules/lifecycle/db.js +0 -4
  92. package/modules/lifecycle/kickoff-checklist.js +0 -4
  93. package/modules/lifecycle/lifecycle.js +0 -8
  94. package/modules/lifecycle/module.json +1 -0
  95. package/modules/lifecycle/routes/tasks.js +5 -7
  96. package/modules/memory/module.json +1 -0
  97. package/modules/npm-release/module.json +1 -0
  98. package/modules/onboarding/module.json +1 -0
  99. package/modules/onboarding/onboarding-state.js +17 -36
  100. package/modules/onboarding/routes/access-requests.js +3 -3
  101. package/modules/platform-identity/module.json +1 -0
  102. package/modules/platform-identity/platform-identity.js +13 -5
  103. package/modules/platform-identity/routes/sso.js +2 -2
  104. package/modules/platform-identity/tests/platform-identity.mjs +17 -3
  105. package/modules/provisioning/module.json +1 -0
  106. package/modules/provisioning/provisioning.js +2 -2
  107. package/modules/provisioning/starter-bundles.js +5 -25
  108. package/modules/public-landing/module.json +1 -0
  109. package/modules/public-landing/public/assets/cosmos.css +1 -1
  110. package/modules/public-landing/public/contact.html +1 -1
  111. package/modules/public-landing/public/privacy.html +1 -1
  112. package/modules/public-landing/public/projects.html +101 -32
  113. package/modules/public-landing/public/projects.probes.json +2 -2
  114. package/modules/public-landing/public/projects.states.json +4 -3
  115. package/modules/public-landing/public/terms.html +1 -1
  116. package/modules/security/module.json +1 -0
  117. package/modules/sessions/module.json +1 -0
  118. package/modules/specialities/module.json +1 -0
  119. package/modules/status-ui/module.json +1 -0
  120. package/modules/ui-design/module.json +1 -0
  121. package/package-lock.json +2 -2
  122. package/package.json +1 -1
  123. package/release-notes.json +78 -0
  124. package/scripts/gds/artifact-format.js +203 -0
  125. package/scripts/gds/audit-authorship.sh +1 -13
  126. package/scripts/gds/claim.js +0 -15
  127. package/scripts/gds/cli-lib.js +8 -8
  128. package/scripts/gds/diagram-facts.js +12 -23
  129. package/scripts/gds/do-api.js +13 -80
  130. package/scripts/gds/doc-cli-guard.js +2 -0
  131. package/scripts/gds/doctor.js +1 -1
  132. package/scripts/gds/feedback-latest.js +139 -9
  133. package/scripts/gds/fitness-checks-identity.js +2 -7
  134. package/scripts/gds/fitness-checks-write-validation.js +4 -0
  135. package/scripts/gds/fitness-ratchets.js +4 -4
  136. package/scripts/gds/fitness.js +10 -10
  137. package/scripts/gds/gen-diagrams.js +49 -71
  138. package/scripts/gds/http-api-client.js +12 -19
  139. package/scripts/gds/init.js +1 -1
  140. package/scripts/gds/lint-infra-exec.js +1 -1
  141. package/scripts/gds/local-preview-lib.js +8 -39
  142. package/scripts/gds/local-preview.js +2 -15
  143. package/scripts/gds/migration-namespace.js +3 -3
  144. package/scripts/gds/module-artifact.js +212 -0
  145. package/scripts/gds/module.js +95 -6
  146. package/scripts/gds/package-core.js +5 -116
  147. package/scripts/gds/provision-config.js +2 -2
  148. package/scripts/gds/provision-net.js +2 -3
  149. package/scripts/gds/provision.js +9 -9
  150. package/scripts/gds/publish-manifest.js +1 -2
  151. package/scripts/gds/regen-instance-docs.js +1 -1
  152. package/scripts/gds/run-unit-tests.js +2 -16
  153. package/scripts/gds/sandbox-stage.js +39 -341
  154. package/scripts/gds/ship-flow.js +6 -6
  155. package/scripts/gds/ship-land.js +3 -4
  156. package/scripts/gds/ship-regen.js +10 -41
  157. package/scripts/gds/ship.js +4 -24
  158. package/scripts/gds/start.js +0 -15
  159. package/scripts/hall-preview/README.md +5 -5
  160. package/scripts/hall-preview/server.js +6 -6
  161. package/scripts/render-diagrams.sh +1 -1
  162. package/src/bongos/api-errors.js +1 -1
  163. package/src/bongos/auth-github.js +2 -3
  164. package/src/bongos/auth.js +51 -126
  165. package/src/bongos/db-kernel.js +2 -3
  166. package/src/bongos/db.js +5 -28
  167. package/src/bongos/module-scope-map.js +14 -29
  168. package/src/bongos/module-store.js +141 -0
  169. package/src/bongos/route-rank-check.js +3 -16
  170. package/src/bongos/routes/auth.js +0 -116
  171. package/src/bongos/routes/builders.js +5 -15
  172. package/src/bongos/routes/instance.js +8 -14
  173. package/src/bongos/routes/me.js +6 -22
  174. package/src/bongos/routes/modules.js +83 -0
  175. package/src/bongos/routes.js +5 -0
  176. package/src/module-api.js +13 -6
  177. package/src/module-loader/catalog.js +3 -0
  178. package/src/module-loader/loader.js +4 -4
  179. package/src/module-loader/manifest-schema.js +15 -7
  180. package/src/module-seams.js +2 -3
  181. package/src/modules.js +42 -14
  182. package/tests/agents_spend_guard.mjs +1 -1
  183. package/tests/api_client.mjs +0 -15
  184. package/tests/auth_page_gate.mjs +10 -10
  185. package/tests/canonical_profile_url.mjs +1 -3
  186. package/tests/claim_action.mjs +4 -1
  187. package/tests/claim_from_task_and_home.mjs +127 -0
  188. package/tests/consumer_layout_boot.mjs +2 -0
  189. package/tests/copy_desk_page_docx.mjs +33 -2
  190. package/tests/core_upgrade_runner.mjs +6 -6
  191. package/tests/deploy_divergence_line.mjs +11 -11
  192. package/tests/diagram_facts_offset.mjs +1 -1
  193. package/tests/do_api.mjs +106 -0
  194. package/tests/feedback_latest.mjs +122 -0
  195. package/tests/fitness.mjs +44 -42
  196. package/tests/gate_approvals.mjs +0 -1
  197. package/tests/go_live.mjs +3 -3
  198. package/tests/government_abuse_matrix.mjs +20 -19
  199. package/tests/government_ownership_scope.mjs +33 -35
  200. package/tests/government_parity.mjs +2 -2
  201. package/tests/government_protected_surfaces.mjs +56 -37
  202. package/tests/government_require_permission.mjs +3 -3
  203. package/tests/government_seed.mjs +37 -4
  204. package/tests/government_session_scope.mjs +92 -366
  205. package/tests/hall_error_envelope.mjs +4 -0
  206. package/tests/hall_landing_boot.mjs +4 -1
  207. package/tests/hall_palette.mjs +1 -16
  208. package/tests/hall_record_world.mjs +6 -0
  209. package/tests/hall_tweak_editor.mjs +52 -1
  210. package/tests/host_topology_skips.mjs +1 -2
  211. package/tests/html_comment_nesting.mjs +137 -0
  212. package/tests/infra_exec_paths.mjs +9 -10
  213. package/tests/init.mjs +4 -5
  214. package/tests/instance_manifest.mjs +18 -25
  215. package/tests/kickoff_checklist.mjs +1 -1
  216. package/tests/lib_sh_resolution.mjs +0 -221
  217. package/tests/local_preview.mjs +0 -12
  218. package/tests/module-scope-map.mjs +9 -9
  219. package/tests/module_catalog.mjs +1 -1
  220. package/tests/module_cli.mjs +15 -15
  221. package/tests/module_contributions.mjs +6 -6
  222. package/tests/module_loader.mjs +6 -6
  223. package/tests/module_manifest.mjs +13 -4
  224. package/tests/module_store_publish.mjs +307 -0
  225. package/tests/module_store_publish_route.mjs +121 -0
  226. package/tests/module_store_registry_migration.mjs +70 -0
  227. package/tests/modules.mjs +56 -26
  228. package/tests/onboarding_route_signals.mjs +21 -31
  229. package/tests/onboarding_state.mjs +35 -49
  230. package/tests/permission_path.mjs +0 -3
  231. package/tests/platform_boot.mjs +1 -2
  232. package/tests/profile_route.mjs +1 -6
  233. package/tests/projects_hub.mjs +5 -5
  234. package/tests/projects_hub_app_step.mjs +138 -0
  235. package/tests/projects_hub_module_picker.mjs +2 -2
  236. package/tests/projects_hub_pre_uat.mjs +4 -5
  237. package/tests/provision.mjs +7 -7
  238. package/tests/provisioning_starter_bundles.mjs +0 -22
  239. package/tests/publish_branch_route.mjs +10 -18
  240. package/tests/publish_manifest.mjs +1 -2
  241. package/tests/rank_tier_single_source.mjs +1 -13
  242. package/tests/repo_map.mjs +3 -4
  243. package/tests/runner_drift.mjs +3 -21
  244. package/tests/sandbox_stage.mjs +57 -390
  245. package/tests/session_rename_fallback.mjs +0 -21
  246. package/tests/ship_error_shape.mjs +1 -1
  247. package/tests/task_visual_slots.mjs +31 -0
  248. package/tests/terms_acceptance.mjs +1 -1
  249. package/tests/upgrade.mjs +4 -4
  250. package/tests/watch_sealed_floor.mjs +0 -1
  251. package/tests/wizard_draft_resume.mjs +2 -2
  252. package/tests/wizard_intent_resume.mjs +21 -10
  253. package/tests/wizard_preselect_why.mjs +18 -18
  254. package/docs/onboarding/browser-terminal-guide.md +0 -73
  255. package/docs/recipes/managed-settings-remote-control.md +0 -416
  256. package/modules/dev-box/CLAUDE.md +0 -15
  257. package/modules/dev-box/box-access.js +0 -623
  258. package/modules/dev-box/box-credential.js +0 -94
  259. package/modules/dev-box/box-onboard.js +0 -486
  260. package/modules/dev-box/boxes.js +0 -1038
  261. package/modules/dev-box/db.js +0 -39
  262. package/modules/dev-box/module.json +0 -19
  263. package/modules/dev-box/routes/box.js +0 -1223
  264. package/scripts/gds/box-auth-check.js +0 -160
  265. package/scripts/gds/box-infra.js +0 -321
  266. package/scripts/gds/box-sync.js +0 -216
  267. package/scripts/gds/box.js +0 -1332
  268. package/scripts/gds/cf-tunnel.js +0 -558
  269. package/scripts/gds/smoke-box.sh +0 -150
  270. package/scripts/gds/tree-preflight.js +0 -241
  271. package/src/bongos/app-pair.js +0 -172
  272. package/tests/app_pair.mjs +0 -168
  273. package/tests/box_access.mjs +0 -578
  274. package/tests/box_auth_check.mjs +0 -114
  275. package/tests/box_code_staleness.mjs +0 -129
  276. package/tests/box_connect_e2e.mjs +0 -196
  277. package/tests/box_cost.mjs +0 -70
  278. package/tests/box_credential.mjs +0 -125
  279. package/tests/box_dns_repoint.mjs +0 -153
  280. package/tests/box_env.mjs +0 -64
  281. package/tests/box_host_keys.mjs +0 -126
  282. package/tests/box_infra_resolve.mjs +0 -246
  283. package/tests/box_onboard.mjs +0 -317
  284. package/tests/box_scope_session.mjs +0 -119
  285. package/tests/box_sweep_docs.mjs +0 -119
  286. package/tests/box_sync.mjs +0 -55
  287. package/tests/box_sync_scope_report.mjs +0 -126
  288. package/tests/box_task_scope.mjs +0 -169
  289. package/tests/box_task_scope_pause.mjs +0 -66
  290. package/tests/box_terminal_ssrf.mjs +0 -96
  291. package/tests/boxes.mjs +0 -1585
  292. package/tests/cf_ruleset_preflight.mjs +0 -142
  293. package/tests/cf_tunnel.mjs +0 -496
  294. package/tests/docs_scrubber_damage.mjs +0 -111
  295. package/tests/module_scope_active_claims.mjs +0 -94
  296. package/tests/ship_api_client_pathspec.mjs +0 -100
  297. package/tests/tree_preflight.mjs +0 -243
@@ -6,6 +6,19 @@ The executable half of [task 1004142](https://cloudbongos.com/builders#/task/100
6
6
 
7
7
  **The API shape below was read from Render's own reference on 2026-09-23** (`api-docs.render.com`), not inferred. Where this recipe and a live response disagree, the response wins and this file is the bug.
8
8
 
9
+ > **Corrections, checked against mercury's vendored core (1.19.305) on 2026-09-28 (task 1004142).** Six things in §4–§6 below would have failed on the real repo; §4's payload now carries all of them, and the list says why. Where the prose below still disagrees with this box, this box wins:
10
+ >
11
+ > 1. **There is no `npm start`.** An adopted instance's `package.json` has only the `@bongos/core` dependency, no scripts. The start command is `node node_modules/@bongos/core/src/platform-server.js`, the same thing the box's unit runs (`serviceUnit` in `scripts/gds/provision-units.js`). Set it explicitly, because the repo's `Procfile` starts the owner's Python app.
12
+ > 2. **The env prefix is the instance's own pack, not the neutral one.** mercury's `config/branding.json` pins `envPrefix: "CLOUDBONGOS"`, so every `BONGOS_*` in §4 and §5 is `CLOUDBONGOS_*` for this repo. A wrong prefix is silently ignored and the hall never loads.
13
+ > 3. **Set `BUILDER_SECRET_KEY`.** Without it `src/bongos/secret-box.js` falls back to a key file in the config home, and Render wipes the disk on every deploy. Each deploy would mint a new key and orphan every stored per-builder secret. A random 32-byte hex value held in Render's env is enough.
14
+ > 4. **Migrations run as Render's pre-deploy command**, as `bash node_modules/@bongos/core/scripts/migrate.sh`. `migrate.sh` addresses the database as `psql -d "$PGDATABASE"`, so set `PGDATABASE` to the same connection URL as `DATABASE_URL`. Whether Render's Node runtime ships `psql` is one of the things the pilot measures.
15
+ > 5. **The OAuth callback is `<origin>/api/gds/auth/web/callback`** — what `GET /api/gds/auth/web/start` actually sends (`src/bongos/routes/auth.js`). The `/builders/auth/github/callback` in `src/bongos/auth.js`'s header comment is stale; GitHub refuses it with "redirect_uri is not associated with this application". If the sign-in app is a GitHub App rather than an OAuth App, its Callback URL field must be filled in too, or GitHub answers "must be configured with a callback URL". Also set `NODE_VERSION=22`, because the core declares `engines.node >=22`.
16
+ > 6. **Set `HOST=0.0.0.0`.** `src/platform-server.js` binds `127.0.0.1` unless `HOST` is set. On the box Caddy is on the same machine, but Render's router is not: the server logs "listening", the health check never reaches it, and the deploy sits at `update_in_progress` while the URL answers `502` with `x-render-routing: no-deploy`. Measured live 2026-09-28.
17
+ >
18
+ > **One more, about §2: clone a stripped copy, not the whole repo.** §2's full clone would copy willHermesLines' warehouse application into a repo they never agreed to. The pilot needs only the Bongos layer (`package.json`, the lockfile, `vendor/*.tgz` and `config/`), plus the `Procfile` and a stub `app.py` so the hybrid shape is kept. Twelve files are enough.
19
+ >
20
+ > **What the 2026-09-28 run actually did.** The owner chose to deploy from `willHermesLines/mercury_bongos` itself instead of a clone. That is safe in a narrower sense than a clone: Render only *reads* the repo, the service and its database are new, and live `mercury` (row 31) is untouched. But it needs the platform owner to have Render access to someone else's private repo, which a real customer standup will not have. Both paths are valid for a pilot. The stripped clone is the one to use when you don't have that access. The `envPrefix` above is mercury's; a greenfield instance on the neutral pack uses `BONGOS_*`.
21
+
9
22
  ---
10
23
 
11
24
  ## 0. Before you start — three things worth knowing
@@ -117,23 +130,29 @@ curl -sS -X POST -H "Authorization: Bearer $RENDER_TOKEN" -H 'Content-Type: appl
117
130
  "autoDeploy": "yes",
118
131
  "serviceDetails": {
119
132
  "runtime": "node",
120
- "plan": "0.5c-512mb",
133
+ "plan": "starter",
121
134
  "region": "virginia",
122
135
  "healthCheckPath": "/healthz",
123
136
  "envSpecificDetails": {
124
137
  "buildCommand": "npm ci",
125
- "startCommand": "npm start"
138
+ "startCommand": "node node_modules/@bongos/core/src/platform-server.js",
139
+ "preDeployCommand": "bash node_modules/@bongos/core/scripts/migrate.sh"
126
140
  }
127
141
  },
128
142
  "envVars": [
143
+ { "key": "NODE_VERSION", "value": "22" },
144
+ { "key": "HOST", "value": "0.0.0.0" },
129
145
  { "key": "DATABASE_URL", "value": "<internalConnectionString>" },
130
- { "key": "BONGOS_GITHUB_CLIENT_ID", "value": "" },
131
- { "key": "BONGOS_GITHUB_CLIENT_SECRET", "value": "" },
132
- { "key": "BONGOS_FIRST_ADMIN", "value": "<your-github-login>" },
133
- { "key": "BONGOS_PUBLIC_ORIGIN", "value": "https://render-pilot.onrender.com" },
134
- { "key": "BONGOS_BUILDERS_ORIGIN", "value": "https://render-pilot.onrender.com" },
135
- { "key": "BONGOS_STATUS_ORIGIN", "value": "https://render-pilot.onrender.com" },
136
- { "key": "BONGOS_OAUTH_ORIGIN", "value": "https://render-pilot.onrender.com" }
146
+ { "key": "PGDATABASE", "value": "<internalConnectionString>" },
147
+ { "key": "PGPOOL_MAX", "value": "5" },
148
+ { "key": "BUILDER_SECRET_KEY", "value": "<64 random hex chars>" },
149
+ { "key": "CLOUDBONGOS_GITHUB_CLIENT_ID", "value": "" },
150
+ { "key": "CLOUDBONGOS_GITHUB_CLIENT_SECRET", "value": "" },
151
+ { "key": "CLOUDBONGOS_FIRST_ADMIN", "value": "<your-github-login>" },
152
+ { "key": "CLOUDBONGOS_PUBLIC_ORIGIN", "value": "https://render-pilot.onrender.com" },
153
+ { "key": "CLOUDBONGOS_BUILDERS_ORIGIN", "value": "https://render-pilot.onrender.com" },
154
+ { "key": "CLOUDBONGOS_STATUS_ORIGIN", "value": "https://render-pilot.onrender.com" },
155
+ { "key": "CLOUDBONGOS_OAUTH_ORIGIN", "value": "https://render-pilot.onrender.com" }
137
156
  ]
138
157
  }' | jq
139
158
  ```
@@ -142,7 +161,7 @@ Notes that matter:
142
161
 
143
162
  - **`PORT` is not set by us.** Render injects it; `webEnvBody` writes it because the box allocates ports itself. One more line the standup leg drops.
144
163
  - **The four origin overrides are load-bearing, not cosmetic.** Without them `src/branding.js` stays on neutral localhost and the hall never loads (task 1972). They must be the Render hostname, and they must all four be the same host — a single-host instance has no separate `builders.`/`status.` subdomain.
145
- - **`BONGOS_FIRST_ADMIN` is what seats you as Archon** on first sign-in. Omit it and the instance has no owner and you can never claim it (the 2026-07-07 `demo` regression).
164
+ - **`CLOUDBONGOS_FIRST_ADMIN` is what seats you as Archon** on first sign-in. Omit it and the instance has no owner and you can never claim it (the 2026-07-07 `demo` regression).
146
165
  - **OAuth creds start blank** and are filled after the app exists — see step 5.
147
166
  - `PGPOOL_MAX` is read bare and still applies. On `basic_256mb` the connection ceiling is low; if the app struggles to connect, set it small (the box uses `PROVISION_INSTANCE_PGPOOL_MAX`).
148
167
  - Do **not** send a `secretFiles` entry for the GitHub App PEM until you know whether the App path is in scope for the pilot; blank OAuth is enough to prove boot.
@@ -0,0 +1,87 @@
1
+ -- core_257_module_store_registry.sql — the module store's registry: a row per
2
+ -- known module, a row per published version, and the price it is offered at.
3
+ --
4
+ -- task 1003812 (goal 1000091, working area 5). ADR 0338 D1 puts the store on the
5
+ -- control plane: an author publishes a packaged module version to it (task
6
+ -- 1004271), an instance acquires one from it (task 1003785). These three tables
7
+ -- are what both of those read and write. They are the STORE SURFACE ONLY:
8
+ -- balances, taking payment, fees and author payout belong to area 8 (goal
9
+ -- 1000092) and are deliberately not modelled here. Entitlement (1003813),
10
+ -- channels (1003814) and scores (1003815) are their own tasks and their own
11
+ -- tables; nothing here pre-empts their shape.
12
+ --
13
+ -- store_modules one row per module key the store knows. `status`
14
+ -- only governs what comes NEXT (ADR 0338 D2): a
15
+ -- delisted module leaves the catalog and takes no new
16
+ -- acquisitions or updates, but installed copies keep
17
+ -- working forever — nothing here can reach them.
18
+ -- store_module_versions one row per published version. Immutable once
19
+ -- written (a version names one exact build, so the
20
+ -- publish path never UPDATEs a row; a fix is a new
21
+ -- version). `version` is the module's own exact semver
22
+ -- from module.json (task 1003781); `tree_sha256` and
23
+ -- `tarball_sha256` are the package-core.js manifest
24
+ -- pins install verifies against; `artifact_path` is
25
+ -- where the tarball sits (DO Spaces key or a path
26
+ -- under the control plane's store dir).
27
+ -- store_module_prices the price history. The CURRENT price is the newest
28
+ -- row per module. Money and credits are separate
29
+ -- columns because a module can be bought with either;
30
+ -- ZERO is a first-class price (free modules are
31
+ -- assessed identically — criterion wa5-quality-assessed),
32
+ -- so NULL means "not offered in this currency" and
33
+ -- 0 means "free". Kept as history rather than a column
34
+ -- on store_modules so a buyer's price at acquisition
35
+ -- time stays answerable.
36
+ --
37
+ -- Additive only (new tables), so forward-safe. Idempotent: safe to re-run.
38
+
39
+ CREATE TABLE IF NOT EXISTS store_modules (
40
+ module_key text PRIMARY KEY
41
+ CHECK (module_key ~ '^[a-z][a-z0-9-]*$'),
42
+ title text NOT NULL,
43
+ description text NOT NULL DEFAULT '',
44
+ author_id bigint NOT NULL REFERENCES builders(id),
45
+ status text NOT NULL DEFAULT 'listed'
46
+ CHECK (status IN ('listed', 'delisted')),
47
+ delisted_at timestamptz,
48
+ created_at timestamptz NOT NULL DEFAULT now(),
49
+ CHECK ((status = 'delisted') = (delisted_at IS NOT NULL))
50
+ );
51
+
52
+ CREATE INDEX IF NOT EXISTS idx_store_modules_author ON store_modules (author_id);
53
+
54
+ CREATE TABLE IF NOT EXISTS store_module_versions (
55
+ id bigserial PRIMARY KEY,
56
+ module_key text NOT NULL REFERENCES store_modules(module_key),
57
+ version text NOT NULL
58
+ CHECK (version ~ '^[0-9]+\.[0-9]+\.[0-9]+$'),
59
+ core_version text NOT NULL,
60
+ manifest jsonb NOT NULL,
61
+ tree_sha256 text NOT NULL CHECK (tree_sha256 ~ '^[0-9a-f]{64}$'),
62
+ tarball_sha256 text NOT NULL CHECK (tarball_sha256 ~ '^[0-9a-f]{64}$'),
63
+ tarball_bytes bigint NOT NULL CHECK (tarball_bytes > 0),
64
+ artifact_path text NOT NULL,
65
+ published_by bigint NOT NULL REFERENCES builders(id),
66
+ published_at timestamptz NOT NULL DEFAULT now(),
67
+ UNIQUE (module_key, version)
68
+ );
69
+
70
+ CREATE INDEX IF NOT EXISTS idx_store_module_versions_key
71
+ ON store_module_versions (module_key, published_at DESC);
72
+
73
+ CREATE TABLE IF NOT EXISTS store_module_prices (
74
+ id bigserial PRIMARY KEY,
75
+ module_key text NOT NULL REFERENCES store_modules(module_key),
76
+ price_cents integer CHECK (price_cents >= 0),
77
+ price_credits integer CHECK (price_credits >= 0),
78
+ set_by bigint NOT NULL REFERENCES builders(id),
79
+ set_at timestamptz NOT NULL DEFAULT now(),
80
+ CHECK (price_cents IS NOT NULL OR price_credits IS NOT NULL)
81
+ );
82
+
83
+ CREATE INDEX IF NOT EXISTS idx_store_module_prices_current
84
+ ON store_module_prices (module_key, set_at DESC, id DESC);
85
+
86
+ INSERT INTO schema_migrations (version) VALUES ('core_257_module_store_registry')
87
+ ON CONFLICT (version) DO NOTHING;
@@ -0,0 +1,55 @@
1
+ -- core_258_drop_dev_box_tables.sql — drop the dev box's four tables and its session
2
+ -- source (task 1003895, goal 1000120).
3
+ --
4
+ -- Goal 1000120 removed the dev box: its module, routes and pollers (task 1003892),
5
+ -- its desktop pairing (1004310), its hall surfaces (1004311) and its session scope
6
+ -- and permissions (1003894). Owner decision 2026-09-13: drop the box's stored state
7
+ -- rather than keep it (blocker 1000159 — the historic box compute spend is not worth
8
+ -- preserving). What this removes:
9
+ --
10
+ -- builder_boxes, box_events migration 067 (+ columns from 071/073/078/079/105/
11
+ -- 121/core_237/core_238, all on builder_boxes)
12
+ -- box_intents migration 070 (reshaped by 071, 102)
13
+ -- builder_ssh_keys migration 069
14
+ -- builder_sessions 'box' migration 090 widened the source CHECK to admit it;
15
+ -- its rows are deleted and the CHECK narrowed back to
16
+ -- ('cli','web'). Since task 1003894 those rows were
17
+ -- already refused on every route and page.
18
+ --
19
+ -- NOT HERE, deliberately: the four `builders.box_blocked*` columns (migration 104).
20
+ -- A deploy runs migrations while the PREVIOUS server is still up, and an upgrade that
21
+ -- fails rolls the code back but never the schema. The previous code's
22
+ -- getBuilderById SELECTs those columns, so dropping them in the same release would
23
+ -- break every builder read for as long as that code runs. The code stops reading them
24
+ -- in this change; the columns go in task 1004360 (filed with this one, depends on it),
25
+ -- once no live instance runs a core that reads them.
26
+ --
27
+ -- NOT HERE either: cost_log rows the dev box wrote. cost_log has no box category (its
28
+ -- CHECK is api/compute/infra/domain/art/other), so they are ordinary 'compute' rows
29
+ -- recording money that was actually spent; the cost ledger keeps them.
30
+ --
31
+ -- forward-unsafe: drops the dev box's own tables. Their only reader in any previous
32
+ -- core was the dev-box module (removed in task 1003892), so a code rollback past this
33
+ -- migration breaks only that module's box features — never sign-in, claims or any
34
+ -- core read. The one core read of box state (builders.box_blocked*) is deliberately
35
+ -- left for a later migration, above.
36
+ --
37
+ -- Order matters and CASCADE is deliberately absent: box_events references
38
+ -- builder_boxes, so it goes first; if anything outside these four tables still
39
+ -- depended on them the DROP fails loudly rather than silently taking it too.
40
+ -- Idempotent: every statement is IF EXISTS / re-runnable.
41
+
42
+ BEGIN;
43
+
44
+ DROP TABLE IF EXISTS box_events;
45
+ DROP TABLE IF EXISTS builder_boxes;
46
+ DROP TABLE IF EXISTS box_intents;
47
+ DROP TABLE IF EXISTS builder_ssh_keys;
48
+
49
+ DELETE FROM builder_sessions WHERE source = 'box';
50
+ ALTER TABLE builder_sessions
51
+ DROP CONSTRAINT IF EXISTS builder_sessions_source_check;
52
+ ALTER TABLE builder_sessions
53
+ ADD CONSTRAINT builder_sessions_source_check CHECK (source IN ('cli', 'web'));
54
+
55
+ COMMIT;
@@ -2,6 +2,7 @@
2
2
  "key": "agents",
3
3
  "title": "Agents (declarative agent registry)",
4
4
  "description": "Declarative agent definitions (.claude/agents/*.md) reconciled at deploy into a DB-backed registry (agents_definitions) plus a per-fire ledger (agents_runs) — BONGOS-V1 goal 1000038, Phase 1. Files-are-source: agents-sync upserts file-sourced definitions and never clobbers an instance-authored db row, and stamps author_rank from the author's LIVE DB rank because a committed file cannot grant authority (ADR 0016). Spawns consume the EXISTING `grade` port (runSubagentCached), which is fixed read-only, so v1 agents are observe-and-report only — no write principals — and every fire bills through the shared LLM cost cache. default:false: shipped is not enabled, and an instance opts in per ADR 0083. spend.requiresPayer (task 1003884): a fire spends real money for whoever called it (~$0.90, and POST /agents/:name/invoke answers 202 without waiting), so the core refuses to ENABLE this module on an instance whose join door is `open` with no payer configured — an account created seconds ago would otherwise spend the instance's money with nobody to bill. Every run then records its payer beside cost_usd (agents_002_payer.sql) and a per-payer monthly ceiling fails closed when it is exhausted OR unreadable (lib/spend-ceiling.js) — distinct from lib/fire-budget.js, which stays a rate ceiling.",
5
+ "version": "1.0.0",
5
6
  "coreVersion": "^1.19.0",
6
7
  "default": false,
7
8
  "maintenance": { "status": "core-maintained" },
@@ -2,6 +2,7 @@
2
2
  "key": "autonomy",
3
3
  "title": "Autonomy & MAS (conductor · architect · gate · routine timers)",
4
4
  "description": "The Multi-Agent System's autonomy surfaces (ADR 0024): the $0 deterministic autonomy gate (System 3 — a precheck route + the gate CLI, inert until OTB_AUTONOMY_ENABLED=1), the conductor mid-flight dispatch CLI (Phase 4), the Architect bounded first-principles --scope audit CLI (Phase 5), and the in-process routine timers (System 4 / ADR 0078 amendment — the weekly peer-vote-tally poller). A core-domain module carved out of the GDS core in BV1.R83 (ADR 0093 §1); default-on because the autonomy machinery + the durable routine timer ship to every instance (both are inert/no-op until armed or the pool is reachable). Cross-cutting: it OBSERVES lifecycle events. The routine timer reaches the lifecycle `tallyPeerVotes` capability through the published doorway (a tracked lifecycle leak until R86), never a core require; the precheck route + the gate/conductor/architect CLIs travel with this domain (the CLIs stay under scripts/, outside the kernel boundary, so they may still read the ideas/economy/lifecycle signals + the grader directly). Registers no port — core never calls back in.",
5
+ "version": "1.0.0",
5
6
  "coreVersion": "^1.11.0",
6
7
  "default": true,
7
8
  "maintenance": { "status": "core-maintained" },
@@ -2,6 +2,7 @@
2
2
  "key": "builder-settings",
3
3
  "title": "Builder settings (prefs · needs)",
4
4
  "description": "The per-builder PREFERENCE domain — the wandering knob (how readily the agent opens off-task threads, rank-clamped), per-skill model ceilings, per-sound + per-event-sound on/off, and the render-widgets toggle — plus builder-needs (the 'the system needs an input from you' list). A core-domain module carved out of the GDS core in BV1.R80 (ADR 0093 §1); default-on because every instance configures its own builders. The `/me/*-prefs` routes mount here; the `GET /me` aggregator stays a thin core facade that resolves the `builder-settings` kernel PORT for its wandering/render/needs slices (02-module-map §2).",
5
+ "version": "1.0.0",
5
6
  "coreVersion": "^1.8.0",
6
7
  "default": true,
7
8
  "maintenance": { "status": "core-maintained" },
@@ -258,11 +258,10 @@ function describeForApi(behaviorPrefs) {
258
258
  // --- levels (task 1002518) ---
259
259
  level,
260
260
  // The three booleans a card emitter needs, RESOLVED SERVER-SIDE. This is not
261
- // a convenience: scripts/gds/start.js runs on a task-scoped dev box that
262
- // carries scripts/ and src/bongos/ but not every modules/ directory (task
263
- // 1003098), so a CLI cannot require this file to learn what a rung draws —
264
- // tree-preflight fails the entry point that tries. Shipping the answers
265
- // keeps the ladder in one place AND keeps the CLI dependency-free.
261
+ // a convenience: a CLI (scripts/gds/start.js) reaches module logic through
262
+ // the server, never by requiring this file, so it cannot learn what a rung
263
+ // draws any other way. Shipping the answers keeps the ladder in one place
264
+ // AND keeps the CLI dependency-free.
266
265
  flags: cardFlags(level),
267
266
  levels: levelCatalog(),
268
267
  derived_level: derived,
@@ -464,10 +464,16 @@ function guidFor(seed) {
464
464
  return `{${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}}`;
465
465
  }
466
466
 
467
+ // The Title the download writes, and the one an upload without its page details
468
+ // is identified by (identifyUpload): one rule for both.
469
+ function downloadTitle({ readingPage, title }) {
470
+ return String(title || readingPage.title || readingPage.id);
471
+ }
472
+
467
473
  // composeDownload({ readingPage, title, draft }) -> Buffer (the .docx)
468
474
  function composeDownload({ readingPage, title, draft }) {
469
475
  const lines = shownLines(readingPage, draft);
470
- const pageTitle = String(title || readingPage.title || readingPage.id);
476
+ const pageTitle = downloadTitle({ readingPage, title });
471
477
  const doc = documentXml({ title: pageTitle, lines });
472
478
  const rels = (items) => `${XML_HEAD}<Relationships xmlns="${NS.rels}">${items.map(([id, type, target]) => `<Relationship Id="${id}" Type="${type}" Target="${target}"/>`).join('')}</Relationships>`;
473
479
  const OD = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
@@ -699,20 +705,86 @@ function headingChanges(expected, found) {
699
705
  return out;
700
706
  }
701
707
 
708
+ const isHeading = (p) => /^heading\s*2$/i.test(String(p.style || ''));
709
+ const isTitle = (p) => /^title$/i.test(String(p.style || ''));
710
+ const sameWords = (a, b) => foldQuotes(tidy(a)) === foldQuotes(tidy(b));
711
+
712
+ // The file's frame: every untagged paragraph before its first section heading.
713
+ // The download writes the Title and the note there and never a line, so they
714
+ // stay out of the lines even when the program that saved the file dropped
715
+ // their styles (Google Docs keeps Title and Heading 2, not a custom style).
716
+ function frameOf(paragraphs) {
717
+ const first = paragraphs.findIndex((p) => isHeading(p) || p.tags.length);
718
+ return new Set((first < 0 ? paragraphs : paragraphs.slice(0, first)).filter((p) => !p.tags.length));
719
+ }
720
+
721
+ // The page title the file shows: its Title paragraph, else the first words of
722
+ // its frame.
723
+ function uploadTitle(paragraphs) {
724
+ const frame = [...frameOf(paragraphs)].filter((p) => tidy(p.text));
725
+ const t = frame.find(isTitle) || frame[0];
726
+ return t ? tidy(t.text) : null;
727
+ }
728
+
729
+ // identifyUpload({ readingPage, pageId, title, upload }) ->
730
+ // { ok, page_details } | wrong_page | reading_moved
731
+ //
732
+ // WHICH PAGE a file is (task 1004354 / BV2.TW23, ADR 0341 D12). With its page
733
+ // details (the page_id and reading_hash properties) the file says so, and it
734
+ // must be this page on this reading. A program that re-saves the file may drop
735
+ // them: a Google Docs download keeps the words, the styles and the comments,
736
+ // and loses the custom properties, the shown-text part and every content
737
+ // control. Then the file is identified by what the download WROTE: its Title
738
+ // must be this page's title and its section headings must be this reading's,
739
+ // in order. Anything else is another page's file, and wrong_page names what
740
+ // did not match. The line count per section is mapUpload's to check, so an
741
+ // added or dropped line in the right page's file is still structure_changed,
742
+ // named, and never merged. With no reading_hash there is no reading to move
743
+ // from: the structure matching THIS reading is the check.
744
+ function identifyUpload({ readingPage, pageId, title, upload }) {
745
+ if (upload.page_id != null) {
746
+ if (upload.page_id !== pageId) return fail('wrong_page', { page_id: pageId, file_page_id: upload.page_id, page_details: true });
747
+ if (upload.reading_hash !== readingPage.reading_hash) {
748
+ return fail('reading_moved', { reading_hash: readingPage.reading_hash || null, file_reading_hash: upload.reading_hash });
749
+ }
750
+ return { ok: true, page_details: true };
751
+ }
752
+ const expected = { title: tidy(downloadTitle({ readingPage, title })), headings: sectionRuns(readingPage.lines || []).map((r) => tidy(r.section)) };
753
+ const found = { title: uploadTitle(upload.paragraphs), headings: upload.paragraphs.filter((p) => isHeading(p) && !p.tags.length).map((p) => tidy(p.text)) };
754
+ const mismatched = [];
755
+ if (found.title == null || !sameWords(found.title, expected.title)) mismatched.push('title');
756
+ if (found.headings.length !== expected.headings.length || found.headings.some((h, i) => !sameWords(h, expected.headings[i]))) mismatched.push('headings');
757
+ if (mismatched.length) {
758
+ return fail('wrong_page', {
759
+ page_id: pageId,
760
+ file_page_id: null,
761
+ page_details: false,
762
+ mismatched,
763
+ expected_title: expected.title,
764
+ file_title: found.title,
765
+ });
766
+ }
767
+ if (upload.reading_hash != null && upload.reading_hash !== readingPage.reading_hash) {
768
+ return fail('reading_moved', { reading_hash: readingPage.reading_hash || null, file_reading_hash: upload.reading_hash });
769
+ }
770
+ return { ok: true, page_details: false };
771
+ }
772
+
702
773
  // mapUpload({ readingPage, upload }) -> { ok, mode, texts: Map(key -> text) } | structure_changed
703
774
  //
704
775
  // By TAG when any paragraph carries a line tag; by POSITION when every tag is
705
776
  // gone, which needs the same section headings and the same number of lines in
706
- // each. Paragraphs styled as the Title or the note, and empty untagged
707
- // paragraphs (an artist's spacing), are not lines.
777
+ // each. Paragraphs styled as the Title or the note, the untagged frame above
778
+ // the first heading (frameOf), and empty untagged paragraphs (an artist's
779
+ // spacing), are not lines.
708
780
  function mapUpload({ readingPage, upload }) {
709
781
  const lines = readingPage.lines || [];
710
782
  const byKey = new Map(lines.map((l) => [l.key, l]));
711
783
  const order = new Map(lines.map((l, i) => [l.key, i]));
712
784
  const runs = sectionRuns(lines);
713
785
  const expectedHeadings = runs.map((r) => tidy(r.section));
714
- const isHeading = (p) => /^heading\s*2$/i.test(String(p.style || ''));
715
- const isFrame = (p) => /^title$/i.test(String(p.style || '')) || p.style === STYLE.note;
786
+ const frame = frameOf(upload.paragraphs);
787
+ const isFrame = (p) => isTitle(p) || p.style === STYLE.note || frame.has(p);
716
788
  const paras = upload.paragraphs.filter((p) => !isFrame(p) && !(p.tags.length === 0 && !isHeading(p) && !tidy(p.text)));
717
789
  const moved = [];
718
790
  const named = (change, key, extra) => {
@@ -813,6 +885,7 @@ module.exports = {
813
885
  composeDownload,
814
886
  downloadName,
815
887
  readUpload,
888
+ identifyUpload,
816
889
  mapUpload,
817
890
  mergeUpload,
818
891
  };
@@ -2,6 +2,7 @@
2
2
  "key": "copy-desk",
3
3
  "title": "Copy desk (the artist's loop over user-facing text)",
4
4
  "description": "The COPY half of the design contract (goal 1000074, the artist's loop). Named for the editorial desk copy passes over before it is published — which is exactly the job, and which keeps the module key off the generic word 'copy' that the branding contract already owns (branding.copy.tagline/hallName/notFound). ADR 0081 made the repo the source of truth for design TOKENS and component surfaces; there was no equivalent for user-facing TEXT. This module owns that half: the FLAG — anyone, in any role, marks a string or a whole surface as needing an artist's attention, with a required reason — and the QUEUE an artist reads it from. The inventory a flag points at is the committed registry docs/copy-registry.json, generated by scripts/gds/copy-inventory.js (R01); this module reads it and never writes it. R03 (task 1003114) added the PROPOSAL: an artist writes the replacement wording and it becomes a GDS task carrying a patch, which scripts/gds/copy-apply.js lands as a real diff under a claim — the design half's adapter.export() contract (ADR 0081) applied to text. NO LIVE CMS is the goal's hard non-goal: nothing here can change a rendered string. A flag is a work request; a proposal is a work request with a suggested fix; the fix ships as a claimed, graded GDS task like every other change. default-on — every instance shows a person words, so every instance can flag them.",
5
+ "version": "1.0.0",
5
6
  "coreVersion": "^1.8.0",
6
7
  "default": true,
7
8
  "maintenance": { "status": "core-maintained" },
@@ -164,7 +164,7 @@ const PAGE_REFUSAL_MEANS = Object.freeze({
164
164
  });
165
165
  // The upload's answers when the file is readable but is not this page's, or not
166
166
  // this reading's (task 1004320, ADR 0341 D12).
167
- const UPLOAD_WRONG_PAGE_MEANS = 'This file is not the download of this page, or the program that saved it dropped the page details Word keeps. Download this page again and make your changes in that file. Nothing was saved.';
167
+ const UPLOAD_WRONG_PAGE_MEANS = 'This file is not the download of this page: its title or its section headings are not this page\'s. Check you picked the file you downloaded from this page, or download this page again and make your changes in that file. Nothing was saved.';
168
168
  const UPLOAD_READING_MOVED_MEANS = 'The page has changed since this file was downloaded, so its lines may no longer line up with the page. Download the page again and copy your changes across. Nothing was saved.';
169
169
 
170
170
  // The upload's body is the .docx itself, never JSON (the global parser only
@@ -1166,7 +1166,7 @@ module.exports = function buildCopyDeskRouter() {
1166
1166
  try {
1167
1167
  const found = pageOrFail(req, res);
1168
1168
  if (!found) return;
1169
- const { pageId } = found;
1169
+ const { page, pageId } = found;
1170
1170
  const lifecycle = pageReadsOrFail(res);
1171
1171
  if (!lifecycle) return;
1172
1172
  if (typeof lifecycle.updateHeldTaskDescription !== 'function') {
@@ -1177,12 +1177,10 @@ module.exports = function buildCopyDeskRouter() {
1177
1177
 
1178
1178
  const upload = docx.readUpload(Buffer.isBuffer(req.body) ? req.body : null);
1179
1179
  if (!upload.ok) return refusePage(res, upload);
1180
- if (upload.page_id !== pageId) {
1181
- return res.fail('wrong_page', 409, { page_id: pageId, file_page_id: upload.page_id, what_this_means: UPLOAD_WRONG_PAGE_MEANS });
1182
- }
1183
- if (upload.reading_hash !== readingPage.reading_hash) {
1184
- return res.fail('reading_moved', 409, { reading_hash: readingPage.reading_hash || null, file_reading_hash: upload.reading_hash, what_this_means: UPLOAD_READING_MOVED_MEANS });
1185
- }
1180
+ // Which page the file is: its page details, or (a Google Docs save drops
1181
+ // them, task 1004354) its title and section headings.
1182
+ const who = docx.identifyUpload({ readingPage, pageId, title: page.title, upload });
1183
+ if (!who.ok) return res.fail(who.code, 409, { ...who.detail, what_this_means: who.code === 'wrong_page' ? UPLOAD_WRONG_PAGE_MEANS : UPLOAD_READING_MOVED_MEANS });
1186
1184
  const mapped = docx.mapUpload({ readingPage, upload });
1187
1185
  if (!mapped.ok) return refusePage(res, mapped);
1188
1186
 
@@ -1227,7 +1225,9 @@ module.exports = function buildCopyDeskRouter() {
1227
1225
  // your doc", Editor.dc.html): the lines the file changed, the draft
1228
1226
  // lines it left alone, and whether lines were matched by tag or by
1229
1227
  // position, with or without the file's record of what it showed.
1230
- merge: { mode: mapped.mode, baseline: !!upload.baseline, from_file: merge.from_file, kept_from_draft: merge.kept_from_draft },
1228
+ // page_details false: the file had lost its page details and was
1229
+ // matched by its title and headings (task 1004354).
1230
+ merge: { mode: mapped.mode, baseline: !!upload.baseline, page_details: who.page_details, from_file: merge.from_file, kept_from_draft: merge.kept_from_draft },
1231
1231
  });
1232
1232
  case 'not_held':
1233
1233
  return refuseNotHeld(res, pageId, r, round);
@@ -15,7 +15,7 @@
15
15
  import { strict as assert } from 'node:assert';
16
16
  import zlib from 'node:zlib';
17
17
  import { createRequire } from 'node:module';
18
- import { unzipAll, wordZip, resave, typeInto, wordRuns, trackedInsert, trackedDelete, stripControls, lineParagraph } from './fixtures/word-docx.mjs';
18
+ import { unzipAll, wordZip, resave, typeInto, wordRuns, trackedInsert, trackedDelete, stripControls, lineParagraph, googleDocsSave, GOOGLE_DOCS_DROPS } from './fixtures/word-docx.mjs';
19
19
 
20
20
  const require = createRequire(import.meta.url);
21
21
  const docx = require('../docx.js');
@@ -327,10 +327,116 @@ test('by position, a section with a line more or a line fewer is refused and nam
327
327
  assert.equal(refusal(heading)[0].change, 'heading_changed');
328
328
  });
329
329
 
330
+ // ---------------------------------------------------------------------------
331
+ // A file re-saved by Google Docs (task 1004354 / BV2.TW23): no page details,
332
+ // no shown text, no controls, no custom styles. Identified by its title and
333
+ // headings, mapped by position, merged with the file as the whole state.
334
+ // ---------------------------------------------------------------------------
335
+
336
+ const identify = (buf, readingPage = READING, title = 'Studio') => docx.identifyUpload({ readingPage, pageId: readingPage.id, title, upload: upload(buf) });
337
+ const OTHER = {
338
+ id: 'builders:home',
339
+ title: 'Home',
340
+ reading_hash: 'home-now',
341
+ lines: [
342
+ L('L0001', 'top bar', 'Home', 'shared'),
343
+ L('L0002', 'welcome', 'Good to see you', 'placed', { file: 'index.html', line: 4, string_id: 'hm02' }),
344
+ L('L0003', 'welcome', 'Three tasks wait', 'placed', { file: 'index.html', line: 5, string_id: 'hm03' }),
345
+ ],
346
+ };
347
+
348
+ test('the Google Docs shape: no page details, no shown text, no controls and no custom styles', () => {
349
+ const buf = googleDocsSave(download());
350
+ const parts = unzipAll(buf);
351
+ for (const k of GOOGLE_DOCS_DROPS) assert.equal(parts.has(k), false, k);
352
+ assert.doesNotMatch(parts.get('word/document.xml'), /<w:tag |TweakNote|TweakShared|TweakUnplaced/);
353
+ assert.doesNotMatch(parts.get('_rels/.rels'), /custom-properties/);
354
+ const u = upload(buf);
355
+ assert.deepEqual([u.page_id, u.reading_hash, u.baseline], [null, null, null]);
356
+ });
357
+
358
+ test('a Google Docs round trip merges: identified by its title and headings, mapped by position', () => {
359
+ const buf = googleDocsSave(download(), { document: (xml) => typeInto(xml, 'L0006', wordRuns(['Pick ', 'up ', 'where you left off'])) });
360
+ assert.deepEqual(identify(buf), { ok: true, page_details: false });
361
+ const { mapped, merged } = roundTrip(buf);
362
+ assert.equal(mapped.ok, true, JSON.stringify(mapped.detail));
363
+ assert.equal(mapped.mode, 'position');
364
+ assert.deepEqual(merged.from_file, ['L0006']);
365
+ assert.equal(merged.lines.find((l) => l.key === 'L0006').after, 'Pick up where you left off');
366
+ // Every other line, the curly one included, reads as unchanged.
367
+ assert.deepEqual(merged.lines.filter((l) => l.key !== 'L0006').map((l) => l.after), READING.lines.filter((l) => l.key !== 'L0006').map((l) => l.text));
368
+ });
369
+
370
+ test('a Google Docs round trip over a draft keeps the draft, since the file shows it', () => {
371
+ const draft = draftOf([{ key: 'L0002', before: 'Welcome back', after: 'Welcome home' }]);
372
+ const buf = googleDocsSave(download(draft), { document: (xml) => typeInto(xml, 'L0004', wordRuns(['Nothing ', 'waits'])) });
373
+ const { merged } = roundTrip(buf, draft);
374
+ assert.deepEqual(merged.from_file, ['L0004']);
375
+ assert.deepEqual(merged.kept_from_draft, ['L0002']);
376
+ assert.equal(merged.lines.find((l) => l.key === 'L0002').after, 'Welcome home');
377
+ });
378
+
379
+ test('a Google Docs file that kept its note style, or lost only its page details, still merges', () => {
380
+ const styled = googleDocsSave(download(), { plainStyles: false, document: (xml) => typeInto(xml, 'L0002', wordRuns(['Hello'])) });
381
+ assert.equal(identify(styled).ok, true);
382
+ assert.deepEqual(roundTrip(styled).merged.from_file, ['L0002']);
383
+ // Controls kept, page details dropped: mapped by tag, identified by the file.
384
+ const tagged = resave(download(), { drop: GOOGLE_DOCS_DROPS, document: (xml) => typeInto(xml, 'L0002', wordRuns(['Hello'])) });
385
+ assert.deepEqual(identify(tagged), { ok: true, page_details: false });
386
+ assert.equal(roundTrip(tagged).mapped.mode, 'tag');
387
+ });
388
+
389
+ test('another page\'s file with its page details dropped is still wrong_page, and says what did not match', () => {
390
+ const other = docx.composeDownload({ readingPage: OTHER, title: 'Home', draft: null });
391
+ const r = identify(googleDocsSave(other));
392
+ assert.equal(r.code, 'wrong_page');
393
+ assert.deepEqual(r.detail, { page_id: 'builders:studio', file_page_id: null, page_details: false, mismatched: ['title', 'headings'], expected_title: 'Studio', file_title: 'Home' });
394
+ // Its line tags survived (L0001… on every page), so only the page check
395
+ // stands between it and this page's lines.
396
+ assert.equal(identify(resave(other, { drop: GOOGLE_DOCS_DROPS })).code, 'wrong_page');
397
+ // This page's headings under another title; this page's title over other headings.
398
+ const retitled = googleDocsSave(download(), { document: (xml) => xml.replace('<w:t xml:space="preserve">Studio</w:t></w:r></w:p><w:p><w:pPr><w:pStyle w:val="TweakNote"/>', '<w:t xml:space="preserve">Home</w:t></w:r></w:p><w:p><w:pPr><w:pStyle w:val="TweakNote"/>') });
399
+ assert.deepEqual(identify(retitled).detail.mismatched, ['title']);
400
+ const reheaded = googleDocsSave(download(), { document: (xml) => xml.replace('<w:t xml:space="preserve">footer</w:t>', '<w:t>foot</w:t>') });
401
+ assert.deepEqual(identify(reheaded).detail.mismatched, ['headings']);
402
+ });
403
+
404
+ test('with its page details kept, the file still names its page and its reading', () => {
405
+ const other = docx.composeDownload({ readingPage: OTHER, title: 'Home', draft: null });
406
+ assert.deepEqual(identify(other).detail, { page_id: 'builders:studio', file_page_id: 'builders:home', page_details: true });
407
+ assert.equal(identify(download(), { ...READING, reading_hash: 'h-later' }).code, 'reading_moved');
408
+ assert.deepEqual(identify(download()), { ok: true, page_details: true });
409
+ });
410
+
411
+ test('the right page from Google Docs with a line added is structure_changed, named, not wrong_page', () => {
412
+ const buf = googleDocsSave(download(), { document: (xml) => { const p = lineParagraph(xml, 'L0006'); return xml.replace(p, () => `${p}<w:p><w:r><w:t>A new thought</w:t></w:r></w:p>`); } });
413
+ assert.equal(identify(buf).ok, true);
414
+ assert.deepEqual(refusal(buf), [{ change: 'added', key: null, section: 'footer', first_words: 'A new thought' }]);
415
+ });
416
+
330
417
  // ---------------------------------------------------------------------------
331
418
  // Every page the inventory reads round-trips untouched.
332
419
  // ---------------------------------------------------------------------------
333
420
 
421
+ test('every committed page, re-saved by Google Docs, merges into itself and into no other page', () => {
422
+ const readings = require('../../../docs/page-readings.json');
423
+ const files = readings.pages.map((p) => upload(googleDocsSave(docx.composeDownload({ readingPage: p, title: p.title, draft: null }))));
424
+ readings.pages.forEach((a, i) => {
425
+ for (const b of readings.pages) {
426
+ const who = docx.identifyUpload({ readingPage: b, pageId: b.id, title: b.title, upload: files[i] });
427
+ if (a === b) {
428
+ assert.equal(who.ok, true, a.id);
429
+ const m = docx.mapUpload({ readingPage: a, upload: files[i] });
430
+ assert.equal(m.ok, true, `${a.id}: ${JSON.stringify(m.detail)}`);
431
+ assert.deepEqual(docx.mergeUpload({ readingPage: a, texts: m.texts, baseline: null, draft: null }).from_file, [], a.id);
432
+ } else if (who.ok) {
433
+ // The same title and headings: the lines must still refuse it.
434
+ assert.equal(docx.mapUpload({ readingPage: b, upload: files[i] }).ok, false, `${a.id} merged into ${b.id}`);
435
+ }
436
+ }
437
+ });
438
+ });
439
+
334
440
  test('every committed page reading round-trips with no change', () => {
335
441
  const readings = require('../../../docs/page-readings.json');
336
442
  for (const p of readings.pages) {
@@ -129,3 +129,33 @@ export function trackedDelete(text) {
129
129
  export function stripControls(xml) {
130
130
  return xml.replace(/<w:sdt><w:sdtPr>[\s\S]*?<\/w:sdtPr><w:sdtContent>([\s\S]*?)<\/w:sdtContent><\/w:sdt>/g, (m, inner) => inner);
131
131
  }
132
+
133
+ // The parts a Google Docs "Download, Microsoft Word (.docx)" does not carry
134
+ // back: the custom properties (page_id, reading_hash) and the custom XML part
135
+ // (the shown text). Task 1004354 (BV2.TW23) reproduced the owner's upload by
136
+ // dropping exactly these.
137
+ export const GOOGLE_DOCS_DROPS = Object.freeze(['docProps/custom.xml', 'customXml/item1.xml', 'customXml/itemProps1.xml', 'customXml/_rels/item1.xml.rels']);
138
+
139
+ // Re-save a downloaded file the way Google Docs hands it back: every content
140
+ // control gone, the page details and the shown text dropped, and the package's
141
+ // references to them removed. Google Docs has no custom paragraph styles, so
142
+ // `plainStyles` also turns the note, shared and engineer styles into plain
143
+ // paragraphs (Title and Heading 2 are its own and stay). `document` edits the
144
+ // document part first, while its line tags are still there to find a line by.
145
+ export function googleDocsSave(buf, { document, plainStyles = true } = {}) {
146
+ const all = unzipAll(buf);
147
+ const rels = (xml) => xml.replace(/<Relationship [^>]*(?:custom-properties|customXml)"[^>]*\/>/g, '');
148
+ return resave(buf, {
149
+ drop: GOOGLE_DOCS_DROPS,
150
+ parts: {
151
+ '_rels/.rels': rels(all.get('_rels/.rels')),
152
+ 'word/_rels/document.xml.rels': rels(all.get('word/_rels/document.xml.rels')),
153
+ '[Content_Types].xml': all.get('[Content_Types].xml').replace(/<Override PartName="\/(?:docProps\/custom|customXml\/[^"]+)\.xml"[^>]*\/>/g, ''),
154
+ },
155
+ document: (xml) => {
156
+ let out = stripControls(document ? document(xml) : xml);
157
+ if (plainStyles) out = out.replace(/<w:pPr><w:pStyle w:val="Tweak(?:Note|Shared|Unplaced)"\/><\/w:pPr>/g, '');
158
+ return out;
159
+ },
160
+ });
161
+ }
@@ -2,6 +2,7 @@
2
2
  "key": "discord",
3
3
  "title": "Discord",
4
4
  "description": "Outbound ship broadcast + the inbound #ideas/#bugs bot + role-sync + channel management + account-link state. Mirror only, never authority (ADR 0033).",
5
+ "version": "1.0.0",
5
6
  "coreVersion": "^1.4.0",
6
7
  "default": false,
7
8
  "maintenance": { "status": "core-maintained" },
@@ -2,6 +2,7 @@
2
2
  "key": "economy",
3
3
  "title": "Economy",
4
4
  "description": "Credit allocation (the cost-plus + kind-multiplier reward model), the cost ledger + dashboard rollups, builder streaks, achievements/gamification, and the leaderboard. A core-domain module carved out of the GDS core in BV1.R77 (ADR 0093 §1-2); default-on because every instance rewards its own builders. Registers the `reward` kernel PORT the lifecycle resolves at confirm/ship/claim time.",
5
+ "version": "1.0.0",
5
6
  "coreVersion": "^1.7.0",
6
7
  "default": true,
7
8
  "maintenance": { "status": "core-maintained" },