@bongos/core 1.19.715 → 1.19.724

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 (414) hide show
  1. package/.bongos-core.json +472 -407
  2. package/.claude/skills/backlog-review/SKILL.md +3 -3
  3. package/.claude/skills/blocker-review/SKILL.md +1 -1
  4. package/.claude/skills/blocker-solve/SKILL.md +2 -2
  5. package/.claude/skills/builder-reauth/SKILL.md +1 -1
  6. package/.claude/skills/builder-redteam/SKILL.md +1 -1
  7. package/.claude/skills/builder-ship/SKILL.md +1 -1
  8. package/.claude/skills/builder-start/SKILL.md +1 -1
  9. package/.claude/skills/design/SKILL.md +2 -2
  10. package/.claude/skills/feedback/SKILL.md +1 -1
  11. package/.claude/skills/goal-create/SKILL.md +1 -1
  12. package/.claude/skills/goal-review/SKILL.md +2 -2
  13. package/.claude/skills/idea-triage/SKILL.md +5 -5
  14. package/.claude/skills/merge-mode/SKILL.md +4 -4
  15. package/.claude/skills/new-project/SKILL.md +17 -17
  16. package/.claude/skills/planning-session/SKILL.md +11 -11
  17. package/.claude/skills/recall/SKILL.md +2 -2
  18. package/.claude/skills/strand-fix/SKILL.md +1 -1
  19. package/.devcontainer/README.md +2 -2
  20. package/CONTRIBUTING.md +2 -2
  21. package/GOVERNANCE.md +4 -4
  22. package/clients/bongos-client/README.md +1 -1
  23. package/clients/bongos-client/bongos-client.global.js +4 -0
  24. package/clients/bongos-client/index.cjs +4 -0
  25. package/clients/bongos-client/index.d.ts +5 -0
  26. package/clients/bongos-client/index.mjs +4 -0
  27. package/config/branding.neutral.json +4 -2
  28. package/docs/adr/0001-stack-choice-single-vps.md +2 -2
  29. package/docs/adr/0002-digitalocean-over-hetzner.md +1 -1
  30. package/docs/adr/0008-google-chat-oauth-user-auth.md +1 -1
  31. package/docs/adr/0009-autotile-architecture.md +1 -1
  32. package/docs/adr/0011-terrain-id-rendering-model.md +3 -3
  33. package/docs/adr/0013-paths-as-object-overlays-with-autotile-dispatch.md +1 -1
  34. package/docs/adr/0014-families-canonical-over-vocabulary.md +1 -1
  35. package/docs/adr/0015-task-dependencies-and-auto-promotion.md +1 -1
  36. package/docs/adr/0016-trust-boundary-server-enforced-permissions.md +1 -1
  37. package/docs/adr/0018-three-rank-model-goes-live.md +3 -3
  38. package/docs/adr/0020-security-model-public-stub.md +9 -9
  39. package/docs/adr/0021-per-builder-skill-model-preferences.md +2 -2
  40. package/docs/adr/0022-secrets-policy.md +5 -5
  41. package/docs/adr/0024-cloneable-repo-local-first-memory.md +3 -3
  42. package/docs/adr/0024-multi-agent-system-architecture.md +10 -10
  43. package/docs/adr/0025-offsite-backup-vendor-digitalocean-spaces.md +1 -1
  44. package/docs/adr/0025-structured-criterion-task-link.md +1 -1
  45. package/docs/adr/0026-bfg-memory-hygiene-and-cross-builder-learning.md +13 -13
  46. package/docs/adr/0027-bfg-session-inefficiency-evaluator.md +14 -14
  47. package/docs/adr/0029-status-page-outage-resilience.md +1 -1
  48. package/docs/adr/0030-builders-hall-live-updates.md +1 -1
  49. package/docs/adr/0031-cloud-dev-environments-for-builders.md +7 -7
  50. package/docs/adr/0032-discord-mirror-of-gds.md +3 -3
  51. package/docs/adr/0033-discord-bot-service-principal.md +4 -4
  52. package/docs/adr/0034-thetes-graduated-newcomer-rank.md +1 -1
  53. package/docs/adr/0035-builder-onboarding-three-paths.md +4 -4
  54. package/docs/adr/0036-builders-subdomain-and-ui-reorg.md +1 -1
  55. package/docs/adr/0037-discord-channel-management.md +1 -1
  56. package/docs/adr/0038-chromebook-ttyd-cloudflare-tunnel.md +5 -5
  57. package/docs/adr/0039-setup-first-onboarding-ux.md +6 -6
  58. package/docs/adr/0040-remote-control-default-browser-onramp.md +3 -3
  59. package/docs/adr/0041-temporary-grader-bypass-killswitch.md +2 -2
  60. package/docs/adr/0042-builder-self-deploy-ci-auto-merge.md +5 -5
  61. package/docs/adr/0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md +2 -2
  62. package/docs/adr/0044-mediterranean-palette-replacement.md +1 -1
  63. package/docs/adr/0044-per-box-live-game-preview.md +1 -1
  64. package/docs/adr/0046-sandbox-first-review-gate.md +1 -1
  65. package/docs/adr/0047-discord-bugs-intake-anti-abuse.md +1 -1
  66. package/docs/adr/0049-split-parallel-safety-contract.md +1 -1
  67. package/docs/adr/0050-device-flow-admission-invite-gated-by-default.md +2 -2
  68. package/docs/adr/0051-full-session-transcript-corpus.md +2 -2
  69. package/docs/adr/0052-sandbox-for-everyone-game-only-preview.md +1 -1
  70. package/docs/adr/0053-scoped-dev-box-session.md +1 -1
  71. package/docs/adr/0054-session-token-reward-cost-plus.md +7 -7
  72. package/docs/adr/0055-server-mediated-branch-publish.md +2 -2
  73. package/docs/adr/0056-prod-deploy-script-mirror-and-healthcheck-retry.md +3 -3
  74. package/docs/adr/0057-container-cost-ledger.md +1 -1
  75. package/docs/adr/0057-discord-archon-approval-channels.md +2 -2
  76. package/docs/adr/0058-automatic-gate-surface-gatekeeper.md +6 -6
  77. package/docs/adr/0059-single-approval-remove-devbox-approval-gate.md +11 -11
  78. package/docs/adr/0060-gds-retrieval-layer.md +3 -3
  79. package/docs/adr/0061-context-layer-decomposition.md +2 -2
  80. package/docs/adr/0062-medusa-instance-model-decoupling.md +5 -5
  81. package/docs/adr/0063-generated-repo-map-symbol-skeleton.md +2 -2
  82. package/docs/adr/0064-rename-medusa-to-cloud-bongos.md +3 -3
  83. package/docs/adr/0065-agpl-nonprofit-ai-first-governance.md +5 -5
  84. package/docs/adr/0066-generated-file-map-sections.md +2 -2
  85. package/docs/adr/0067-session-start-fast-forward-pull.md +1 -1
  86. package/docs/adr/0068-shared-art-key-for-newcomers.md +1 -1
  87. package/docs/adr/0069-hall-live-channel-sse-decouple.md +3 -3
  88. package/docs/adr/0070-deterministic-session-card-delivery.md +1 -1
  89. package/docs/adr/0071-box-confirm-before-destroyed-and-drift-reconcile.md +2 -2
  90. package/docs/adr/0072-bongos-app-mac-signed-first-windows-deferred.md +2 -2
  91. package/docs/adr/0072-dev-box-code-staleness-visibility.md +2 -2
  92. package/docs/adr/0073-builder-needs-signal-and-byok-gemini-key.md +3 -3
  93. package/docs/adr/0073-secrets-scan-exclude-uri-detector.md +2 -2
  94. package/docs/adr/0076-in-session-deterministic-compaction.md +2 -2
  95. package/docs/adr/0077-content-addressed-llm-cache.md +2 -2
  96. package/docs/adr/0078-deterministic-first-cron.md +3 -3
  97. package/docs/adr/0079-value-gated-autonomous-engagement.md +2 -2
  98. package/docs/adr/0080-llm-cache-api-transport-and-trust-model.md +3 -3
  99. package/docs/adr/0081-tool-agnostic-design-layer.md +3 -3
  100. package/docs/adr/0082-server-side-merge-conflict-auto-resolution.md +2 -2
  101. package/docs/adr/0083-modular-architecture-strangler-build-plan.md +3 -3
  102. package/docs/adr/0085-remove-default-per-builder-spend-cap.md +2 -2
  103. package/docs/adr/0086-goal-scoped-work-hierarchy.md +15 -15
  104. package/docs/adr/0087-bongos-app-architecture-and-handoff-contract.md +5 -5
  105. package/docs/adr/0087-compete-on-governance-not-tooling-cursor.md +1 -1
  106. package/docs/adr/0088-consistent-character-generative-animation.md +5 -5
  107. package/docs/adr/0089-modular-multi-model-adversarial-grader.md +15 -15
  108. package/docs/adr/0090-metic-task-authoring.md +8 -8
  109. package/docs/adr/0091-bounding-the-kernel-and-db-carve.md +3 -3
  110. package/docs/adr/0092-hosting-module-and-on-prem-target.md +11 -11
  111. package/docs/adr/0093-tranche-2-core-carve-sequence.md +5 -5
  112. package/docs/adr/0094-repo-atlas-self-drawing-codebase-map.md +1 -1
  113. package/docs/adr/0095-borrowed-memory-and-retrieval-concepts.md +6 -6
  114. package/docs/adr/0095-cross-agent-context-management.md +5 -5
  115. package/docs/adr/0096-require-reward-before-workable.md +5 -5
  116. package/docs/adr/0097-one-active-claim-per-session-and-worktree-binding.md +2 -2
  117. package/docs/adr/0097-retroactive-reward-backfill.md +4 -4
  118. package/docs/adr/0098-public-mirror-publish-manifest.md +3 -3
  119. package/docs/adr/0099-delayed-redacted-mirror-export.md +1 -1
  120. package/docs/adr/0100-two-repo-instance-model.md +6 -6
  121. package/docs/adr/0101-goal-hierarchy-phase-5-judge-membership-reward.md +9 -9
  122. package/docs/adr/0102-finish-bounding-the-kernel.md +1 -1
  123. package/docs/adr/0103-core-first-extraction-cloud-bongos-trunk.md +6 -6
  124. package/docs/adr/0103-gdsv4-bongos-consolidation.md +3 -3
  125. package/docs/adr/0104-trust-gds-api-channel-in-auto-mode.md +3 -3
  126. package/docs/adr/0105-instance-seed-migrations-out-of-core.md +2 -2
  127. package/docs/adr/0106-goal-owner-manager-authority.md +2 -2
  128. package/docs/adr/0107-module-upstreaming-contribution-to-core-catalog.md +6 -6
  129. package/docs/adr/0108-instance-composition-configurable-root.md +6 -6
  130. package/docs/adr/0109-self-describing-openapi-and-hosted-docs.md +3 -3
  131. package/docs/adr/0110-untrack-whole-file-generated-nav-docs.md +1 -1
  132. package/docs/adr/0111-instance-hosting-provisioning-module.md +18 -18
  133. package/docs/adr/0112-private-goals-invitations-join-requests.md +7 -7
  134. package/docs/adr/0113-sandbox-studio-descope-superseded-by-0044-0052.md +1 -1
  135. package/docs/adr/0114-instance-docs-discovery-contract.md +4 -4
  136. package/docs/adr/0115-scheduled-routines-portability.md +1 -1
  137. package/docs/adr/0117-api-versioning-and-deprecation-policy.md +1 -1
  138. package/docs/adr/0118-api-contract-source-of-truth-code-first.md +1 -1
  139. package/docs/adr/0120-pay-on-land-and-builder-owned-rebase-gate.md +2 -2
  140. package/docs/adr/0121-greenfield-vs-brownfield-onboarding-adopt-existing-repo.md +1 -1
  141. package/docs/adr/0122-onboarding-diagram-renders-deploy-rendered-off-git.md +3 -3
  142. package/docs/adr/0123-box-idle-sweep-autosave-before-destroy.md +1 -1
  143. package/docs/adr/0124-typed-api-response-bodies.md +2 -2
  144. package/docs/adr/0125-control-plane-scaffolds-standalone-instance-repo.md +2 -2
  145. package/docs/adr/0126-dedicated-cloudbongos-control-plane-droplet.md +3 -3
  146. package/docs/adr/0127-cross-box-fleet-observability.md +1 -1
  147. package/docs/adr/0128-provisioning-runner-privilege-and-db-auth-model.md +3 -3
  148. package/docs/adr/0129-cloudbongos-control-plane-pull-deploy.md +4 -4
  149. package/docs/adr/0130-provision-remote-exec-over-ssh.md +4 -4
  150. package/docs/adr/0131-rank-scoped-skill-visibility.md +2 -2
  151. package/docs/adr/0132-cohost-fleet-control-plane-migration.md +2 -2
  152. package/docs/adr/0133-github-app-manifest-onboarding-signin.md +1 -1
  153. package/docs/adr/0134-private-first-npm-distribution.md +3 -3
  154. package/docs/adr/0135-module-upstream-submission-interim-queue.md +1 -1
  155. package/docs/adr/0136-update-channel-subscription-policy.md +2 -2
  156. package/docs/adr/0137-upstream-publish-policy-manual-owner-gated.md +6 -6
  157. package/docs/adr/0138-bundled-module-deps-declared-per-module.md +4 -4
  158. package/docs/adr/0142-project-context-is-prose-only.md +2 -2
  159. package/docs/adr/0143-onboarding-repo-picker-oauth-scope.md +2 -2
  160. package/docs/adr/0144-devbox-rehome-onto-cloudbongos-plane.md +2 -2
  161. package/docs/adr/0145-free-hosted-project-tier-isolation-and-domain-separation.md +2 -2
  162. package/docs/adr/0146-configurable-reward-mode-cost-plus-only.md +4 -4
  163. package/docs/adr/0147-standalone-live-docs-regen.md +1 -1
  164. package/docs/adr/0149-bongos-dev-local-launcher.md +1 -1
  165. package/docs/adr/0150-box-first-boot-bringup-vendored-instances.md +3 -3
  166. package/docs/adr/0151-governance-permissions-as-atom-ranks-as-roles.md +20 -20
  167. package/docs/adr/0152-landing-gate-served-by-the-core-app.md +2 -2
  168. package/docs/adr/0152-metic-task-abandonment.md +7 -7
  169. package/docs/adr/0153-sequential-chain-runner.md +1 -1
  170. package/docs/adr/0154-goal-owner-criterion-authoring.md +7 -7
  171. package/docs/adr/0155-adopt-private-repo-widen-oauth-scope.md +2 -2
  172. package/docs/adr/0156-user-set-display-name-wins.md +1 -1
  173. package/docs/adr/0157-archon-is-rank-and-identity-only.md +10 -10
  174. package/docs/adr/0158-choke-point-verdict-taxonomy.md +1 -1
  175. package/docs/adr/0159-merge-gate-reconciliation.md +1 -1
  176. package/docs/adr/0160-main-audit-repo-verifiable-caller.md +1 -1
  177. package/docs/adr/0161-publish-on-merge.md +2 -2
  178. package/docs/adr/0163-legacy-display-name-resolution.md +1 -1
  179. package/docs/adr/0165-instances-hookless-by-default.md +1 -1
  180. package/docs/adr/0166-module-maintenance-and-deprecation-policy.md +3 -3
  181. package/docs/adr/0167-gate-trust-link-agpl-default.md +2 -2
  182. package/docs/adr/0167-module-catalog-source-vs-provenance.md +3 -3
  183. package/docs/adr/0169-bongos-serve-server-bring-up.md +1 -1
  184. package/docs/adr/0171-profile-rollup-join-and-consent.md +1 -1
  185. package/docs/adr/0172-editable-rank-roles-substrate-axis.md +2 -2
  186. package/docs/adr/0172-per-craft-compensation-ideator-credit-lane.md +4 -4
  187. package/docs/adr/0173-legacy-multi-ship-shortfall-not-backfilled.md +1 -1
  188. package/docs/adr/0174-government-terminology-ranks-not-roles.md +1 -1
  189. package/docs/adr/0177-goal-membership-kind.md +3 -3
  190. package/docs/adr/0178-the-copy-desk-flag-and-queue.md +1 -1
  191. package/docs/adr/0179-board-membership-predicate-grammar.md +1 -1
  192. package/docs/adr/0181-teardown-state-gate-and-address-retention.md +1 -1
  193. package/docs/adr/0182-project-visibility-two-axes.md +1 -1
  194. package/docs/adr/0183-criteria-close-themselves.md +1 -1
  195. package/docs/adr/0184-ship-requires-an-assurance.md +1 -1
  196. package/docs/adr/0185-spark-handoff-credit-split.md +1 -1
  197. package/docs/adr/0186-per-project-idea-templates.md +1 -1
  198. package/docs/adr/0187-collab-four-decisions.md +1 -1
  199. package/docs/adr/0187-oauth-handshake-cookie-lifetime.md +1 -1
  200. package/docs/adr/0188-patrol-runs-on-ci-not-the-server.md +2 -2
  201. package/docs/adr/0189-baseline-floors-and-the-freeze-detector.md +2 -2
  202. package/docs/adr/0191-majority-and-the-author-who-may-not-decide-alone.md +1 -1
  203. package/docs/adr/0192-a-category-orients-and-authorises-nothing.md +2 -2
  204. package/docs/adr/0193-pause-task-scoped-box-slices.md +1 -1
  205. package/docs/adr/0195-adr-numbers-are-checked-like-migration-numbers.md +2 -2
  206. package/docs/adr/0196-chrome-world-token-contract-and-the-landing-sheets.md +2 -2
  207. package/docs/adr/0197-the-ui-design-module.md +1 -1
  208. package/docs/adr/0198-third-party-skill-vendoring-policy.md +1 -1
  209. package/docs/adr/0203-doorway-changelog-out-of-module-api.md +2 -2
  210. package/docs/adr/0204-the-apex-is-dark-only.md +2 -2
  211. package/docs/adr/0205-federated-checkin-needs-the-hubs-own-signin-witness.md +1 -1
  212. package/docs/adr/0206-own-scoped-session-revocation-no-absolute-ceiling.md +1 -1
  213. package/docs/adr/0207-connections-implementation-departures-and-honest-limits.md +1 -1
  214. package/docs/adr/0208-application-lifecycle-the-handle-waits-and-the-vouch-has-no-minter.md +2 -2
  215. package/docs/adr/0209-one-budget-for-the-account-existence-oracle.md +1 -1
  216. package/docs/adr/0210-scouting-graduates-on-an-instance-asserted-owner-claim.md +1 -1
  217. package/docs/adr/0212-connections-on-the-profile-surface-and-addressing.md +1 -1
  218. package/docs/adr/0213-platform-terms-acceptance-is-the-hubs-own-witness.md +2 -2
  219. package/docs/adr/0215-the-recruiter-sliver-is-a-ceiling-not-a-step.md +6 -6
  220. package/docs/adr/0216-a-fake-pool-interpreter-must-fail-loudly-not-silently-ignore.md +2 -2
  221. package/docs/adr/0217-rank-gates-inviting-not-viewing.md +1 -1
  222. package/docs/adr/0218-the-apex-root-web-surface.md +2 -2
  223. package/docs/adr/0219-a-look-is-a-branding-pack-the-style-library.md +1 -1
  224. package/docs/adr/0220-an-in-house-rebuilt-skill-is-a-first-party-skill.md +1 -1
  225. package/docs/adr/0221-the-impeccable-method-is-one-skill-with-sub-command-playbooks.md +2 -2
  226. package/docs/adr/0222-the-image-family-under-the-hero-only-rule.md +2 -2
  227. package/docs/adr/0223-the-detector-is-two-tiers-in-the-kit.md +3 -3
  228. package/docs/adr/0224-module-skills-reach-the-core-checkout-as-untracked-self-ignored-copies.md +1 -1
  229. package/docs/adr/0225-privacy-suppresses-cross-project-surfaces-only.md +1 -1
  230. package/docs/adr/0226-one-stale-timer-reporting-degrades-enforcement-refuses.md +1 -1
  231. package/docs/adr/0227-a-task-network-map-is-refused-the-goal-graph-is-the-map.md +1 -1
  232. package/docs/adr/0230-a-look-skill-is-a-composition-grammar-the-palette-is-the-pack.md +1 -1
  233. package/docs/adr/0231-the-style-session-an-owner-authors-a-look-inside-the-contract.md +2 -2
  234. package/docs/adr/0232-rot-is-derived-not-swept-and-water-is-the-only-new-verb.md +2 -2
  235. package/docs/adr/0233-a-copy-proposal-is-a-task-carrying-a-patch.md +2 -2
  236. package/docs/adr/0234-idea-routing-capture-time-promotion-landing-matrix-homeless-inbox.md +2 -2
  237. package/docs/adr/0235-a-fix-category-is-kindbug-under-a-goal-no-column-required.md +11 -11
  238. package/docs/adr/0236-many-ideas-one-task-the-lineage-key-is-the-pair.md +5 -5
  239. package/docs/adr/0237-a-starter-bundle-is-a-preset-over-the-always-on-core-keyed-by-declared-type.md +5 -5
  240. package/docs/adr/0238-the-join-door-is-a-provisioning-settings-policy-key.md +1 -1
  241. package/docs/adr/0239-a-recommendation-is-a-table-that-carries-its-own-reason.md +4 -4
  242. package/docs/adr/0240-a-skipped-module-picker-stores-nothing-and-resolves-the-bundle-on-read.md +4 -4
  243. package/docs/adr/0241-the-artist-gate-is-a-per-project-deploy-gate-that-reads-a-state.md +1 -1
  244. package/docs/adr/0242-a-cascade-is-a-declaration-table-on-the-event-that-already-exists.md +2 -2
  245. package/docs/adr/0243-a-bundle-adjustment-belongs-to-the-bundle-not-the-advice-about-it.md +4 -4
  246. package/docs/adr/0244-a-catalog-search-is-a-view-over-the-rows-never-an-edit-to-the-selection.md +3 -3
  247. package/docs/adr/0245-the-way-back-into-an-optional-step-is-that-step-s-own-answer-route.md +7 -7
  248. package/docs/adr/0246-catalog-membership-is-a-projection-not-a-federation-side-effect.md +1 -1
  249. package/docs/adr/0247-the-join-door-is-one-composed-answer-and-dark-matter-has-no-live-verdict-yet.md +2 -2
  250. package/docs/adr/0248-a-hub-project-s-owner-is-the-provisioning-row-carried-on-a-port.md +3 -3
  251. package/docs/adr/0249-the-invite-step-is-the-done-panel-s-first-act-not-a-rail-step.md +3 -3
  252. package/docs/adr/0250-strict-versioning-the-version-boundary-is-the-scope-gate.md +6 -6
  253. package/docs/adr/0251-a-suggestion-of-who-to-invite-is-a-recruiting-surface-and-owes-the-opt-out.md +5 -5
  254. package/docs/adr/0252-the-self-hosted-visibility-report-is-a-pull-and-auto-appear-buys-a-retraction-lever.md +19 -19
  255. package/docs/adr/0253-the-takedown-lever-has-two-writers-and-provenance-is-a-column.md +2 -2
  256. package/docs/adr/0254-a-relayed-link-is-contained-downward-or-it-is-not-published.md +6 -6
  257. package/docs/adr/0255-a-public-lists-ordering-is-part-of-its-payload.md +2 -2
  258. package/docs/adr/0256-the-visibility-ceiling-is-the-sign-in-gate-too-and-the-reader-moves-to-the-composer.md +3 -3
  259. package/docs/adr/0257-auth-resolves-before-the-hall-mounts-anything.md +1 -1
  260. package/docs/adr/0258-the-public-cli-is-a-generated-client-package-not-the-published-core.md +1 -1
  261. package/docs/adr/0259-a-projects-departure-from-the-public-list-is-public.md +7 -7
  262. package/docs/adr/0260-the-application-is-the-consent-and-the-echo-is-the-gate.md +4 -4
  263. package/docs/adr/0261-a-preselect-always-carries-a-reason-the-bundle-summary-is-the-floor.md +1 -1
  264. package/docs/adr/0262-a-bug-never-lands-in-the-inbox.md +1 -1
  265. package/docs/adr/0263-how-a-version-closes.md +3 -3
  266. package/docs/adr/0264-the-ten-working-areas.md +3 -3
  267. package/docs/adr/0265-work-with-no-goal-and-the-order-the-cut-needs.md +5 -5
  268. package/docs/adr/0266-the-board-room-is-its-own-surface-reachable-by-whoever-may-vote.md +2 -2
  269. package/docs/adr/0267-unanimity-and-the-revise-and-re-sit-loop.md +1 -1
  270. package/docs/adr/0268-the-constitution-comes-from-two-roots.md +3 -3
  271. package/docs/adr/0269-the-cli-session-store-is-host-keyed-at-a-fixed-anchor.md +1 -1
  272. package/docs/adr/0270-rank-floor-derives-without-a-prediction.md +2 -2
  273. package/docs/adr/0271-the-ideator-surface-is-two-halves.md +3 -3
  274. package/docs/adr/0272-a-craft-is-an-identity-not-a-toolchain.md +4 -4
  275. package/docs/adr/0273-hall-nav-can-gate-on-a-permission.md +1 -1
  276. package/docs/adr/0274-one-kernel-three-role-packs.md +1 -1
  277. package/docs/adr/0275-one-source-for-a-written-role-responsibility.md +1 -1
  278. package/docs/adr/0276-the-skill-listing-budget-cannot-hold-every-trigger.md +1 -1
  279. package/docs/adr/0277-a-box-is-in-use-only-while-a-human-is-attached.md +1 -1
  280. package/docs/adr/0278-a-gated-project-still-takes-applications.md +2 -2
  281. package/docs/adr/0279-an-upgrade-is-proven-by-the-served-version-not-the-health-check.md +1 -1
  282. package/docs/adr/0280-the-unattended-lane-may-drive-a-co-tenant-because-the-roster-is-the-decision.md +1 -1
  283. package/docs/adr/0281-an-instance-identity-is-its-own-unix-account-and-pg-role.md +2 -2
  284. package/docs/adr/README.md +294 -294
  285. package/docs/api/openapi.json +72 -5
  286. package/docs/api-reference.md +8 -2
  287. package/docs/architecture.md +22 -22
  288. package/docs/branding-contract.md +5 -5
  289. package/docs/canonical-permissions.md +22 -22
  290. package/docs/design/hall-direction-v2.md +3 -3
  291. package/docs/design/landing-page-direction-v2.md +1 -1
  292. package/docs/design/projects-hub-direction-v2.md +1 -1
  293. package/docs/file-map.md +3 -1
  294. package/docs/module-api-changelog.md +34 -1
  295. package/docs/modules-contract.md +25 -25
  296. package/docs/onboarding/browser-terminal-guide.md +1 -1
  297. package/docs/onboarding/diagrams/README.md +3 -3
  298. package/docs/onboarding/drachmae-and-karma.md +13 -13
  299. package/docs/onboarding/primer.md +7 -7
  300. package/docs/packs/artist.md +1 -1
  301. package/docs/packs/ideator.md +1 -1
  302. package/docs/project-context.template.md +3 -3
  303. package/docs/recipes/builder-box-lifecycle.md +5 -5
  304. package/docs/recipes/claude-design-loop.md +1 -1
  305. package/docs/recipes/core-release-pipeline.md +55 -14
  306. package/docs/recipes/figma-design-loop.md +1 -1
  307. package/docs/recipes/gds-db-backup.md +2 -2
  308. package/docs/recipes/instance-deploy.md +2 -2
  309. package/docs/recipes/instance-service-restart.md +3 -3
  310. package/docs/recipes/local-dev.md +2 -2
  311. package/docs/recipes/managed-settings-remote-control.md +3 -3
  312. package/docs/recipes/memory-backup.md +4 -4
  313. package/docs/recipes/memory-sync-conflicts.md +2 -2
  314. package/docs/recipes/multi-builder-merge.md +2 -2
  315. package/docs/recipes/ops-gotchas.md +4 -4
  316. package/docs/recipes/overnight-builder.md +1 -1
  317. package/docs/recipes/packaging-the-core.md +6 -6
  318. package/docs/recipes/private-npm-distribution.md +5 -5
  319. package/docs/recipes/search-index-ingestion.md +3 -3
  320. package/docs/recipes/self-host.md +5 -5
  321. package/docs/recipes/server-bring-up.md +1 -1
  322. package/docs/recipes/ship-and-regrade.md +2 -2
  323. package/docs/recipes/standalone-live-docs.md +1 -1
  324. package/docs/recipes/ui-look-before-you-ship.md +1 -1
  325. package/docs/recipes/upgrading-the-core.md +2 -2
  326. package/docs/recipes/work-on-a-task-from-your-dev-box.md +6 -6
  327. package/docs/research/cursor-competitive-analysis.md +3 -3
  328. package/docs/research/llm-model-agnostic-and-cheaper-grader.md +7 -7
  329. package/migrations/core_240_artist_gate_indexes.sql +48 -0
  330. package/migrations/core_240_goal_members_drop_legacy_role.sql +79 -0
  331. package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
  332. package/modules/lifecycle/cascade.js +37 -9
  333. package/modules/lifecycle/db-claims.js +5 -2
  334. package/modules/lifecycle/db-goals.js +4 -3
  335. package/modules/lifecycle/db-overrides.js +117 -4
  336. package/modules/lifecycle/db-tasks.js +63 -0
  337. package/modules/lifecycle/migrations/lifecycle_001_goal_first_leads.sql +12 -4
  338. package/modules/lifecycle/module.json +2 -1
  339. package/modules/lifecycle/pg-constraint-fail.js +49 -0
  340. package/modules/lifecycle/routes/artist-gate.js +101 -0
  341. package/modules/lifecycle/routes/claims.js +13 -1
  342. package/modules/lifecycle/routes/lifecycle.js +6 -0
  343. package/modules/lifecycle/routes/task-write-routes.js +7 -0
  344. package/modules/lifecycle/routes/tasks.js +24 -0
  345. package/modules/lifecycle/task-classifier.js +10 -1
  346. package/modules/lifecycle/tests/cascade.mjs +43 -0
  347. package/modules/provisioning/provisioning.js +79 -3
  348. package/modules/provisioning/routes/provisioning.js +6 -1
  349. package/modules/provisioning/tests/provisioning.mjs +8 -8
  350. package/modules/security/secret-scrub.js +40 -4
  351. package/modules/status-ui/public/README.md +1 -1
  352. package/modules/ui-design/docs/design-contract.md +2 -2
  353. package/modules/ui-design/kit/README.md +1 -1
  354. package/modules/ui-design/kit/serve.js +1 -0
  355. package/modules/ui-design/skills/README.md +6 -6
  356. package/modules/ui-design/skills/brandkit/SKILL.md +1 -1
  357. package/modules/ui-design/skills/design-taste-frontend/SKILL.md +1 -1
  358. package/modules/ui-design/skills/design-taste-frontend-v1/SKILL.md +1 -1
  359. package/modules/ui-design/skills/gpt-taste/SKILL.md +1 -1
  360. package/modules/ui-design/skills/high-end-visual-design/SKILL.md +1 -1
  361. package/modules/ui-design/skills/image-to-code/SKILL.md +1 -1
  362. package/modules/ui-design/skills/imagegen-frontend-mobile/SKILL.md +1 -1
  363. package/modules/ui-design/skills/imagegen-frontend-web/SKILL.md +1 -1
  364. package/modules/ui-design/skills/impeccable/SKILL.md +1 -1
  365. package/modules/ui-design/skills/industrial-brutalist-ui/SKILL.md +1 -1
  366. package/modules/ui-design/skills/minimalist-ui/SKILL.md +1 -1
  367. package/modules/ui-design/skills/redesign-existing-projects/SKILL.md +1 -1
  368. package/modules/ui-design/skills/stitch-design-taste/SKILL.md +1 -1
  369. package/modules/ui-design/skills/style/SKILL.md +2 -2
  370. package/modules/ui-design/styles/README.md +3 -3
  371. package/modules/ui-design/styles/blueprint/DESIGN.md +1 -1
  372. package/modules/ui-design/styles/chrome-world/DESIGN.md +1 -1
  373. package/package-lock.json +2 -2
  374. package/package.json +1 -1
  375. package/scripts/gds/audit-rank-roles.js +172 -0
  376. package/scripts/gds/build-cli-package.js +1 -0
  377. package/scripts/gds/claim.js +94 -9
  378. package/scripts/gds/fitness.js +8 -8
  379. package/scripts/gds/go-live.js +139 -5
  380. package/scripts/gds/land-watch.js +13 -5
  381. package/scripts/gds/mirror-redact.js +13 -2
  382. package/scripts/gds/package-core.js +11 -3
  383. package/scripts/gds/release.js +55 -0
  384. package/scripts/gds/ship-land.js +10 -2
  385. package/scripts/gds/ship-regen.js +41 -8
  386. package/scripts/gds/start.js +7 -4
  387. package/scripts/gds/worktree-claim-guard.js +27 -1
  388. package/scripts/public-mirror-export.js +17 -4
  389. package/src/bongos/api-errors.js +9 -0
  390. package/src/bongos/artist-gate.js +204 -0
  391. package/src/branding.js +15 -0
  392. package/src/module-api.js +34 -1
  393. package/tests/api_path_404.mjs +28 -0
  394. package/tests/artist_gate.mjs +234 -0
  395. package/tests/artist_gate_release.mjs +283 -0
  396. package/tests/artist_gate_route.mjs +174 -0
  397. package/tests/box_scope_predicate.mjs +117 -0
  398. package/tests/claim_error_surface.mjs +163 -1
  399. package/tests/cli_exit_no_abort.mjs +256 -3
  400. package/tests/currency_label.mjs +11 -4
  401. package/tests/go_live.mjs +144 -0
  402. package/tests/main_worktree_busy_reason.mjs +167 -0
  403. package/tests/membership_kind_lockstep.mjs +88 -0
  404. package/tests/mirror_redact.mjs +14 -0
  405. package/tests/module_api.mjs +6 -0
  406. package/tests/provision_settings_apply.mjs +13 -5
  407. package/tests/provisioning_settings.mjs +11 -7
  408. package/tests/provisioning_settings_apply.mjs +4 -4
  409. package/tests/provisioning_settings_env.mjs +59 -7
  410. package/tests/public_mirror_export.mjs +12 -0
  411. package/tests/rank_roles_audit.mjs +114 -0
  412. package/tests/scrubber_corpus.mjs +70 -2
  413. package/tests/start_rebase_warning.mjs +50 -0
  414. package/tests/task_field_bounds_match_db.mjs +140 -0
package/tests/go_live.mjs CHANGED
@@ -25,6 +25,7 @@
25
25
  import { test } from 'node:test';
26
26
  import assert from 'node:assert/strict';
27
27
  import { createRequire } from 'node:module';
28
+ import fs from 'node:fs';
28
29
  import path from 'node:path';
29
30
  import { fileURLToPath } from 'node:url';
30
31
 
@@ -109,6 +110,149 @@ test('an undetermined divergence halts and does not fall through to a bump', ()
109
110
  assert.equal(p.from, null, 'must not invent a from-version');
110
111
  });
111
112
 
113
+ // ---------------------------------------------------------------------------
114
+ // The artist gate (ADR 0241, task 1003575)
115
+ //
116
+ // This is the one halt that comes from a PERSON'S queue rather than from git, so
117
+ // what these pin is the boundary around it: it stops a PIN MOVE, it names its own
118
+ // remedy, and it never fires on anything but a definite 'strict'-with-open-reviews
119
+ // answer. A gate that halted on "I could not tell" would brick deploys on an
120
+ // unrelated outage with no task id to release against — the blocked-state-that-
121
+ // does-not-explain-itself failure ADR 0241 §4 was written against.
122
+ // ---------------------------------------------------------------------------
123
+
124
+ // The shape readArtistGate() resolves to, as the live instance reports it.
125
+ const gate = (over = {}) => ({ mode: 'strict', since: null, holds: false, reviews: [], halt: null, ...over });
126
+ const heldGate = (n = 1) => gate({
127
+ holds: true,
128
+ reviews: Array.from({ length: n }, (_, i) => ({ id: String(9000 + i), title: 'Artist review: …' })),
129
+ halt: {
130
+ code: 'ARTIST_REVIEW_UNRESOLVED',
131
+ message: `${n} unresolved artist review${n === 1 ? '' : 's'} hold this deploy — the project runs artist_gate 'strict'`,
132
+ detail: 'Held by task 9000. Two ways out … POST /tasks/:id/override-request …',
133
+ },
134
+ });
135
+
136
+ test('an unresolved artist review HALTS the pin move', () => {
137
+ const p = G.planGoLive({ div: div(), target: okTarget, targetVersion: '1.19.306', artistGate: heldGate(2) });
138
+ const h = p.halts.find((x) => x.code === 'ARTIST_REVIEW_UNRESOLVED');
139
+ assert.ok(h, 'expected ARTIST_REVIEW_UNRESOLVED');
140
+ // The refusal has to carry its own way out, or it is the stall ADR 0162 retired.
141
+ assert.match(h.detail, /override-request/);
142
+ });
143
+
144
+ test('a gate that holds nothing halts nothing — at any level', () => {
145
+ for (const mode of ['off', 'advisory', 'strict']) {
146
+ const p = G.planGoLive({ div: div(), target: okTarget, targetVersion: '1.19.306', artistGate: gate({ mode }) });
147
+ assert.equal(p.halts.length, 0, `${mode}: ${JSON.stringify(p.halts)}`);
148
+ }
149
+ });
150
+
151
+ test('an UNREAD gate is a note, never a halt — and never renders as "nothing holds"', () => {
152
+ // No session, or an instance too old to serve the route: we do not know the
153
+ // gate applies at all, and halting every deploy on that would brick the pin
154
+ // with no task id to release against.
155
+ const p = G.planGoLive({
156
+ div: div(), target: okTarget, targetVersion: '1.19.306',
157
+ artistGate: { unread: 'this instance does not serve /artist-gate — it predates ADR 0241' },
158
+ });
159
+ assert.equal(p.halts.length, 0, 'not knowing must not brick the pin');
160
+ const n = p.notes.find((x) => x.code === 'ARTIST_GATE_UNREAD');
161
+ assert.ok(n, 'but it must be REPORTED — "could not tell" is not "nothing holds"');
162
+ assert.match(n.message, /may be holding/, 'the wording must not claim the gate is clear');
163
+ });
164
+
165
+ test('a gate that is THERE and BROKEN halts — the fail-closed half', () => {
166
+ // The other side of the line: the route exists and 500s. We know the gate
167
+ // applies and cannot get an answer from it, so deploying past would be a
168
+ // quieter way through than the owner's own override — the asymmetry to refuse.
169
+ const p = G.planGoLive({
170
+ div: div(), target: okTarget, targetVersion: '1.19.306',
171
+ artistGate: { broken: 'GET /artist-gate returned 500' },
172
+ });
173
+ const h = p.halts.find((x) => x.code === 'ARTIST_GATE_UNREADABLE');
174
+ assert.ok(h, 'expected ARTIST_GATE_UNREADABLE');
175
+ assert.match(h.detail, /fault on the instance/, 'and it must point at the instance, not at an artist');
176
+ assert.equal(p.notes.find((x) => x.code === 'ARTIST_GATE_UNREAD'), undefined, 'one verdict, not two');
177
+ });
178
+
179
+ test('artistGateEntries is the shared composer, so the gitless lane cannot skip the gate', () => {
180
+ // The on-box lane builds its plan without planGoLive; it reaches the same halt
181
+ // through this, which is what stops "no git checkout" from meaning "no gate".
182
+ assert.equal(G.artistGateEntries(heldGate()).halts[0].code, 'ARTIST_REVIEW_UNRESOLVED');
183
+ assert.deepEqual(G.artistGateEntries(null), { halts: [], notes: [] });
184
+ assert.equal(G.artistGateEntries({ unread: 'no session' }).notes[0].code, 'ARTIST_GATE_UNREAD');
185
+ });
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // readArtistGate — the network leg.
189
+ //
190
+ // This is tested rather than left to the file's ssh/spawnSync boundary because a
191
+ // mistake here is INVISIBLE: cli-lib's apiCall composes `${origin}${path}`, so a
192
+ // path missing its `/api/bongos` prefix lands on the origin's catch-all and the
193
+ // gate reports itself unread on every instance — including the ones that fully
194
+ // support it — while the plan reads exactly like a clean run. It shipped that way
195
+ // once. The URL is pinned against the route's REAL mount point in the generated
196
+ // OpenAPI spec, so a rename on either side reds this rather than going quiet.
197
+ // ---------------------------------------------------------------------------
198
+
199
+ test('the path it requests is the one the server actually mounts', () => {
200
+ // The spec's paths are relative to its server base, and apiCall prepends
201
+ // nothing — so the URL go-live builds must be base + path, assembled from the
202
+ // spec rather than retyped. A rename on either side reds this.
203
+ const spec = JSON.parse(fs.readFileSync(new URL('../docs/api/openapi.json', import.meta.url), 'utf8'));
204
+ const bases = (spec.servers || []).map((s) => s.url);
205
+ const full = new Set(bases.flatMap((b) => Object.keys(spec.paths || {}).map((p) => `${b}${p}`)));
206
+ assert.ok(full.has(G.ARTIST_GATE_PATH),
207
+ `go-live requests ${G.ARTIST_GATE_PATH}, which the API spec does not serve — it would hit the origin's catch-all and read as "unread" forever`);
208
+ assert.match(G.ARTIST_GATE_PATH, /^\/api\//, 'apiCall does not prepend an API prefix — the path must carry it');
209
+ });
210
+
211
+ test('a real answer is parsed; a 200 that is NOT this route reads as unread, never as "nothing holds"', async () => {
212
+ const seen = [];
213
+ const call = async (method, path, body, opts) => { seen.push({ method, path, opts }); return call._res; };
214
+
215
+ call._res = { status: 200, ok: true, data: { mode: 'strict', since: null, holds: true, reviews: [{ id: '9001' }], halt: { message: 'm', detail: 'd' } } };
216
+ const got = await G.readArtistGate(call);
217
+ assert.deepEqual(seen[0].method, 'GET');
218
+ assert.equal(seen[0].path, G.ARTIST_GATE_PATH);
219
+ assert.equal(got.mode, 'strict');
220
+ assert.ok(got.halt, 'a real halt survives the read');
221
+
222
+ // An SPA index.html from a catch-all, or a proxy's own page: 200, no gate. The
223
+ // one failure that would wave a deploy through in silence.
224
+ call._res = { status: 200, ok: true, data: { raw: '<!doctype html><html>…' } };
225
+ const spa = await G.readArtistGate(call);
226
+ assert.ok(spa.unread, 'a 200 from something else is NOT a gate that holds nothing');
227
+ assert.equal(spa.halt, undefined);
228
+ });
229
+
230
+ test('every failure shape resolves — 404 and a lost connection are notes, a 5xx is the halt', async () => {
231
+ const res = (r) => async () => r;
232
+ assert.match((await G.readArtistGate(res({ status: 404, ok: false, data: {} }))).unread, /predates ADR 0241/);
233
+ assert.match((await G.readArtistGate(res({ status: 401, ok: false, data: {} }))).unread, /401/);
234
+ assert.match((await G.readArtistGate(res({ status: 500, ok: false, data: {} }))).broken, /500/);
235
+ assert.match((await G.readArtistGate(res({ status: 503, ok: false, data: {} }))).broken, /503/);
236
+ // It must never throw: a go-live that crashes on a gate read is worse than one
237
+ // that halts on it.
238
+ const thrower = async () => { throw new Error('ECONNREFUSED'); };
239
+ assert.match((await G.readArtistGate(thrower)).unread, /ECONNREFUSED/);
240
+ });
241
+
242
+ test('the level vocabulary go-live checks against is the gate\'s own', async () => {
243
+ const gate = await import('../src/bongos/artist-gate.js');
244
+ assert.deepEqual([...G.ARTIST_GATE_LEVELS].sort(), [...gate.ARTIST_GATE_LEVELS].sort(),
245
+ 'the mirrored list must not drift from src/bongos/artist-gate.js');
246
+ });
247
+
248
+ test('the plan SHOWS the gate level even when nothing is held', () => {
249
+ const out = G.renderPlan(
250
+ { ...G.planGoLive({ div: div(), target: okTarget, targetVersion: '1.19.306', artistGate: gate({ mode: 'advisory' }) }), artistGate: gate({ mode: 'advisory' }) },
251
+ { targetName: 'web' }
252
+ );
253
+ assert.match(out, /artist gate\s+advisory/, '"why did nothing stop?" must be answerable from the plan');
254
+ });
255
+
112
256
  // ---------------------------------------------------------------------------
113
257
  // Shell safety — this builds a command that runs as root over ssh
114
258
  // ---------------------------------------------------------------------------
@@ -0,0 +1,167 @@
1
+ // tests/main_worktree_busy_reason.mjs
2
+ //
3
+ // task 1002871 — every bail that touches the shared main checkout must identify
4
+ // the OCCUPANT, not just the symptom.
5
+ //
6
+ // THE INCIDENT (idea 1000604, 2026-07-28). A shared main checkout sat parked on
7
+ // `task-1002503-backfill-recovery` with 2 unpushed commits and 4 modified
8
+ // generated files, and blocked the auto-merge leg for NINE confirmed tasks. Most
9
+ // of that idea was fixed: ship-merge.js now detects a main checkout on a feature
10
+ // branch, names it, and states the cure.
11
+ //
12
+ // What was NOT fixed is the exact shape of the incident. mainWorktreeBusy() runs
13
+ // FIRST, and only ever said 'a merge is already in progress' or 'the working tree
14
+ // is dirty or has unmerged files'. A checkout that is BOTH parked AND dirty — the
15
+ // 2026-07-28 state precisely — short-circuits there, so the named-branch
16
+ // diagnostic was reachable only when the tree was clean: the one case where
17
+ // nobody needs telling twice.
18
+ //
19
+ // The decision is pure now so the exact string is pinned rather than eyeballed.
20
+ // This is the ship hot path and the string IS the deliverable.
21
+ //
22
+ // Run: node tests/main_worktree_busy_reason.mjs
23
+
24
+ import { strict as assert } from 'node:assert';
25
+ import { readFileSync } from 'node:fs';
26
+ import path from 'node:path';
27
+ import { createRequire } from 'node:module';
28
+ import { fileURLToPath } from 'node:url';
29
+
30
+ const require = createRequire(import.meta.url);
31
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
32
+ const { describeMainWorktreeBusy } = require(path.join(ROOT, 'scripts', 'gds', 'ship-regen.js'));
33
+ const { parkedMainCheckoutWarning } = require(path.join(ROOT, 'scripts', 'gds', 'worktree-claim-guard.js'));
34
+
35
+ let passed = 0;
36
+ let failed = 0;
37
+ function t(name, fn) {
38
+ try { fn(); passed++; console.log(` PASS ${name}`); }
39
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
40
+ }
41
+
42
+ // --- describeMainWorktreeBusy ------------------------------------------------
43
+
44
+ t('a clean main checkout on main is not busy', () => {
45
+ assert.equal(describeMainWorktreeBusy({ mergeInProgress: false, dirty: false, branch: 'main' }), null);
46
+ });
47
+
48
+ t('THE INCIDENT: dirty AND parked on a feature branch names the branch', () => {
49
+ const reason = describeMainWorktreeBusy({
50
+ mergeInProgress: false, dirty: true, branch: 'task-1002503-backfill-recovery',
51
+ });
52
+ assert.match(reason, /dirty or has unmerged files/, 'keeps the original symptom');
53
+ assert.match(reason, /task-1002503-backfill-recovery/, 'and NAMES the squatting branch — the whole point');
54
+ assert.match(reason, /not `main`/, 'and says what it should have been');
55
+ });
56
+
57
+ t('a merge in progress on a feature branch also names it', () => {
58
+ const reason = describeMainWorktreeBusy({ mergeInProgress: true, dirty: true, branch: 'task-999' });
59
+ assert.match(reason, /merge is already in progress/);
60
+ assert.match(reason, /task-999/, 'the branch is named on this arm too, not just the dirty one');
61
+ });
62
+
63
+ t('merge-in-progress wins over dirty — one reason, the most specific', () => {
64
+ const reason = describeMainWorktreeBusy({ mergeInProgress: true, dirty: true, branch: 'main' });
65
+ assert.match(reason, /merge is already in progress/);
66
+ assert.ok(!/dirty/.test(reason), 'must not concatenate both symptoms into one run-on reason');
67
+ });
68
+
69
+ t('on `main` itself the branch is NOT named — that line would be pure noise', () => {
70
+ const reason = describeMainWorktreeBusy({ mergeInProgress: false, dirty: true, branch: 'main' });
71
+ assert.equal(reason, 'the working tree is dirty or has unmerged files',
72
+ 'saying "(on main, not main)" in every bail trains the reader to skip the line');
73
+ });
74
+
75
+ t('an unreadable branch degrades to the old wording rather than inventing one', () => {
76
+ for (const branch of [null, undefined, '', 'HEAD']) {
77
+ const reason = describeMainWorktreeBusy({ mergeInProgress: false, dirty: true, branch });
78
+ assert.equal(reason, 'the working tree is dirty or has unmerged files',
79
+ `branch=${JSON.stringify(branch)} must not produce a half-formed name`);
80
+ }
81
+ });
82
+
83
+ t('a non-default defaultBranch is honoured', () => {
84
+ assert.equal(describeMainWorktreeBusy({ dirty: true, branch: 'trunk', defaultBranch: 'trunk' }),
85
+ 'the working tree is dirty or has unmerged files', 'the repo default is not hardcoded to main');
86
+ assert.match(describeMainWorktreeBusy({ dirty: true, branch: 'main', defaultBranch: 'trunk' }),
87
+ /on `main`, not `trunk`/);
88
+ });
89
+
90
+ t('no facts at all is a quiet null, never a throw', () => {
91
+ assert.equal(describeMainWorktreeBusy(), null);
92
+ assert.equal(describeMainWorktreeBusy({}), null);
93
+ });
94
+
95
+ // --- parkedMainCheckoutWarning (the release.js half) -------------------------
96
+
97
+ t('release warning is silent when the main checkout is on main', () => {
98
+ assert.deepEqual(parkedMainCheckoutWarning({ branch: 'main' }), []);
99
+ assert.deepEqual(parkedMainCheckoutWarning({ branch: null }), [], 'an unreadable branch says nothing');
100
+ assert.deepEqual(parkedMainCheckoutWarning({}), []);
101
+ });
102
+
103
+ t('release warning names the parked branch, the cost, and the cure', () => {
104
+ const out = parkedMainCheckoutWarning({ branch: 'task-1002503-backfill-recovery' }).join('\n');
105
+ assert.match(out, /task-1002503-backfill-recovery/, 'names the occupant');
106
+ assert.match(out, /nowhere to merge INTO/, 'states the cost, not just the fact');
107
+ assert.match(out, /someone else/, 'and that the cost lands on other builders too');
108
+ assert.match(out, /checkout main/, 'and gives the cure');
109
+ });
110
+
111
+ t('release warning NEVER offers to restore the tree — on EITHER branch of the message', () => {
112
+ // Both arms, deliberately. A probe that made only the clean-tree line offer a
113
+ // restore kept this test green when it checked the dirty arm alone: the
114
+ // "we will not touch it" promise has two renderings and both must keep it.
115
+ for (const dirty of [true, false]) {
116
+ const out = parkedMainCheckoutWarning({ branch: 'task-42', dirty }).join('\n');
117
+ assert.match(out, /nothing here will touch it/i,
118
+ `dirty=${dirty}: switching the tree back would be a destructive guess — the branch may hold real work`);
119
+ assert.ok(!/\brestore it\b/i.test(out),
120
+ `dirty=${dirty}: must not offer to restore the tree on the builder's behalf`);
121
+ }
122
+ const dirtyOut = parkedMainCheckoutWarning({ branch: 'task-42', dirty: true }).join('\n');
123
+ assert.match(dirtyOut, /uncommitted changes/i, 'and when it IS dirty, say so — that is why we do not touch it');
124
+ });
125
+
126
+ // --- the probes that gather the facts must not build shell strings -----------
127
+ //
128
+ // Caught by the grader on this task: the first cut ran
129
+ // `git -C "${root}" rev-parse …` through execSync, which spawns a shell. A
130
+ // resolved filesystem path is NOT trusted input — a directory containing a quote,
131
+ // backtick or `$()` breaks the quoting and executes as shell. The repo's
132
+ // established pattern passes the directory to child_process instead of
133
+ // interpolating it into a command string, and release.js now goes further and
134
+ // uses an argv array with no shell at all.
135
+ t('release.js never interpolates a path into a git command string', () => {
136
+ const raw = readFileSync(path.join(ROOT, 'scripts', 'gds', 'release.js'), 'utf8');
137
+ // Strip comments first: the file DESCRIBES the unsafe form on purpose, to stop
138
+ // the next reader reintroducing it. Scanning prose as if it were code is how a
139
+ // guard reds on its own documentation — which this one did, first run.
140
+ const src = raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
141
+ assert.ok(!/git\s+-C\s+["'`]?\$\{/.test(src),
142
+ 'a `git -C "${path}"` command string is shell-interpolated — pass the directory to child_process instead');
143
+ assert.ok(!/execSync\s*\(\s*[`'"]git /.test(src),
144
+ 'a git command built as a shell string re-opens the injection this test exists to close');
145
+ assert.match(src, /execFileSync\('git', args/,
146
+ 'git must be invoked with an argv array, which never reaches a shell');
147
+ assert.match(src, /cwd,/, 'and the directory must travel as cwd, not inside the command');
148
+ // The stripper must not have eaten the file: a guard over an empty string
149
+ // passes every negative assertion above and proves nothing.
150
+ assert.ok(src.length > 500, `comment-stripping left only ${src.length} chars — the scan is reading nothing`);
151
+ });
152
+
153
+ t('release.js imports nothing outside the published CLI package', () => {
154
+ const src = readFileSync(path.join(ROOT, 'scripts', 'gds', 'release.js'), 'utf8');
155
+ const manifest = readFileSync(path.join(ROOT, 'scripts', 'gds', 'build-cli-package.js'), 'utf8');
156
+ const locals = [...src.matchAll(/require\('\.\/([A-Za-z0-9._-]+)'\)/g)].map((m) => m[1]);
157
+ assert.ok(locals.length > 0, 'no local requires found — this guard is reading the wrong shape');
158
+ for (const name of locals) {
159
+ const file = name.endsWith('.js') ? name : `${name}.js`;
160
+ assert.ok(manifest.includes(`scripts/gds/${file}`),
161
+ `release.js requires ./${name}, which is not in the CLI package FILES list — `
162
+ + '`bongos release` would crash at require time in the published package (it did once already)');
163
+ }
164
+ });
165
+
166
+ console.log(`\nmain_worktree_busy_reason: ${passed} passed, ${failed} failed`);
167
+ process.exit(failed === 0 ? 0 : 1);
@@ -35,9 +35,13 @@ import assert from 'node:assert/strict';
35
35
  import { test } from 'node:test';
36
36
  import fs from 'node:fs';
37
37
  import path from 'node:path';
38
+ import { createRequire } from 'node:module';
38
39
  import { fileURLToPath } from 'node:url';
39
40
  import { authSource } from './helpers.mjs';
40
41
 
42
+ const require = createRequire(import.meta.url);
43
+ const lint = require('../scripts/gds/lint-migrations.js');
44
+
41
45
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
42
46
  const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
43
47
 
@@ -111,3 +115,87 @@ test('the sender writes BOTH spellings, computing the kind once (the cross-versi
111
115
  assert.equal((body.match(/membershipKind\(\{ githubLogin \}\)/g) || []).length, 1,
112
116
  'the kind is computed ONCE per check-in, not per field');
113
117
  });
118
+
119
+ // ── THE CONTRACT PHASE — the test of the shim's own removal (task 1003089) ─────
120
+ //
121
+ // ADR 0174's Lineage lesson: a time-boxed shim outlives its release unless its
122
+ // removal ships with the test that says it is gone. core_240 closed the GOAL
123
+ // membership window — the trigger, the function and `goal_members.role` are all
124
+ // dropped, and `membership_kind` carries NOT NULL + DEFAULT itself rather than
125
+ // resting on a trigger surviving.
126
+ //
127
+ // The PROJECT membership half (platform_identity_010) is deliberately NOT here:
128
+ // it is still inside its cross-version federation window, its contract phase is
129
+ // task 1003092, and the expand-phase block above is what still guards it. Do not
130
+ // merge the two — the whole point of the pair is that they close on different
131
+ // schedules.
132
+ const CONTRACT = 'migrations/core_240_goal_members_drop_legacy_role.sql';
133
+
134
+ test('core_240: the contract phase drops the trigger, the function AND the column', () => {
135
+ const sql = read(CONTRACT);
136
+ assert.match(sql, /DROP TRIGGER IF EXISTS goal_members_membership_kind_lockstep ON goal_members/);
137
+ assert.match(sql, /DROP FUNCTION IF EXISTS goal_members_membership_kind_lockstep\(\)/,
138
+ 'the FUNCTION outlives the trigger unless it is dropped by name — dropping only the trigger '
139
+ + 'leaves a dangling plpgsql body naming a column that no longer exists');
140
+ assert.match(sql, /ALTER TABLE goal_members\s+DROP COLUMN IF EXISTS role/);
141
+ assert.match(sql, /ALTER COLUMN membership_kind SET NOT NULL/,
142
+ 'the guarantee core_220 deferred to step 2 arrives here, or the column never gets one');
143
+ assert.match(sql, /ALTER COLUMN membership_kind SET DEFAULT 'member'/,
144
+ 'safe only NOW: a default while the legacy column was still written is the task-1003093 bug');
145
+ });
146
+
147
+ test('core_240: the forward-unsafe pragma is present, and its FIRST line carries the reason', () => {
148
+ const scan = lint.scanMigrationSql(read(CONTRACT));
149
+ assert.deepEqual([...scan.unsafe].sort(), ['drop-column', 'set-not-null'],
150
+ 'the linter must still SEE both operations — an acknowledged migration is not an exempt one');
151
+ assert.ok(scan.acknowledged,
152
+ 'a drop-column with no pragma reds tests/migrations_forward_safe.mjs, which is the point');
153
+ // ACK_RE captures a SINGLE line, so a reason spread over a paragraph records
154
+ // nothing in the lint output. The release a rollback could restore is the one
155
+ // fact that makes the drop safe, so it belongs on that line.
156
+ assert.match(scan.ackReason, /1\.19\.238/,
157
+ 'the pragma must name the release that stopped reading the column, not merely assert safety');
158
+ });
159
+
160
+ test('the FRESH-INSTALL seed writes the new name — the writer that is not application code', () => {
161
+ // The last writer of `goal_members.role` was a MODULE migration, not a query:
162
+ // lifecycle_001_goal_first_leads.sql named it in the INSERT list AND the
163
+ // ON CONFLICT arm. Module migrations run AFTER the core ones on every fresh
164
+ // install, so a stale name here survives the DROP invisibly on an existing
165
+ // instance (already in schema_migrations, never re-run) and breaks
166
+ // `cloudbongos init` for every new one. Zero rows on a fresh DB does not save
167
+ // it: a missing column is a plan-time error, not a row-time one.
168
+ const code = lint.stripSqlComments(read('modules/lifecycle/migrations/lifecycle_001_goal_first_leads.sql'));
169
+ assert.match(code, /INSERT INTO goal_members \(goal_id, builder_id, membership_kind\)/);
170
+ assert.match(code, /DO UPDATE SET membership_kind = 'lead'/,
171
+ 'the ON CONFLICT arm names the column too — renaming only the INSERT list still fails to plan');
172
+ assert.ok(!/\brole\b/.test(code),
173
+ 'no statement in the fresh-install seed may name the dropped column');
174
+ });
175
+
176
+ test('no migration outside the three that own its lifecycle still names goal_members.role', () => {
177
+ // Comments are stripped first: the history is allowed to DISCUSS the old column,
178
+ // and this rule is about statements. The three exemptions are the migrations that
179
+ // created the column, expanded away from it, and dropped it.
180
+ const OWNS_ITS_LIFECYCLE = new Set([
181
+ '160_goal_tier.sql', // created it
182
+ 'core_220_goal_members_membership_kind.sql', // expand: read it to backfill; the trigger kept it in step
183
+ 'core_240_goal_members_drop_legacy_role.sql', // contract: dropped it
184
+ ]);
185
+ const dirs = [path.join(ROOT, 'migrations')];
186
+ for (const d of fs.readdirSync(path.join(ROOT, 'modules'), { withFileTypes: true })) {
187
+ if (!d.isDirectory()) continue;
188
+ const md = path.join(ROOT, 'modules', d.name, 'migrations');
189
+ if (fs.existsSync(md)) dirs.push(md);
190
+ }
191
+ const offenders = [];
192
+ for (const dir of dirs) {
193
+ for (const f of fs.readdirSync(dir)) {
194
+ if (!f.endsWith('.sql') || OWNS_ITS_LIFECYCLE.has(f)) continue;
195
+ const code = lint.stripSqlComments(fs.readFileSync(path.join(dir, f), 'utf8'));
196
+ if (/goal_members/.test(code) && /\brole\b/.test(code)) offenders.push(f);
197
+ }
198
+ }
199
+ assert.deepEqual(offenders, [],
200
+ `these migrations touch goal_members and still name the dropped column: ${offenders.join(', ')}`);
201
+ });
@@ -161,6 +161,20 @@ t('findSecretLeak detects a secret shape, clean text passes', () => {
161
161
  assert.equal(findSecretLeak('token ghp_' + 'a'.repeat(36)), true);
162
162
  assert.equal(findSecretLeak('just ordinary prose here'), false);
163
163
  });
164
+ // Regression (task 1003873). findSecretLeak is a DIFFERENTIAL audit — it re-scrubs and asks
165
+ // "did anything change?" — so it must run with the same options as the scrub it is auditing.
166
+ // Given none, it re-redacts the long ADR filename that task 1002460's allowlist deliberately
167
+ // spared, mistakes its own disagreement for a leak, and (the gate being fail-closed) reports
168
+ // a phantom secret in every published doc.
169
+ t('findSecretLeak forwards scrub options, so it can audit a scrub that used them', () => {
170
+ const name = '0281-differential-audit-allowlist-symmetry.md';
171
+ const doc = `see docs/adr/${name} for the rationale`;
172
+ const knownDocNames = new Set([name]);
173
+ assert.equal(findSecretLeak(doc), true, 'without the allowlist the filename reads as a secret');
174
+ assert.equal(findSecretLeak(doc, [], { knownDocNames }), false, 'with it, a file is a file');
175
+ // …and the allowlist is not a smuggling channel: a real shape beside it still trips.
176
+ assert.equal(findSecretLeak(`ghp_${'a'.repeat(36)} in docs/adr/${name}`, [], { knownDocNames }), true);
177
+ });
164
178
 
165
179
  // ── vanilla instance (empty branding) — IP floor still works, no token/owner rules ────
166
180
  t('empty branding derives no tokens/owners but the IP floor still redacts', () => {
@@ -23,6 +23,11 @@ const PUBLISHED_SURFACE = [
23
23
  'requireBuilder', 'requireRank', 'rankMeetsThreshold', 'requireBfgPrincipal', 'allowBoxScope', 'resolveSession',
24
24
  'requirePermission', // BV1.R105: the authority-atom gate module routes migrate onto (1.19.0; ADR 0151)
25
25
  'pool', 'getPool', 'instanceDbName', 'withTx', 'getBuilderById',
26
+ // added by task 1003575 (ADR 0241 §3): the instance's PROJECT OWNER — its first
27
+ // admitted human builder. Kernel identity like getBuilderById beside it, and
28
+ // published because the artist gate's one escape hatch is the owner's alone, and
29
+ // a module cannot enforce that without being able to ask who the owner is.
30
+ 'getFoundingBuilder',
26
31
  'secretBox', // Gemini-key storage relocated to modules/art-pipeline + its 'art.getBuilderGeminiKeyRow' port (ADR 0102)
27
32
  'grantedScopeCoversRepos', // the create-route credential preflight's scope read (task 1002909); kernel (auth-config.js), unlike db/github-repos which the module reaches via `pool` + its own client
28
33
  'accountExistenceReadRateLimit', // task 1003339 (ADR 0209): the shared per-IP budget both account-existence probes spend from — one instance, not a factory, so the budget is genuinely shared
@@ -40,6 +45,7 @@ const PUBLISHED_SURFACE = [
40
45
  'resolveRotDays', 'projectSettings', // added by task 1003279 (ADR 0232; goal 1000072): the ROT timer + the live knob store behind it. A DIFFERENT question from the stale-claim timer above — days not hours, DB not env, work nobody picked up rather than a builder who went quiet, and it only ever ASKS a human where that one ACTS. Both ride the doorway so no module hardcodes either.
41
46
  'joinabilityMode', // added by task 1003044 (ADR 0194; the carrier after 1.19.374): the project's membership door, one reader for the sign-in gate AND the onboarding module's public POST
42
47
  'projectJoinDoor', // added by task 1002331 (ADR 0182 D6 / ADR 0247): the COMPOSED join/apply door — visibility × join grant × joinability, one answer, so a module reading a single knob cannot re-open a door the other two closed
48
+ 'artistGate', // added by task 1003575 (ADR 0241): the artist gate — off | advisory | strict, plus the pure verdict helpers over it. Two modules ask it two different questions ("file this review?" and "does an open one hold the deploy?") and both must resolve the knob through this one reader, never branding().project
43
49
  'readApplicantProfileFromHub', // added by task 1002972 (privacy spec D7): the reviewer queue's LIVE per-render read of one applicant's hub profile view. NARROW by design — the underlying call is authenticated with this instance's hub client secret, and loadIdpConfig stays OFF the doorway so no module ever holds the credential that speaks for the whole project
44
50
  'enabledDisciplines', // BV1.R81: instance offered-disciplines (1.9.0; onboarding restock backstop)
45
51
  'registerProvider', 'hasProvider', 'resolve', 'resolveOptional', 'listPorts', 'verifyPortsSatisfied',
@@ -61,6 +61,8 @@ ${NEUTRAL_PREFIX}_PLATFORM_VISIBILITY=public
61
61
  ${NEUTRAL_PREFIX}_JOINABILITY=apply
62
62
  ${NEUTRAL_PREFIX}_VISIBILITY=public
63
63
  ${NEUTRAL_PREFIX}_JOIN_GRANT=full
64
+ ${NEUTRAL_PREFIX}_ARTIST_GATE=strict
65
+ ${NEUTRAL_PREFIX}_ARTIST_GATE_SINCE=
64
66
  `;
65
67
 
66
68
  t('replaces an existing KEY= line where it stands; every other line survives byte-for-byte', () => {
@@ -113,7 +115,13 @@ t('is PURE: same input, same output; empty body yields just the lines', () => {
113
115
  t('settingsEnvVarsFor: the runner pushes the SAME pair webEnvBody writes at standup, under the pack prefix', () => {
114
116
  const inst = { slug: 'spike', port: 3010, db_name: 'spike', hosting_shape: 'standalone', target_ref: 'jaxri/spike', settings: { platform_visibility: 'gated' } };
115
117
  const vars = P.settingsEnvVarsFor(inst);
116
- assert.deepEqual(vars, { [`${NEUTRAL_PREFIX}_PLATFORM_VISIBILITY`]: 'gated', [`${NEUTRAL_PREFIX}_JOINABILITY`]: 'apply', [`${NEUTRAL_PREFIX}_VISIBILITY`]: 'public', [`${NEUTRAL_PREFIX}_JOIN_GRANT`]: 'full' });
118
+ assert.deepEqual(vars, {
119
+ [`${NEUTRAL_PREFIX}_PLATFORM_VISIBILITY`]: 'gated', [`${NEUTRAL_PREFIX}_JOINABILITY`]: 'apply',
120
+ [`${NEUTRAL_PREFIX}_VISIBILITY`]: 'public', [`${NEUTRAL_PREFIX}_JOIN_GRANT`]: 'full',
121
+ // the artist gate + its non-retroactivity stamp (task 1003575, ADR 0241) ride
122
+ // the same transport; the stamp is empty until the gate is actually RAISED
123
+ [`${NEUTRAL_PREFIX}_ARTIST_GATE`]: 'strict', [`${NEUTRAL_PREFIX}_ARTIST_GATE_SINCE`]: '',
124
+ });
117
125
  const body = P.webEnvBody(inst, {});
118
126
  for (const [k, v] of Object.entries(vars)) assert.match(body, new RegExp(`^${k}=${v}$`, 'm'), `${k} at standup`);
119
127
  });
@@ -124,10 +132,10 @@ const GATED = { settings: { platform_visibility: 'gated', joinability: 'invite_o
124
132
  const manifestWith = (project) => JSON.stringify({ schema: 'instance-manifest/v1', brand: project === undefined ? {} : { project } });
125
133
 
126
134
  t('true when every pushed key matches brand.project (camelCase, the ENV_OVERRIDES targets)', () => {
127
- assert.equal(P.settingsConsumed(manifestWith({ platformVisibility: 'gated', joinability: 'invite_only', visibility: 'public', joinGrant: 'full' }), GATED), true);
135
+ assert.equal(P.settingsConsumed(manifestWith({ platformVisibility: 'gated', joinability: 'invite_only', visibility: 'public', joinGrant: 'full', artistGate: 'strict' }), GATED), true);
128
136
  });
129
137
  t('false when the instance still reports a different value', () => {
130
- assert.equal(P.settingsConsumed(manifestWith({ platformVisibility: 'public', joinability: 'invite_only', visibility: 'public', joinGrant: 'full' }), GATED), false);
138
+ assert.equal(P.settingsConsumed(manifestWith({ platformVisibility: 'public', joinability: 'invite_only', visibility: 'public', joinGrant: 'full', artistGate: 'strict' }), GATED), false);
131
139
  assert.equal(P.settingsConsumed(manifestWith({ joinability: 'invite_only' }), GATED), false, 'a missing key is a mismatch, not a pass');
132
140
  });
133
141
  t('null (unknown) when the manifest carries no project block — a core that predates task 1003139 — or does not parse', () => {
@@ -145,7 +153,7 @@ t('instanceManifestCmd asks the instance on loopback, like healthzCmd', () => {
145
153
  function recorder({ apply = false, envBody = LIVE_ENV, readFails = false, healthzFails = false, privileged = false,
146
154
  manifest = 'match', restartThrows = false, writeThrows = false } = {}) {
147
155
  const cmds = [], writes = [], events = [], probes = [], cleared = [], logs = [];
148
- const manifestOut = manifest === 'match' ? manifestWith({ platformVisibility: 'gated', joinability: 'invite_only', visibility: 'public', joinGrant: 'full' })
156
+ const manifestOut = manifest === 'match' ? manifestWith({ platformVisibility: 'gated', joinability: 'invite_only', visibility: 'public', joinGrant: 'full', artistGate: 'strict' })
149
157
  : manifest === 'mismatch' ? manifestWith({ platformVisibility: 'public', joinability: 'apply', visibility: 'public' })
150
158
  : manifest === 'none' ? manifestWith(undefined) : 'nope';
151
159
  const exec = (cmd, opts = {}) => {
@@ -214,7 +222,7 @@ await ta('dry-run PLANS the patch: reads nothing real, writes NOTHING (no synthe
214
222
  assert.equal(r.ok, true);
215
223
  assert.equal(r.healthy, null, 'nothing ran, nothing observed');
216
224
  assert.equal(writes.length, 0, 'a dry-run must not plan a two-line overwrite of web.env');
217
- assert.ok(logs.some((l) => /would patch .*_PLATFORM_VISIBILITY, .*_JOINABILITY, .*_VISIBILITY, .*_JOIN_GRANT in \/etc\/spike\/web\.env in place/.test(l)), logs.join('\n'));
225
+ assert.ok(logs.some((l) => /would patch .*_PLATFORM_VISIBILITY, .*_JOINABILITY, .*_VISIBILITY, .*_JOIN_GRANT, .*_ARTIST_GATE, .*_ARTIST_GATE_SINCE in \/etc\/spike\/web\.env in place/.test(l)), logs.join('\n'));
218
226
  assert.ok(cmds.some((c) => /restart spike/.test(c)), 'the restart is still in the plan');
219
227
  assert.ok(logs.some((l) => /dry-run — nothing ran/.test(l)), 'the closing line does not claim a restart happened');
220
228
  });
@@ -164,7 +164,7 @@ await ta('owner reads DEFAULTS (public / apply) on a row with empty settings —
164
164
  reset();
165
165
  const r = await get('/provisioning/instances/18/settings');
166
166
  assert.equal(r.status, 200, r.raw);
167
- assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full' });
167
+ assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict' });
168
168
  // the planet rides beside the settings (task 1003347): no default — null = the platform's own pick
169
169
  assert.deepEqual(r.body.planet, { planet_template: null, planet_accent: null, card_backdrop: null });
170
170
  assert.deepEqual(r.body.options, {
@@ -175,6 +175,10 @@ await ta('owner reads DEFAULTS (public / apply) on a row with empty settings —
175
175
  // what a PUBLIC join grants (task 1002331, ADR 0247) — the fourth policy
176
176
  // axis, offered to the form the same way
177
177
  join_grant: ['view', 'apply', 'full'],
178
+ // the artist gate (task 1003575, ADR 0241) — the fifth policy axis. It is
179
+ // offered to the form like the rest; what makes it different is only its
180
+ // default, which is the owner's ruling rather than today's behaviour.
181
+ artist_gate: ['off', 'advisory', 'strict'],
178
182
  planet_template: [...provisioning.PLANET_TEMPLATES], planet_accent: ['sea', 'terracotta', 'gold-soft', 'sun-lit', 'accent'],
179
183
  // the card's backdrop (task 1003352) is the same kind of key: platform-drawn,
180
184
  // closed list, no default, no push — so it rides the same vocab
@@ -187,14 +191,14 @@ await ta('a stored planet reads back; a name that left the vocabulary reads null
187
191
  const r = await get('/provisioning/instances/18/settings');
188
192
  assert.equal(r.status, 200, r.raw);
189
193
  assert.deepEqual(r.body.planet, { planet_template: 'bead-obsidian-tint', planet_accent: null, card_backdrop: null });
190
- assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full' }, 'the planet is not a setting: the policy read is untouched');
194
+ assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict' }, 'the planet is not a setting: the policy read is untouched');
191
195
  });
192
196
 
193
197
  await ta('stored keys overlay the defaults', async () => {
194
198
  reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', settings: { joinability: 'invite_only' } } });
195
199
  const r = await get('/provisioning/instances/18/settings');
196
200
  assert.equal(r.status, 200, r.raw);
197
- assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'invite_only', visibility: 'public', join_grant: 'full' });
201
+ assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'invite_only', visibility: 'public', join_grant: 'full', artist_gate: 'strict' });
198
202
  });
199
203
 
200
204
  await ta('ANOTHER builder\'s GET → 404 (no existence leak)', async () => {
@@ -222,7 +226,7 @@ await ta('owner changes one key — merged, returned effective, audited api:self
222
226
  reset();
223
227
  const r = await patch('/provisioning/instances/18/settings', { joinability: 'open' });
224
228
  assert.equal(r.status, 200, r.raw);
225
- assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'open', visibility: 'public', join_grant: 'full' }, 'the SAVED value — the response never claims the instance runs it (task 1003140)');
229
+ assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'open', visibility: 'public', join_grant: 'full', artist_gate: 'strict' }, 'the SAVED value — the response never claims the instance runs it (task 1003140)');
226
230
  assert.equal(r.body.push.queued, true, 'an active co-tenant gets the live push (task 1003140)');
227
231
  assert.deepEqual(updateCalls, [{ id: 18, patch: { joinability: 'open' } }], 'exactly the sent key, nothing else');
228
232
  assert.equal(events.length, 1);
@@ -235,7 +239,7 @@ await ta('owner changes both keys in one call', async () => {
235
239
  reset();
236
240
  const r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated', joinability: 'invite_only' });
237
241
  assert.equal(r.status, 200, r.raw);
238
- assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'invite_only', visibility: 'public', join_grant: 'full' });
242
+ assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'invite_only', visibility: 'public', join_grant: 'full', artist_gate: 'strict' });
239
243
  });
240
244
 
241
245
  await ta('the OWN-SCOPE WALL: another builder\'s PATCH → 404 and the write path is never reached', async () => {
@@ -274,7 +278,7 @@ await ta('the owner sets the door to stealth — stored, echoed effective, audit
274
278
  reset();
275
279
  const r = await patch('/provisioning/instances/18/settings', { visibility: 'stealth' });
276
280
  assert.equal(r.status, 200, r.raw);
277
- assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'apply', visibility: 'stealth', join_grant: 'full' });
281
+ assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'apply', visibility: 'stealth', join_grant: 'full', artist_gate: 'strict' });
278
282
  assert.deepEqual(updateCalls, [{ id: 18, patch: { visibility: 'stealth' } }], 'exactly the sent key, nothing else');
279
283
  assert.equal(events[0].actor, 'api:self');
280
284
  assert.match(events[0].detail, /visibility=stealth/);
@@ -334,7 +338,7 @@ await ta('the owner picks a planet: saved, echoed as `planet`, audited — and N
334
338
  const r = await patch('/provisioning/instances/18/settings', { planet_template: 'bead-obsidian-tint', planet_accent: 'sea' });
335
339
  assert.equal(r.status, 200, r.raw);
336
340
  assert.deepEqual(r.body.planet, { planet_template: 'bead-obsidian-tint', planet_accent: 'sea', card_backdrop: null });
337
- assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full' }, 'the policy half is untouched');
341
+ assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict' }, 'the policy half is untouched');
338
342
  assert.deepEqual(updateCalls, [{ id: 18, patch: { planet_template: 'bead-obsidian-tint', planet_accent: 'sea' } }]);
339
343
  assert.equal(r.body.push.queued, false);
340
344
  assert.equal(r.body.push.restart, false, 'an active co-tenant would get a push for a POLICY change — never for the planet');