ai-delivery-workflow 0.2.2 → 0.4.0

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 (361) hide show
  1. package/README.md +45 -14
  2. package/bin/ai-delivery.mjs +181 -6
  3. package/bin/benchmark-autonomy.mjs +217 -0
  4. package/bin/release-check.mjs +382 -0
  5. package/docs/CLI-PARAMETER-REFERENCE.zh-CN.md +346 -0
  6. package/docs/CODEGRAPH-INTEGRATION.zh-CN.md +89 -0
  7. package/docs/DUAL-REPOSITORY-WORKSPACE.zh-CN.md +2 -2
  8. package/docs/EVOLUTION-MAINTENANCE.zh-CN.md +7 -7
  9. package/docs/EVOLUTION-USER-GUIDE.zh-CN.md +2 -2
  10. package/docs/FILE-REFERENCE.zh-CN.md +120 -39
  11. package/docs/MAINTENANCE-VERIFICATION.zh-CN.md +51 -0
  12. package/docs/MOCK-SYSTEM.zh-CN.md +141 -0
  13. package/docs/NPM-RELEASE-MANAGEMENT.zh-CN.md +106 -0
  14. package/docs/PROJECT-MANUAL.zh-CN.md +154 -95
  15. package/docs/PROTOTYPE-MIGRATION.zh-CN.md +164 -0
  16. package/docs/STATE-CLI-MAINTENANCE.zh-CN.md +35 -17
  17. package/docs/STATE-CLI-USER-GUIDE.zh-CN.md +115 -39
  18. package/docs/VIEWER-MAINTENANCE.zh-CN.md +101 -324
  19. package/docs/VIEWER-USER-GUIDE.zh-CN.md +68 -190
  20. package/docs/adr/0001-prototype-first-ui-governance.md +3 -0
  21. package/docs/adr/0002-microcks-as-default-mock-system.md +3 -0
  22. package/docs/adr/0003-workflow-manager-governed-control-surface.md +46 -0
  23. package/docs/adr/0004-operational-carbon-visual-standard.md +9 -0
  24. package/docs/adr/0005-prototype-management-as-routed-pages.md +7 -0
  25. package/docs/agents/domain.md +12 -0
  26. package/docs/agents/issue-tracker.md +27 -0
  27. package/docs/agents/triage-labels.md +11 -0
  28. package/docs/agents/workflow-manager-acceptance-matrix.md +93 -0
  29. package/docs/agents/workflow-manager-agent-interface.md +145 -0
  30. package/docs/agents/workflow-manager-current-state.md +205 -0
  31. package/lib/artifact-files.mjs +47 -0
  32. package/lib/codegraph.mjs +405 -0
  33. package/lib/delivery-state.mjs +3079 -198
  34. package/lib/frontend-guard.mjs +288 -0
  35. package/lib/fs-utils.mjs +53 -0
  36. package/lib/history-anchor.mjs +98 -0
  37. package/lib/mock-scenario-pack.mjs +687 -0
  38. package/lib/mock-system.mjs +897 -0
  39. package/lib/npm-package-result.mjs +11 -0
  40. package/lib/png-evidence.mjs +149 -0
  41. package/lib/project-bootstrap.mjs +96 -55
  42. package/lib/project-installer.mjs +1484 -164
  43. package/lib/project-migration.mjs +1193 -0
  44. package/lib/prototype-migration.mjs +713 -0
  45. package/lib/prototype-page-review.mjs +384 -0
  46. package/lib/prototype-review-target.mjs +334 -0
  47. package/lib/prototype-revision.mjs +812 -0
  48. package/lib/stable-value.mjs +15 -0
  49. package/lib/toml-hooks.mjs +69 -0
  50. package/lib/ui-acceptance.mjs +565 -0
  51. package/lib/visual-standard.mjs +232 -0
  52. package/lib/workflow-manager-contracts.mjs +367 -0
  53. package/lib/workflow-manager-facts.mjs +778 -0
  54. package/lib/workflow-manager-governance.mjs +514 -0
  55. package/lib/workflow-manager-mock.mjs +183 -0
  56. package/lib/workflow-manager-prototypes.mjs +1847 -0
  57. package/lib/workflow-manager-public/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  58. package/lib/workflow-manager-public/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  59. package/lib/workflow-manager-public/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
  60. package/lib/workflow-manager-public/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
  61. package/lib/workflow-manager-public/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
  62. package/lib/workflow-manager-public/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
  63. package/lib/workflow-manager-public/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
  64. package/lib/workflow-manager-public/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
  65. package/lib/workflow-manager-public/assets/index-Chlg6r8f.css +1 -0
  66. package/lib/workflow-manager-public/assets/index-E-hGB5ZD.js +13 -0
  67. package/lib/workflow-manager-public/fonts/OFL-1.1.txt +90 -0
  68. package/lib/workflow-manager-public/index.html +14 -0
  69. package/lib/workflow-manager-systems.mjs +519 -0
  70. package/lib/workflow-manager-transaction.mjs +397 -0
  71. package/lib/workflow-manager.mjs +745 -0
  72. package/package.json +8 -2
  73. package/skills/ai-delivery-assemble-release/agents/openai.yaml +3 -3
  74. package/skills/ai-delivery-assemble-release/assets/release-material-index.csv +1 -1
  75. package/skills/ai-delivery-assemble-release/assets/version-inclusion.csv +1 -1
  76. package/skills/ai-delivery-bootstrap/SKILL.md +4 -4
  77. package/skills/ai-delivery-bootstrap/agents/openai.yaml +3 -3
  78. package/skills/ai-delivery-bootstrap/references/bootstrap-contract.md +4 -2
  79. package/skills/ai-delivery-checkpoint-task/SKILL.md +17 -3
  80. package/skills/ai-delivery-checkpoint-task/agents/openai.yaml +3 -3
  81. package/skills/ai-delivery-checkpoint-task/assets/runtime-template/task.json +7 -1
  82. package/skills/ai-delivery-checkpoint-task/references/checkpoint-contract.md +22 -8
  83. package/skills/ai-delivery-checkpoint-task/scripts/hook-event.mjs +91 -0
  84. package/skills/ai-delivery-checkpoint-task/scripts/task-state.mjs +1086 -51
  85. package/skills/ai-delivery-close-version/SKILL.md +9 -8
  86. package/skills/ai-delivery-close-version/agents/openai.yaml +3 -3
  87. package/skills/ai-delivery-close-version/assets/version-lineage.yaml +1 -1
  88. package/skills/ai-delivery-close-version/references/version-closeout-contract.md +2 -2
  89. package/skills/ai-delivery-define-product/SKILL.md +8 -4
  90. package/skills/ai-delivery-define-product/agents/openai.yaml +4 -4
  91. package/skills/ai-delivery-define-product/references/product-contract.md +2 -2
  92. package/skills/ai-delivery-deploy-production/agents/openai.yaml +3 -3
  93. package/skills/ai-delivery-design-architecture/SKILL.md +9 -5
  94. package/skills/ai-delivery-design-architecture/assets/architecture-template/prototype-architecture-validation.yaml +16 -16
  95. package/skills/ai-delivery-design-architecture/references/architecture-contract.md +1 -1
  96. package/skills/ai-delivery-design-experience/SKILL.md +36 -30
  97. package/skills/ai-delivery-design-experience/agents/openai.yaml +4 -4
  98. package/skills/ai-delivery-design-experience/assets/experience-template/LEGACY-COMPATIBILITY.md +16 -0
  99. package/skills/ai-delivery-design-experience/assets/experience-template/design-reference-selection.yaml +32 -0
  100. package/skills/ai-delivery-design-experience/assets/experience-template/experience-baseline.yaml +2 -2
  101. package/skills/ai-delivery-design-experience/assets/experience-template/formal-ui-confirmation.yaml +16 -3
  102. package/skills/ai-delivery-design-experience/assets/experience-template/frontend-change-scope.yaml +30 -0
  103. package/skills/ai-delivery-design-experience/assets/experience-template/interaction-contract.csv +1 -1
  104. package/skills/ai-delivery-design-experience/assets/experience-template/mock-admin-export.yaml +8 -0
  105. package/skills/ai-delivery-design-experience/assets/experience-template/mock-scenario-mappings.csv +1 -0
  106. package/skills/ai-delivery-design-experience/assets/experience-template/mock-scenario-pack-request.yaml +24 -0
  107. package/skills/ai-delivery-design-experience/assets/experience-template/product-prototype-reconciliation.yaml +2 -1
  108. package/skills/ai-delivery-design-experience/assets/experience-template/prototype-adjustment-log.csv +1 -1
  109. package/skills/ai-delivery-design-experience/assets/experience-template/prototype-component-bindings.csv +1 -0
  110. package/skills/ai-delivery-design-experience/assets/experience-template/prototype-component-candidates.csv +1 -0
  111. package/skills/ai-delivery-design-experience/assets/experience-template/prototype-components.csv +1 -0
  112. package/skills/ai-delivery-design-experience/assets/experience-template/prototype-confirmation.yaml +18 -18
  113. package/skills/ai-delivery-design-experience/assets/experience-template/prototype-data-contracts.csv +1 -0
  114. package/skills/ai-delivery-design-experience/assets/experience-template/prototype-impact-assessment.yaml +5 -5
  115. package/skills/ai-delivery-design-experience/assets/experience-template/prototype-implementation-bindings.csv +1 -0
  116. package/skills/ai-delivery-design-experience/assets/experience-template/prototype-manifest.yaml +2 -0
  117. package/skills/ai-delivery-design-experience/assets/experience-template/prototype-pages.csv +1 -0
  118. package/skills/ai-delivery-design-experience/assets/experience-template/prototype-product-coverage.csv +1 -1
  119. package/skills/ai-delivery-design-experience/assets/experience-template/prototype-review-comments.csv +1 -1
  120. package/skills/ai-delivery-design-experience/assets/experience-template/prototype-review-decision.yaml +13 -12
  121. package/skills/ai-delivery-design-experience/assets/experience-template/prototype-review-scenes.csv +1 -0
  122. package/skills/ai-delivery-design-experience/assets/experience-template/prototype-review-sessions.csv +1 -1
  123. package/skills/ai-delivery-design-experience/assets/experience-template/prototype-review-unit-pages.csv +1 -0
  124. package/skills/ai-delivery-design-experience/assets/experience-template/prototype-review-units.csv +1 -0
  125. package/skills/ai-delivery-design-experience/assets/experience-template/prototype-revision.yaml +88 -0
  126. package/skills/ai-delivery-design-experience/assets/experience-template/prototype-routes.csv +1 -0
  127. package/skills/ai-delivery-design-experience/assets/experience-template/prototype-set.yaml +2 -0
  128. package/skills/ai-delivery-design-experience/assets/experience-template/prototype-source-inspection-snapshots.csv +1 -0
  129. package/skills/ai-delivery-design-experience/assets/experience-template/prototype-terminals.csv +1 -0
  130. package/skills/ai-delivery-design-experience/assets/experience-template/prototype-tool-candidates.csv +1 -1
  131. package/skills/ai-delivery-design-experience/assets/experience-template/prototype-tool-selection.yaml +2 -3
  132. package/skills/ai-delivery-design-experience/assets/experience-template/prototype-traceability.csv +1 -1
  133. package/skills/ai-delivery-design-experience/assets/experience-template/prototype-trigger-conditions.csv +1 -0
  134. package/skills/ai-delivery-design-experience/assets/experience-template/prototype.yaml +6 -0
  135. package/skills/ai-delivery-design-experience/assets/experience-template/ui-framework-selection.yaml +17 -1
  136. package/skills/ai-delivery-design-experience/assets/experience-template/ui-handoff.yaml +6 -5
  137. package/skills/ai-delivery-design-experience/assets/experience-template/ui-mapping.csv +1 -0
  138. package/skills/ai-delivery-design-experience/assets/experience-template/visual-direction.yaml +4 -0
  139. package/skills/ai-delivery-design-experience/assets/experience-template/visual-reference-manifest.yaml +23 -0
  140. package/skills/ai-delivery-design-experience/assets/experience-template/visual-reference.html +163 -0
  141. package/skills/ai-delivery-design-experience/assets/experience-template/visual-system-confirmation.yaml +11 -0
  142. package/skills/ai-delivery-design-experience/references/experience-contract.md +26 -27
  143. package/skills/ai-delivery-design-experience/references/prototype-management-contract.md +42 -21
  144. package/skills/ai-delivery-design-tests/SKILL.md +13 -7
  145. package/skills/ai-delivery-design-tests/assets/test-design-template/prototype-test-scope.csv +1 -1
  146. package/skills/ai-delivery-design-tests/assets/test-design-template/prototype-test-validation.yaml +17 -16
  147. package/skills/ai-delivery-design-tests/references/test-design-contract.md +7 -5
  148. package/skills/ai-delivery-develop-iteration/SKILL.md +18 -11
  149. package/skills/ai-delivery-develop-iteration/agents/openai.yaml +3 -3
  150. package/skills/ai-delivery-develop-iteration/assets/development-template/candidate-manifest.yaml +36 -0
  151. package/skills/ai-delivery-develop-iteration/assets/development-template/task-prototype-traceability.csv +1 -1
  152. package/skills/ai-delivery-develop-iteration/references/development-contract.md +5 -3
  153. package/skills/ai-delivery-evolve-workflow/agents/openai.yaml +3 -3
  154. package/skills/ai-delivery-execute-test/SKILL.md +6 -2
  155. package/skills/ai-delivery-execute-test/agents/openai.yaml +4 -4
  156. package/skills/ai-delivery-execute-work-package/SKILL.md +10 -3
  157. package/skills/ai-delivery-execute-work-package/agents/openai.yaml +4 -4
  158. package/skills/ai-delivery-execute-work-package/references/work-package-contract.md +1 -1
  159. package/skills/ai-delivery-manage-git/agents/openai.yaml +3 -3
  160. package/skills/ai-delivery-manage-standards/agents/openai.yaml +4 -4
  161. package/skills/ai-delivery-orchestrate/SKILL.md +47 -29
  162. package/skills/ai-delivery-orchestrate/agents/openai.yaml +3 -3
  163. package/skills/ai-delivery-orchestrate/assets/project-template/.workflow/control/migrations/.gitkeep +0 -0
  164. package/skills/ai-delivery-orchestrate/assets/project-template/.workflow/control/repairs/.gitkeep +0 -0
  165. package/skills/ai-delivery-orchestrate/assets/project-template/.workflow/delivery/project.yaml +1 -0
  166. package/skills/ai-delivery-orchestrate/assets/project-template/.workflow/delivery/workflow-state.yaml +2 -0
  167. package/skills/ai-delivery-orchestrate/assets/project-template/.workflow/tools/frontend-guard/frontend-guard.mjs +50 -0
  168. package/skills/ai-delivery-orchestrate/assets/project-template/.workflow/tools/hooks/hook-event.mjs +91 -0
  169. package/skills/ai-delivery-orchestrate/assets/project-template/.workflow/tools/manager/manager.mjs +64 -0
  170. package/skills/ai-delivery-orchestrate/assets/project-template/.workflow/tools/manager/start.cmd +4 -0
  171. package/skills/ai-delivery-orchestrate/assets/project-template/.workflow/tools/manager/start.ps1 +2 -0
  172. package/skills/ai-delivery-orchestrate/assets/project-template/.workflow/tools/{viewer → manager}/start.sh +1 -1
  173. package/skills/ai-delivery-orchestrate/assets/project-template/.workflow/tools/mock/mock.mjs +50 -0
  174. package/skills/ai-delivery-orchestrate/assets/project-template/.workflow/tools/ui-acceptance/ui-acceptance.mjs +37 -0
  175. package/skills/ai-delivery-orchestrate/assets/project-template/AGENTS.md +32 -6
  176. package/skills/ai-delivery-orchestrate/assets/project-template/gitignore.template +1 -0
  177. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/CONTRIBUTING.md +25 -0
  178. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/LICENSE +21 -0
  179. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/README.md +250 -0
  180. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/UPSTREAM.yaml +19 -0
  181. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/airbnb/DESIGN.md +545 -0
  182. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/airbnb/README.md +5 -0
  183. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/airtable/DESIGN.md +554 -0
  184. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/airtable/README.md +5 -0
  185. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/apple/DESIGN.md +562 -0
  186. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/apple/README.md +5 -0
  187. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/binance/DESIGN.md +634 -0
  188. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/binance/README.md +5 -0
  189. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/bmw/DESIGN.md +544 -0
  190. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/bmw/README.md +5 -0
  191. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/bmw-m/DESIGN.md +503 -0
  192. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/bmw-m/README.md +5 -0
  193. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/bugatti/DESIGN.md +454 -0
  194. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/bugatti/README.md +5 -0
  195. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/cal/DESIGN.md +542 -0
  196. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/cal/README.md +5 -0
  197. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/claude/DESIGN.md +589 -0
  198. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/claude/README.md +5 -0
  199. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/clay/DESIGN.md +541 -0
  200. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/clay/README.md +5 -0
  201. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/clickhouse/DESIGN.md +544 -0
  202. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/clickhouse/README.md +5 -0
  203. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/cohere/DESIGN.md +451 -0
  204. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/cohere/README.md +5 -0
  205. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/coinbase/DESIGN.md +570 -0
  206. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/coinbase/README.md +5 -0
  207. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/composio/DESIGN.md +506 -0
  208. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/composio/README.md +5 -0
  209. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/cursor/DESIGN.md +537 -0
  210. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/cursor/README.md +5 -0
  211. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/dell-1996/DESIGN.md +632 -0
  212. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/dell-1996/README.md +5 -0
  213. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/elevenlabs/DESIGN.md +504 -0
  214. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/elevenlabs/README.md +5 -0
  215. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/expo/DESIGN.md +526 -0
  216. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/expo/README.md +5 -0
  217. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/ferrari/DESIGN.md +531 -0
  218. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/ferrari/README.md +5 -0
  219. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/figma/DESIGN.md +578 -0
  220. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/figma/README.md +5 -0
  221. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/framer/DESIGN.md +544 -0
  222. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/framer/README.md +5 -0
  223. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/hashicorp/DESIGN.md +575 -0
  224. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/hashicorp/README.md +5 -0
  225. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/hp/DESIGN.md +670 -0
  226. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/hp/README.md +5 -0
  227. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/ibm/DESIGN.md +550 -0
  228. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/ibm/README.md +5 -0
  229. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/intercom/DESIGN.md +546 -0
  230. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/intercom/README.md +5 -0
  231. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/kraken/DESIGN.md +125 -0
  232. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/kraken/README.md +5 -0
  233. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/lamborghini/DESIGN.md +288 -0
  234. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/lamborghini/README.md +5 -0
  235. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/linear.app/DESIGN.md +548 -0
  236. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/linear.app/README.md +5 -0
  237. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/lovable/DESIGN.md +298 -0
  238. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/lovable/README.md +5 -0
  239. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/mastercard/DESIGN.md +365 -0
  240. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/mastercard/README.md +5 -0
  241. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/meta/DESIGN.md +683 -0
  242. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/meta/README.md +5 -0
  243. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/minimax/DESIGN.md +746 -0
  244. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/minimax/README.md +5 -0
  245. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/mintlify/DESIGN.md +852 -0
  246. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/mintlify/README.md +5 -0
  247. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/miro/DESIGN.md +825 -0
  248. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/miro/README.md +5 -0
  249. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/mistral.ai/DESIGN.md +773 -0
  250. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/mistral.ai/README.md +5 -0
  251. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/mongodb/DESIGN.md +767 -0
  252. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/mongodb/README.md +5 -0
  253. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/nike/DESIGN.md +575 -0
  254. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/nike/README.md +5 -0
  255. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/nintendo-2001/DESIGN.md +649 -0
  256. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/nintendo-2001/README.md +5 -0
  257. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/notion/DESIGN.md +821 -0
  258. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/notion/README.md +5 -0
  259. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/nvidia/DESIGN.md +640 -0
  260. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/nvidia/README.md +5 -0
  261. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/ollama/DESIGN.md +539 -0
  262. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/ollama/README.md +5 -0
  263. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/opencode.ai/DESIGN.md +521 -0
  264. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/opencode.ai/README.md +5 -0
  265. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/pinterest/DESIGN.md +597 -0
  266. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/pinterest/README.md +5 -0
  267. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/playstation/DESIGN.md +661 -0
  268. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/playstation/README.md +5 -0
  269. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/posthog/DESIGN.md +690 -0
  270. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/posthog/README.md +5 -0
  271. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/raycast/DESIGN.md +669 -0
  272. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/raycast/README.md +5 -0
  273. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/renault/DESIGN.md +589 -0
  274. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/renault/README.md +5 -0
  275. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/replicate/DESIGN.md +616 -0
  276. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/replicate/README.md +5 -0
  277. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/resend/DESIGN.md +585 -0
  278. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/resend/README.md +5 -0
  279. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/revolut/DESIGN.md +636 -0
  280. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/revolut/README.md +5 -0
  281. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/runwayml/DESIGN.md +244 -0
  282. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/runwayml/README.md +5 -0
  283. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/sanity/DESIGN.md +357 -0
  284. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/sanity/README.md +5 -0
  285. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/sentry/DESIGN.md +551 -0
  286. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/sentry/README.md +5 -0
  287. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/shopify/DESIGN.md +516 -0
  288. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/shopify/README.md +5 -0
  289. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/slack/DESIGN.md +482 -0
  290. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/spacex/DESIGN.md +363 -0
  291. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/spacex/README.md +5 -0
  292. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/spotify/DESIGN.md +246 -0
  293. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/spotify/README.md +5 -0
  294. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/starbucks/DESIGN.md +580 -0
  295. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/starbucks/README.md +5 -0
  296. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/stripe/DESIGN.md +487 -0
  297. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/stripe/README.md +5 -0
  298. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/supabase/DESIGN.md +462 -0
  299. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/supabase/README.md +5 -0
  300. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/superhuman/DESIGN.md +448 -0
  301. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/superhuman/README.md +5 -0
  302. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/tesla/DESIGN.md +286 -0
  303. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/tesla/README.md +5 -0
  304. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/theverge/DESIGN.md +339 -0
  305. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/theverge/README.md +5 -0
  306. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/together.ai/DESIGN.md +633 -0
  307. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/together.ai/README.md +5 -0
  308. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/uber/DESIGN.md +636 -0
  309. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/uber/README.md +5 -0
  310. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/vercel/DESIGN.md +736 -0
  311. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/vercel/README.md +5 -0
  312. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/vodafone/DESIGN.md +538 -0
  313. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/vodafone/README.md +5 -0
  314. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/voltagent/DESIGN.md +521 -0
  315. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/voltagent/README.md +5 -0
  316. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/warp/DESIGN.md +526 -0
  317. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/warp/README.md +5 -0
  318. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/webflow/DESIGN.md +588 -0
  319. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/webflow/README.md +5 -0
  320. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/wired/DESIGN.md +497 -0
  321. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/wired/README.md +5 -0
  322. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/wise/DESIGN.md +544 -0
  323. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/wise/README.md +5 -0
  324. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/x.ai/DESIGN.md +465 -0
  325. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/x.ai/README.md +5 -0
  326. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/zapier/DESIGN.md +537 -0
  327. package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/zapier/README.md +5 -0
  328. package/skills/ai-delivery-orchestrate/references/formal-state-contract.md +65 -20
  329. package/skills/ai-delivery-orchestrate/references/workflow-model.md +7 -5
  330. package/skills/ai-delivery-orchestrate-release/SKILL.md +2 -2
  331. package/skills/ai-delivery-orchestrate-release/agents/openai.yaml +4 -4
  332. package/skills/ai-delivery-orchestrate-release/references/release-workflow-contract.md +1 -1
  333. package/skills/ai-delivery-plan-iteration/SKILL.md +11 -4
  334. package/skills/ai-delivery-plan-iteration/agents/openai.yaml +4 -4
  335. package/skills/ai-delivery-plan-iteration/assets/planning-template/scope-manifest.yaml +17 -0
  336. package/skills/ai-delivery-plan-iteration/assets/planning-template/slice-prototype-traceability.csv +1 -1
  337. package/skills/ai-delivery-plan-iteration/references/planning-contract.md +8 -2
  338. package/skills/ai-delivery-prepare-platform/SKILL.md +5 -1
  339. package/skills/ai-delivery-prepare-platform/agents/openai.yaml +4 -4
  340. package/skills/ai-delivery-prepare-release/agents/openai.yaml +4 -4
  341. package/skills/ai-delivery-review-change/SKILL.md +14 -4
  342. package/skills/ai-delivery-review-change/agents/openai.yaml +4 -4
  343. package/skills/ai-delivery-review-change/references/review-contract.md +6 -0
  344. package/skills/ai-delivery-triage-failure/SKILL.md +5 -1
  345. package/skills/ai-delivery-triage-failure/agents/openai.yaml +4 -4
  346. package/skills/ai-delivery-validate-artifacts/SKILL.md +6 -3
  347. package/skills/ai-delivery-validate-artifacts/agents/openai.yaml +4 -4
  348. package/skills/ai-delivery-validate-artifacts/references/artifact-contract.md +13 -13
  349. package/skills/ai-delivery-verify-candidate/SKILL.md +13 -6
  350. package/skills/ai-delivery-verify-candidate/agents/openai.yaml +3 -3
  351. package/skills/ai-delivery-verify-candidate/assets/verification-template/ui-acceptance-request.yaml +19 -0
  352. package/skills/ai-delivery-verify-candidate/assets/verification-template/ui-acceptance-results.yaml +53 -0
  353. package/skills/ai-delivery-verify-candidate/references/candidate-verification-contract.md +10 -2
  354. package/skills/ai-delivery-verify-production/agents/openai.yaml +3 -3
  355. package/skills/ai-delivery-design-experience/assets/experience-template/low-fidelity-confirmation.yaml +0 -32
  356. package/skills/ai-delivery-orchestrate/assets/project-template/.workflow/tools/viewer/public/app.js +0 -546
  357. package/skills/ai-delivery-orchestrate/assets/project-template/.workflow/tools/viewer/public/index.html +0 -199
  358. package/skills/ai-delivery-orchestrate/assets/project-template/.workflow/tools/viewer/public/styles.css +0 -529
  359. package/skills/ai-delivery-orchestrate/assets/project-template/.workflow/tools/viewer/server.mjs +0 -780
  360. package/skills/ai-delivery-orchestrate/assets/project-template/.workflow/tools/viewer/start.cmd +0 -4
  361. package/skills/ai-delivery-orchestrate/assets/project-template/.workflow/tools/viewer/start.ps1 +0 -2
@@ -1,9 +1,24 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import zlib from "node:zlib";
5
+ import {
6
+ historyAnchorId,
7
+ readVerifiedHistoryAnchor,
8
+ structuredDocumentChecksum,
9
+ } from "./history-anchor.mjs";
10
+ import { assertPathInside, atomicWrite } from "./fs-utils.mjs";
4
11
 
5
- const NODE_IDS = new Set([
12
+ const SEVEN_NODE_DEVELOPMENT_IDS = [
6
13
  "00-bootstrap",
14
+ "01-product-shaping",
15
+ "02-solution-design",
16
+ "03-delivery-readiness",
17
+ "04-implementation",
18
+ "05-candidate-assurance",
19
+ "06-version-closeout",
20
+ ];
21
+ const LEGACY_DEVELOPMENT_IDS = [
7
22
  "01-product-discovery",
8
23
  "02-product-definition",
9
24
  "03-architecture-initial",
@@ -16,11 +31,15 @@ const NODE_IDS = new Set([
16
31
  "10-change-review",
17
32
  "11-candidate-verification",
18
33
  "12-version-closeout",
34
+ ];
35
+ const LEGACY_PRODUCTION_IDS = [
19
36
  "12-release-preparation",
20
37
  "13-production-approval",
21
38
  "14-production-deployment",
22
39
  "15-production-verification",
23
40
  "16-version-archive",
41
+ ];
42
+ const RELEASE_NODE_IDS = [
24
43
  "R00-release-request",
25
44
  "R01-version-selection",
26
45
  "R02-release-eligibility",
@@ -32,10 +51,21 @@ const NODE_IDS = new Set([
32
51
  "R08-production-deployment",
33
52
  "R09-production-verification",
34
53
  "R10-release-archive",
54
+ ];
55
+ const NODE_IDS = new Set([
56
+ ...SEVEN_NODE_DEVELOPMENT_IDS,
57
+ ...LEGACY_DEVELOPMENT_IDS,
58
+ ...LEGACY_PRODUCTION_IDS,
59
+ ...RELEASE_NODE_IDS,
35
60
  ]);
61
+ const SEVEN_NODE_DEVELOPMENT_ID_SET = new Set(SEVEN_NODE_DEVELOPMENT_IDS);
62
+ const LEGACY_DEVELOPMENT_ID_SET = new Set(LEGACY_DEVELOPMENT_IDS);
63
+ const RELEASE_NODE_ID_SET = new Set(RELEASE_NODE_IDS);
36
64
  const ARTIFACT_STATUSES = new Set([
37
65
  "draft",
38
66
  "review-draft",
67
+ "submitted",
68
+ "rejected",
39
69
  "pending-approval",
40
70
  "approved",
41
71
  "valid",
@@ -54,14 +84,24 @@ const ARTIFACT_STATUSES = new Set([
54
84
  "rolled-back",
55
85
  "archived",
56
86
  ]);
87
+ const WORKING_DRAFT_STATUSES = new Set(["draft", "review-draft"]);
57
88
  const GATE_DECISIONS = new Set(["approved", "rejected", "blocked", "not-required"]);
58
89
  const UX_GATE_IDS = new Set(["UX-LF", "UX-VS", "UX-UI"]);
90
+ const RETIRED_GATE_IDS = new Set(["UX-LF"]);
91
+ const UX_GATE_NODES = new Set(["02-solution-design", "04-experience-design"]);
92
+ const PRODUCT_TERMINAL_NODES = new Set(["01-product-shaping", "02-product-definition"]);
59
93
  const SUCCESSFUL_ARTIFACT_STATUSES = new Set([
60
94
  "approved", "valid", "frozen", "immutable", "passed", "deployed", "released",
61
95
  "release-ready", "included-in-release", "archived",
62
96
  ]);
63
97
  const NODE_RULES = new Map([
64
98
  ["00-bootstrap", { scope: "development", prerequisites: [], gates: [] }],
99
+ ["01-product-shaping", { scope: "development", prerequisites: ["00-bootstrap"], gates: ["GATE-A", "GATE-B"] }],
100
+ ["02-solution-design", { scope: "development", prerequisites: ["01-product-shaping"], gates: ["UX-VS", "UX-UI"] }],
101
+ ["03-delivery-readiness", { scope: "development", prerequisites: ["02-solution-design"], gates: [] }],
102
+ ["04-implementation", { scope: "development", prerequisites: ["03-delivery-readiness"], gates: [] }],
103
+ ["05-candidate-assurance", { scope: "development", prerequisites: ["04-implementation"], gates: [] }],
104
+ ["06-version-closeout", { scope: "development", prerequisites: ["05-candidate-assurance"], gates: [] }],
65
105
  ["01-product-discovery", { scope: "development", prerequisites: ["00-bootstrap"], gates: ["GATE-A"] }],
66
106
  ["02-product-definition", { scope: "development", prerequisites: ["01-product-discovery"], gates: ["GATE-B"] }],
67
107
  ["03-architecture-initial", { scope: "development", prerequisites: ["02-product-definition"], gates: [] }],
@@ -86,7 +126,27 @@ const NODE_RULES = new Map([
86
126
  ["R09-production-verification", { scope: "release", prerequisites: ["R08-production-deployment"], gates: [] }],
87
127
  ["R10-release-archive", { scope: "release", prerequisites: ["R09-production-verification"], gates: [] }],
88
128
  ]);
89
- const WRITABLE_NODE_IDS = new Set(NODE_RULES.keys());
129
+ const WRITABLE_NODE_IDS = new Set([...SEVEN_NODE_DEVELOPMENT_IDS, ...RELEASE_NODE_IDS]);
130
+ const SCOPED_COMPLETION_NODES = new Set([
131
+ "03-delivery-readiness", "04-implementation", "05-candidate-assurance", "06-version-closeout",
132
+ "07-iteration-planning", "08-test-design", "09-development",
133
+ "10-change-review", "11-candidate-verification", "12-version-closeout",
134
+ ]);
135
+ const CANDIDATE_REQUIRED_NODES = new Set([
136
+ "04-implementation", "05-candidate-assurance", "06-version-closeout",
137
+ "11-candidate-verification", "12-version-closeout",
138
+ ]);
139
+ const CANDIDATE_BOUND_NODES = new Set(["05-candidate-assurance", "06-version-closeout"]);
140
+ const SCOPE_FREEZE_NODES = new Set(["03-delivery-readiness", "07-iteration-planning"]);
141
+ const SLICE_BUDGET_LIMITS = new Map([
142
+ ["logical-tasks", 8],
143
+ ["active-hours", 8],
144
+ ["wall-clock-hours", 24],
145
+ ["blocking-repair-rounds", 2],
146
+ ]);
147
+ const LOCK_WAIT_CELL = new Int32Array(new SharedArrayBuffer(4));
148
+ const ACTIVE_LOCK_WAIT_TIMEOUT_MS = 30_000;
149
+ const LOCK_METADATA_INITIALIZATION_TIMEOUT_MS = 1_000;
90
150
 
91
151
  function parseArgs(argv) {
92
152
  const positional = [];
@@ -120,6 +180,13 @@ function lifecycleDescriptor(command, positional, flags) {
120
180
  if (command === "gate" && new Set(["request", "decide"]).has(positional[1])) {
121
181
  return { kind: "gate", action: `gate-${positional[1]}`, subject_id: optionalFlag(flags, "--gate-id") };
122
182
  }
183
+ if (command === "scope" && new Set(["freeze", "amend", "warn"]).has(positional[1])) {
184
+ return {
185
+ kind: "scope",
186
+ action: `scope-${positional[1]}`,
187
+ subject_id: optionalFlag(flags, "--manifest-id") || optionalFlag(flags, "--iteration-id"),
188
+ };
189
+ }
123
190
  if (command !== "transition") return null;
124
191
  const transition = optionalFlag(flags, "--event") || "transition";
125
192
  if (transition.startsWith("node-")) {
@@ -135,9 +202,62 @@ function lifecycleDescriptor(command, positional, flags) {
135
202
  };
136
203
  }
137
204
 
205
+ function assertNotLegacyDevelopmentNode(node) {
206
+ if (LEGACY_DEVELOPMENT_ID_SET.has(node)) {
207
+ throw new Error(`Legacy workflow node is read-only: ${node}`);
208
+ }
209
+ return node;
210
+ }
211
+
212
+ function assertGateContractWritable(gateId) {
213
+ if (RETIRED_GATE_IDS.has(gateId)) {
214
+ throw new Error(`${gateId} Gate contract is retired; historical records remain read-only.`);
215
+ }
216
+ }
217
+
218
+ function assertRetiredGateContract(gateId) {
219
+ if (!RETIRED_GATE_IDS.has(gateId)) {
220
+ throw new Error(`Gate contract is not retired: ${gateId}.`);
221
+ }
222
+ }
223
+
224
+ function assertNoLegacyDevelopmentWrite(files, command, positional, flags, options) {
225
+ let node = null;
226
+ if ((command === "artifact" && positional[1] === "register")
227
+ || (command === "gate" && positional[1] === "request")) {
228
+ node = optionalFlag(flags, "--node");
229
+ } else if (command === "gate" && positional[1] === "decide") {
230
+ const gateId = optionalFlag(flags, "--gate-id");
231
+ if (gateId) {
232
+ const state = normalizeWorkflowState(readYaml(files.workflowState, options.yaml, "workflow-state.yaml"));
233
+ const pending = [state.development_state, state.release_state]
234
+ .flatMap((scope) => scope.pending_gates)
235
+ .map((gate) => typeof gate === "string" ? { gate_id: gate, node: null } : gate)
236
+ .find((gate) => gate?.gate_id === gateId);
237
+ node = pending?.node || null;
238
+ }
239
+ } else if (command === "transition") {
240
+ node = optionalFlag(flags, "--node");
241
+ const event = optionalFlag(flags, "--event");
242
+ if (!node && new Set(["candidate-selected", "version-closed"]).has(event)) {
243
+ const artifactId = optionalFlag(flags, "--artifact-id");
244
+ if (artifactId) {
245
+ const registry = normalizeArtifactRegistry(
246
+ readYaml(files.artifactRegistry, options.yaml, "artifact-registry.yaml"),
247
+ );
248
+ node = registry.artifacts.find((artifact) =>
249
+ (artifact.artifact_id || artifact.id) === artifactId)?.node || null;
250
+ }
251
+ }
252
+ }
253
+ assertNotLegacyDevelopmentNode(node);
254
+ }
255
+
138
256
  function dispatchLifecycle(options, files, descriptor, phase, flags, result = null, error = null) {
139
257
  if (!descriptor || typeof options.onLifecycleEvent !== "function") return { decision: "pass" };
140
- const revision = optionalFlag(flags, "--expected-revision") || "none";
258
+ const revision = optionalFlag(flags, "--expected-scope-revision")
259
+ || optionalFlag(flags, "--expected-revision")
260
+ || "none";
141
261
  const identity = descriptor.subject_id || "unspecified";
142
262
  const idempotencyKey = `formal-state-${descriptor.kind}-${phase}-${descriptor.action}-${identity}-r${revision}`;
143
263
  const event = {
@@ -179,13 +299,6 @@ function readYaml(file, yaml, label) {
179
299
  return value;
180
300
  }
181
301
 
182
- function atomicWrite(file, content) {
183
- fs.mkdirSync(path.dirname(file), { recursive: true });
184
- const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
185
- fs.writeFileSync(temporary, content, "utf8");
186
- fs.renameSync(temporary, file);
187
- }
188
-
189
302
  function isInside(candidate, root) {
190
303
  const relative = path.relative(path.resolve(root), path.resolve(candidate));
191
304
  return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
@@ -195,6 +308,12 @@ function sha256(value) {
195
308
  return crypto.createHash("sha256").update(value).digest("hex");
196
309
  }
197
310
 
311
+ export function stableValue(value) {
312
+ if (Array.isArray(value)) return value.map(stableValue);
313
+ if (!value || typeof value !== "object") return value;
314
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stableValue(value[key])]));
315
+ }
316
+
198
317
  function requiredFlag(flags, name) {
199
318
  const raw = flags.get(name);
200
319
  const value = Array.isArray(raw) ? raw.at(-1) : raw;
@@ -222,30 +341,84 @@ function artifactStatus(flags) {
222
341
  return status;
223
342
  }
224
343
 
225
- function expectedRevision(flags) {
226
- const raw = requiredFlag(flags, "--expected-revision");
344
+ function nonNegativeRevisionFlag(flags, name) {
345
+ const raw = requiredFlag(flags, name);
227
346
  const value = Number(raw);
228
347
  if (!Number.isInteger(value) || value < 0) {
229
- throw new Error("--expected-revision must be a non-negative integer.");
348
+ throw new Error(`${name} must be a non-negative integer.`);
230
349
  }
231
350
  return value;
232
351
  }
233
352
 
234
- function withLock(files, stream, action) {
353
+ function expectedRevision(flags) {
354
+ return nonNegativeRevisionFlag(flags, "--expected-revision");
355
+ }
356
+
357
+ function expectedScopeRevision(flags) {
358
+ if (!flags.has("--expected-scope-revision")) return null;
359
+ return nonNegativeRevisionFlag(flags, "--expected-scope-revision");
360
+ }
361
+
362
+ function assertWorkflowRevision(flags, state, scopeName = null) {
363
+ const scopedExpected = expectedScopeRevision(flags);
364
+ if (scopedExpected !== null) {
365
+ if (flags.has("--expected-revision")) {
366
+ throw new Error("Use either --expected-scope-revision or --expected-revision, not both.");
367
+ }
368
+ if (!scopeName) throw new Error("A workflow scope is required for --expected-scope-revision.");
369
+ const actual = scopeState(state, scopeName).revision;
370
+ if (actual !== scopedExpected) {
371
+ throw new Error(`${scopeName} scope revision conflict: expected ${scopedExpected}, actual ${actual}.`);
372
+ }
373
+ return;
374
+ }
375
+ const expected = expectedRevision(flags);
376
+ if (state.revision !== expected) {
377
+ throw new Error(`Workflow state revision conflict: expected ${expected}, actual ${state.revision}.`);
378
+ }
379
+ }
380
+
381
+ function pauseForLockRetry() {
382
+ Atomics.wait(LOCK_WAIT_CELL, 0, 0, 10);
383
+ }
384
+
385
+ function withLock(files, stream, action, lockOptions = {}) {
386
+ const recover = lockOptions.recover !== false;
387
+ const waitForActive = lockOptions.waitForActive === true;
388
+ const maximumAttempts = waitForActive ? Number.POSITIVE_INFINITY : 3;
389
+ const waitDeadline = waitForActive
390
+ ? Date.now() + (lockOptions.waitTimeoutMs ?? ACTIVE_LOCK_WAIT_TIMEOUT_MS)
391
+ : null;
392
+ // Read-only callers check both sides of lock acquisition so a stale journal is actionable
393
+ // and a writer that wins the race cannot expose a partial transaction.
394
+ if (!recover) assertNoPendingTransaction(files);
235
395
  fs.mkdirSync(files.lockRoot, { recursive: true });
236
396
  const lockFile = path.join(files.lockRoot, "formal-state.lock");
237
397
  let descriptor;
238
- try {
239
- descriptor = fs.openSync(lockFile, "wx");
240
- fs.writeFileSync(descriptor, `${JSON.stringify({
241
- pid: process.pid,
242
- created_at: new Date().toISOString(),
243
- })}\n`);
244
- } catch (error) {
245
- if (error.code === "EEXIST") throw new Error(`Formal state is locked while updating: ${stream}`);
246
- throw error;
398
+ for (let attempt = 0; attempt < maximumAttempts && descriptor === undefined; attempt += 1) {
399
+ try {
400
+ descriptor = fs.openSync(lockFile, "wx");
401
+ fs.writeFileSync(descriptor, `${JSON.stringify({
402
+ pid: process.pid,
403
+ stream,
404
+ created_at: new Date().toISOString(),
405
+ })}\n`);
406
+ } catch (error) {
407
+ if (error.code !== "EEXIST") throw error;
408
+ if (recover && removeAbandonedLock(lockFile)) continue;
409
+ if (waitForActive && isWaitableFormalLock(lockFile) && Date.now() < waitDeadline) {
410
+ pauseForLockRetry();
411
+ continue;
412
+ }
413
+ throw new Error(`Formal state is locked while updating: ${stream}`);
414
+ }
415
+ }
416
+ if (descriptor === undefined) {
417
+ throw new Error(`Formal state lock could not be acquired: ${stream}`);
247
418
  }
248
419
  try {
420
+ if (recover) recoverPendingTransaction(files);
421
+ else assertNoPendingTransaction(files);
249
422
  return action();
250
423
  } finally {
251
424
  if (descriptor !== undefined) fs.closeSync(descriptor);
@@ -253,6 +426,50 @@ function withLock(files, stream, action) {
253
426
  }
254
427
  }
255
428
 
429
+ function isWaitableFormalLock(lockFile) {
430
+ try {
431
+ const content = fs.readFileSync(lockFile, "utf8");
432
+ try {
433
+ const owner = JSON.parse(content);
434
+ return typeof owner.stream === "string" && owner.stream.length > 0;
435
+ } catch {
436
+ const ageMs = Date.now() - fs.statSync(lockFile).mtimeMs;
437
+ return ageMs >= 0 && ageMs < LOCK_METADATA_INITIALIZATION_TIMEOUT_MS;
438
+ }
439
+ } catch (error) {
440
+ // The owner may release the lock between our failed open and metadata read.
441
+ return error.code === "ENOENT";
442
+ }
443
+ }
444
+
445
+ function isAbandonedLock(lockFile) {
446
+ try {
447
+ const owner = JSON.parse(fs.readFileSync(lockFile, "utf8"));
448
+ if (!Number.isInteger(owner.pid) || owner.pid <= 0) return false;
449
+ try {
450
+ process.kill(owner.pid, 0);
451
+ return false;
452
+ } catch (error) {
453
+ return error.code === "ESRCH";
454
+ }
455
+ } catch {
456
+ return false;
457
+ }
458
+ }
459
+
460
+ function removeAbandonedLock(lockFile) {
461
+ if (!isAbandonedLock(lockFile)) return false;
462
+ const abandoned = `${lockFile}.${process.pid}.${crypto.randomUUID()}.stale`;
463
+ try {
464
+ fs.renameSync(lockFile, abandoned);
465
+ } catch (error) {
466
+ if (error.code === "ENOENT") return true;
467
+ throw error;
468
+ }
469
+ fs.rmSync(abandoned, { force: true });
470
+ return true;
471
+ }
472
+
256
473
  function statePaths(projectRoot) {
257
474
  const root = path.resolve(projectRoot);
258
475
  const workflow = path.join(root, ".workflow");
@@ -266,7 +483,13 @@ function statePaths(projectRoot) {
266
483
  artifactRegistry: path.join(delivery, "artifact-registry.yaml"),
267
484
  events: path.join(delivery, "state-events.jsonl"),
268
485
  eventShards: path.join(workflow, "control", "events"),
486
+ historySnapshots: path.join(workflow, "control", "snapshots", "formal-state"),
487
+ artifactHistoryArchives: path.join(workflow, "control", "archives", "history", "artifact-registry"),
488
+ artifactRetention: path.join(workflow, "control", "retention", "artifact-registry"),
489
+ compactRuntime: path.join(delivery, "runtime", "formal-state-compact"),
490
+ pruneRuntime: path.join(delivery, "runtime", "formal-state-prune"),
269
491
  lockRoot: path.join(delivery, "runtime", "formal-state-locks"),
492
+ transactionJournal: path.join(delivery, "runtime", "formal-state-locks", "formal-state-transaction.json"),
270
493
  };
271
494
  }
272
495
 
@@ -282,6 +505,21 @@ function normalizeScope(value, scopeId = null) {
282
505
  node_attempts: value?.node_attempts && typeof value.node_attempts === "object"
283
506
  ? value.node_attempts
284
507
  : {},
508
+ node_candidate_bindings: value?.node_candidate_bindings
509
+ && typeof value.node_candidate_bindings === "object"
510
+ && !Array.isArray(value.node_candidate_bindings)
511
+ ? value.node_candidate_bindings
512
+ : {},
513
+ scope_manifests: Array.isArray(value?.scope_manifests) ? value.scope_manifests : [],
514
+ current_scope_manifest_id: value?.current_scope_manifest_id ?? null,
515
+ scope_contract_version: value?.scope_contract_version ?? null,
516
+ scope_warnings: Array.isArray(value?.scope_warnings) ? value.scope_warnings : [],
517
+ node_completion_receipts: Array.isArray(value?.node_completion_receipts)
518
+ ? value.node_completion_receipts
519
+ : [],
520
+ contract_retirement_receipts: Array.isArray(value?.contract_retirement_receipts)
521
+ ? value.contract_retirement_receipts
522
+ : [],
285
523
  latest_candidate_id: value?.latest_candidate_id ?? null,
286
524
  latest_release_id: value?.latest_release_id ?? null,
287
525
  updated_at: value?.updated_at ?? "",
@@ -289,6 +527,24 @@ function normalizeScope(value, scopeId = null) {
289
527
  };
290
528
  }
291
529
 
530
+ export function findIncompatibleNodeStates(scope) {
531
+ const memberships = new Map();
532
+ for (const [stateName, nodes] of [
533
+ ["active_nodes", scope?.active_nodes],
534
+ ["blocked_nodes", scope?.blocked_nodes],
535
+ ["completed_nodes", scope?.completed_nodes],
536
+ ]) {
537
+ for (const node of Array.isArray(nodes) ? nodes : []) {
538
+ const states = memberships.get(node) || new Set();
539
+ states.add(stateName);
540
+ memberships.set(node, states);
541
+ }
542
+ }
543
+ return [...memberships]
544
+ .filter(([, states]) => states.size > 1)
545
+ .map(([node, states]) => ({ node, states: [...states] }));
546
+ }
547
+
292
548
  function exposeLegacyScopeAliases(state) {
293
549
  const preferred = state.last_transition?.scope === "release"
294
550
  ? state.release_state
@@ -369,14 +625,597 @@ function normalizeArtifactRegistry(value) {
369
625
  };
370
626
  }
371
627
 
372
- function appendEvent(files, event) {
628
+ function uniqueSortedValues(values, label) {
629
+ const normalized = [...new Set(values.map((value) => String(value).trim()).filter(Boolean))].sort();
630
+ if (!normalized.length) throw new Error(`${label} requires at least one value.`);
631
+ return normalized;
632
+ }
633
+
634
+ function scopedReferences(flags, flag, idField) {
635
+ return uniqueSortedValues(repeatedFlag(flags, flag), flag).map((value) => {
636
+ const separator = value.indexOf(":");
637
+ if (separator <= 0 || separator === value.length - 1) {
638
+ throw new Error(`${flag} must use <node-id>:<id>.`);
639
+ }
640
+ const node = value.slice(0, separator);
641
+ const id = value.slice(separator + 1);
642
+ if (!WRITABLE_NODE_IDS.has(node) || scopeNameForNode(node) !== "development") {
643
+ throw new Error(`${flag} references an invalid development node: ${node}.`);
644
+ }
645
+ return { node, [idField]: id };
646
+ }).sort((left, right) => `${left.node}:${left[idField]}`.localeCompare(`${right.node}:${right[idField]}`));
647
+ }
648
+
649
+ function checksumWithoutField(record, field) {
650
+ const unsigned = { ...record };
651
+ delete unsigned[field];
652
+ return sha256(JSON.stringify(stableValue(unsigned)));
653
+ }
654
+
655
+ function scopeManifestChecksum(manifest) {
656
+ return checksumWithoutField(manifest, "manifest_checksum");
657
+ }
658
+
659
+ function completionReceiptChecksum(receipt) {
660
+ return checksumWithoutField(receipt, "receipt_checksum");
661
+ }
662
+
663
+ function contractRetirementReceiptChecksum(receipt) {
664
+ return checksumWithoutField(receipt, "receipt_checksum");
665
+ }
666
+
667
+ function currentScopeManifest(scope) {
668
+ if (!scope.current_scope_manifest_id) return null;
669
+ return scope.scope_manifests.find((manifest) => manifest?.manifest_id === scope.current_scope_manifest_id) || null;
670
+ }
671
+
672
+ function currentTaskFindings(task) {
673
+ const current = new Map();
674
+ for (const attempt of Array.isArray(task?.attempts) ? task.attempts : []) {
675
+ for (const phase of Array.isArray(attempt?.phases) ? attempt.phases : []) {
676
+ for (const finding of Array.isArray(phase?.findings) ? phase.findings : []) {
677
+ if (finding?.finding_id) current.set(finding.finding_id, finding);
678
+ }
679
+ }
680
+ }
681
+ return [...current.values()];
682
+ }
683
+
684
+ function readScopeTasks(files, scopeId, options) {
685
+ const taskRoot = path.join(files.workflow, "control", "tasks");
686
+ if (!fs.existsSync(taskRoot)) return new Map();
687
+ if (!fs.statSync(taskRoot).isDirectory()) throw new Error(`Task fact root must be a directory: ${taskRoot}`);
688
+ const tasks = new Map();
689
+ for (const name of fs.readdirSync(taskRoot).filter((item) => /\.ya?ml$|\.json$/i.test(item)).sort()) {
690
+ const task = readYaml(path.join(taskRoot, name), options.yaml, `task fact ${name}`);
691
+ if (task.iteration_id !== scopeId) continue;
692
+ if (!task.task_id) throw new Error(`Task fact is missing task_id: ${name}`);
693
+ if (tasks.has(task.task_id)) throw new Error(`Duplicate task fact ID: ${task.task_id}.`);
694
+ tasks.set(task.task_id, task);
695
+ }
696
+ return tasks;
697
+ }
698
+
699
+ function artifactFileIntegrity(files, artifact) {
700
+ const artifactPath = typeof artifact?.path === "string" ? artifact.path.replaceAll("\\", "/") : "";
701
+ if (!artifactPath.startsWith(".workflow/")) {
702
+ return { valid: false, code: "invalid-path", artifact_path: artifactPath, actual_checksum: null };
703
+ }
704
+ const absolute = path.resolve(files.root, artifactPath);
705
+ try {
706
+ if (!isInside(absolute, files.workflow) || !fs.existsSync(absolute) || !fs.statSync(absolute).isFile()) {
707
+ return { valid: false, code: "missing-or-outside", artifact_path: artifactPath, actual_checksum: null };
708
+ }
709
+ if (!isInside(fs.realpathSync(absolute), fs.realpathSync(files.workflow))) {
710
+ return { valid: false, code: "resolved-outside", artifact_path: artifactPath, actual_checksum: null };
711
+ }
712
+ const actualChecksum = sha256(fs.readFileSync(absolute));
713
+ if (artifact.checksum_algorithm !== "sha256" || artifact.checksum !== actualChecksum) {
714
+ return { valid: false, code: "checksum-mismatch", artifact_path: artifactPath, actual_checksum: actualChecksum };
715
+ }
716
+ return { valid: true, code: null, artifact_path: artifactPath, actual_checksum: actualChecksum };
717
+ } catch {
718
+ return { valid: false, code: "unreadable", artifact_path: artifactPath, actual_checksum: null };
719
+ }
720
+ }
721
+
722
+ function latestGateDecisions(scope, node) {
723
+ const attempt = Number(scope.node_attempts[node] || 1);
724
+ const decisions = new Map();
725
+ for (const decision of scope.gate_decisions) {
726
+ if (decision?.node === node
727
+ && (!decision.iteration_id || decision.iteration_id === scope.scope_id)
728
+ && Number(decision.node_attempt || attempt) === attempt
729
+ && decision.gate_id) decisions.set(decision.gate_id, decision);
730
+ }
731
+ return decisions;
732
+ }
733
+
734
+ function artifactSuperseder(registry, artifact) {
735
+ const artifactId = artifact?.artifact_id || artifact?.id;
736
+ if (!artifactId) return null;
737
+ return registry.artifacts.find((candidate) => candidate?.supersedes === artifactId) || null;
738
+ }
739
+
740
+ function projectedArtifactStatus(registry, artifact) {
741
+ return artifactSuperseder(registry, artifact) ? "superseded" : artifact?.status;
742
+ }
743
+
744
+ function deriveNodeStates(files, state, registry, options) {
745
+ const projections = {};
746
+ const artifacts = new Map(registry.artifacts.map((artifact) => [artifact.artifact_id || artifact.id, artifact]));
747
+ for (const [node, rule] of NODE_RULES) {
748
+ const scope = scopeState(state, rule.scope);
749
+ const attempt = Number(scope.node_attempts[node] || 1);
750
+ const active = scope.active_nodes.includes(node);
751
+ const blocked = scope.blocked_nodes.includes(node);
752
+ const completed = scope.completed_nodes.includes(node);
753
+ const manifest = rule.scope === "development" ? currentScopeManifest(scope) : null;
754
+ const tasks = rule.scope === "development" && scope.scope_id
755
+ ? readScopeTasks(files, scope.scope_id, options)
756
+ : new Map();
757
+ const missing = [];
758
+ const blockingReasons = [];
759
+ const blockingDefectIds = [];
760
+ const taskFacts = [];
761
+ const artifactFacts = [];
762
+ const scopedNode = rule.scope === "development" && SCOPED_COMPLETION_NODES.has(node);
763
+ const taskReferences = manifest?.required_tasks?.filter((reference) => reference.node === node) || [];
764
+ const allDefectReferences = CANDIDATE_REQUIRED_NODES.has(node)
765
+ ? manifest?.required_tasks || []
766
+ : taskReferences;
767
+
768
+ const governedScope = scope.scope_contract_version === 1 || Boolean(manifest);
769
+ if (scopedNode && governedScope && !manifest) missing.push("scope-manifest:frozen");
770
+ for (const reference of taskReferences) {
771
+ const task = tasks.get(reference.task_id);
772
+ taskFacts.push({ task_id: reference.task_id, status: task?.status || "missing" });
773
+ if (!task) {
774
+ missing.push(`task:${reference.task_id}:missing`);
775
+ } else if (task.status !== "completed") {
776
+ missing.push(`task:${reference.task_id}:${task.status || "unknown"}`);
777
+ if (new Set(["failed", "abandoned"]).has(task.status)) {
778
+ blockingReasons.push(`task:${reference.task_id}:${task.status}`);
779
+ }
780
+ }
781
+ }
782
+ for (const reference of allDefectReferences) {
783
+ const task = tasks.get(reference.task_id);
784
+ for (const finding of currentTaskFindings(task)) {
785
+ if (finding.severity === "blocking" && finding.status === "open") {
786
+ blockingDefectIds.push(finding.finding_id);
787
+ blockingReasons.push(`defect:${finding.finding_id}:open`);
788
+ }
789
+ }
790
+ }
791
+ for (const reference of manifest?.required_artifacts?.filter((item) => item.node === node) || []) {
792
+ const artifact = artifacts.get(reference.artifact_id);
793
+ const integrity = artifact ? artifactFileIntegrity(files, artifact) : null;
794
+ const artifactStatus = artifact ? projectedArtifactStatus(registry, artifact) : "missing";
795
+ artifactFacts.push({
796
+ artifact_id: reference.artifact_id,
797
+ status: artifactStatus,
798
+ checksum: artifact?.checksum || null,
799
+ checksum_valid: integrity?.valid || false,
800
+ });
801
+ if (!artifact) {
802
+ missing.push(`artifact:${reference.artifact_id}:missing`);
803
+ } else if (!integrity.valid) {
804
+ missing.push(`artifact:${reference.artifact_id}:${integrity.code}`);
805
+ blockingReasons.push(`artifact:${reference.artifact_id}:${integrity.code}`);
806
+ } else if (!allowedCompletionStatuses(node).has(artifactStatus)) {
807
+ missing.push(`artifact:${reference.artifact_id}:${artifactStatus || "unknown"}`);
808
+ if (new Set(["rejected", "failed", "invalid", "blocked", "stale", "superseded"]).has(artifactStatus)) {
809
+ blockingReasons.push(`artifact:${reference.artifact_id}:${artifactStatus}`);
810
+ }
811
+ }
812
+ }
813
+
814
+ const decisions = latestGateDecisions(scope, node);
815
+ const gateFacts = [];
816
+ for (const gateId of rule.gates || []) {
817
+ const decision = decisions.get(gateId);
818
+ gateFacts.push({ gate_id: gateId, decision: decision?.decision || "missing", actor: decision?.actor || null });
819
+ if (!satisfiesMandatoryGate(node, decision)) missing.push(`gate:${gateId}:approved`);
820
+ if (decision && !new Set(["approved", "not-required"]).has(decision.decision)) {
821
+ blockingReasons.push(`gate:${gateId}:${decision.decision}`);
822
+ }
823
+ }
824
+ const pendingForNode = scope.pending_gates.filter((gate) =>
825
+ typeof gate === "string" || gate?.node === node);
826
+ for (const pending of pendingForNode) {
827
+ const gateId = typeof pending === "string" ? pending : pending.gate_id;
828
+ if (gateId && !missing.includes(`gate:${gateId}:pending`)) missing.push(`gate:${gateId}:pending`);
829
+ }
830
+
831
+ let candidateId = null;
832
+ if (rule.scope === "development" && CANDIDATE_REQUIRED_NODES.has(node)) {
833
+ const binding = scope.node_candidate_bindings[node];
834
+ const requiresAttemptBinding = CANDIDATE_BOUND_NODES.has(node) && (active || blocked || completed);
835
+ candidateId = requiresAttemptBinding
836
+ ? (Number(binding?.node_attempt) === attempt ? binding.candidate_id : null)
837
+ : scope.latest_candidate_id;
838
+ const candidate = candidateId ? artifacts.get(candidateId) : null;
839
+ if (!candidate || projectedArtifactStatus(registry, candidate) !== "immutable") {
840
+ missing.push(requiresAttemptBinding ? "candidate:attempt-binding" : "candidate:selected");
841
+ } else {
842
+ const integrity = artifactFileIntegrity(files, candidate);
843
+ if (!integrity.valid) {
844
+ missing.push(`candidate:${candidateId}:${integrity.code}`);
845
+ blockingReasons.push(`candidate:${candidateId}:${integrity.code}`);
846
+ }
847
+ }
848
+ }
849
+
850
+ const receipt = [...scope.node_completion_receipts].reverse().find((item) =>
851
+ item?.node === node
852
+ && Number(item.node_attempt || attempt) === attempt
853
+ && (!manifest || (item.scope_manifest_id === manifest.manifest_id
854
+ && item.scope_manifest_checksum === manifest.manifest_checksum)));
855
+ if (completed && scopedNode && manifest && !receipt) missing.push("completion-receipt:current-scope");
856
+
857
+ const missingConditions = [...new Set(missing)].sort();
858
+ const blockConditions = [...new Set(blockingReasons)].sort();
859
+ const defects = [...new Set(blockingDefectIds)].sort();
860
+ let derivedState = "pending";
861
+ if (blocked || blockConditions.length) derivedState = "blocked";
862
+ else if (completed) derivedState = missingConditions.length ? "blocked" : "completed";
863
+ else if (active && missingConditions.length) derivedState = "waiting";
864
+ else if (active && scopedNode) derivedState = "ready-to-complete";
865
+ else if (active) derivedState = "active";
866
+
867
+ projections[node] = stableValue({
868
+ node,
869
+ state: derivedState,
870
+ completion_ready: derivedState === "ready-to-complete",
871
+ scope_manifest_id: manifest?.manifest_id || null,
872
+ scope_manifest_checksum: manifest?.manifest_checksum || null,
873
+ node_attempt: attempt,
874
+ required_task_ids: taskReferences.map((reference) => reference.task_id).sort(),
875
+ required_artifact_ids: (manifest?.required_artifacts || [])
876
+ .filter((reference) => reference.node === node)
877
+ .map((reference) => reference.artifact_id)
878
+ .sort(),
879
+ task_facts: taskFacts,
880
+ artifact_facts: artifactFacts,
881
+ gate_facts: gateFacts,
882
+ candidate_id: candidateId,
883
+ blocking_defect_ids: defects,
884
+ blocking_conditions: blockConditions,
885
+ missing_conditions: missingConditions,
886
+ completion_receipt_id: receipt?.receipt_id || null,
887
+ });
888
+ }
889
+ return projections;
890
+ }
891
+
892
+ const FORMAL_STREAMS = new Map([
893
+ ["workflow-state", {
894
+ snapshotFile: "workflow-state.yaml",
895
+ viewFile: (files) => files.workflowState,
896
+ normalize: normalizeWorkflowState,
897
+ applyOperations: applyWorkflowStateOperations,
898
+ allowedSchemas: new Set([1, 2]),
899
+ }],
900
+ ["artifact-registry", {
901
+ snapshotFile: "artifact-registry.yaml",
902
+ viewFile: (files) => files.artifactRegistry,
903
+ normalize: normalizeArtifactRegistry,
904
+ applyOperations: applyArtifactRegistryOperations,
905
+ allowedSchemas: new Set([1]),
906
+ }],
907
+ ]);
908
+
909
+ function formalStreamViews(files, rawValues) {
910
+ return new Map([...FORMAL_STREAMS].map(([stream, descriptor]) => [stream, {
911
+ ...descriptor,
912
+ file: descriptor.viewFile(files),
913
+ raw: rawValues[stream],
914
+ value: descriptor.normalize(rawValues[stream]),
915
+ }]));
916
+ }
917
+
918
+ function durableWrite(file, content) {
919
+ const descriptor = fs.openSync(file, "w");
920
+ try {
921
+ fs.writeFileSync(descriptor, content, "utf8");
922
+ fs.fsyncSync(descriptor);
923
+ } finally {
924
+ fs.closeSync(descriptor);
925
+ }
926
+ }
927
+
928
+ function writeTransactionJournal(files, journal) {
929
+ atomicWrite(files.transactionJournal, `${JSON.stringify(journal, null, 2)}\n`);
930
+ const descriptor = fs.openSync(files.transactionJournal, "r+");
931
+ try {
932
+ fs.fsyncSync(descriptor);
933
+ } finally {
934
+ fs.closeSync(descriptor);
935
+ }
936
+ }
937
+
938
+ function fileChecksum(file) {
939
+ return sha256(fs.readFileSync(file));
940
+ }
941
+
942
+ function validateTransactionJournal(files, journal) {
943
+ if (journal?.schema_version !== 1 || !journal.transaction_id
944
+ || !new Set(["prepared", "committed"]).has(journal.phase)
945
+ || !Array.isArray(journal.entries) || !journal.entries.length) {
946
+ throw new Error("Formal state transaction journal is invalid.");
947
+ }
948
+ const managerEvents = path.join(
949
+ files.root, ".workflow", "control", "events", "workflow-manager-events.jsonl",
950
+ );
951
+ const managerReceipts = path.join(files.root, ".workflow", "control", "receipts");
952
+ const allowedFiles = new Set(
953
+ [files.project, files.workflowState, files.artifactRegistry, files.events, managerEvents]
954
+ .map((file) => path.resolve(file)),
955
+ );
956
+ for (const [index, entry] of journal.entries.entries()) {
957
+ const target = path.resolve(String(entry.file || ""));
958
+ const expectedTemporary = `${target}.${journal.transaction_id}.${index}.tmp`;
959
+ const expectedBackup = `${target}.${journal.transaction_id}.${index}.bak`;
960
+ if ((!allowedFiles.has(target)
961
+ && !isInside(target, files.eventShards)
962
+ && !isInside(target, files.historySnapshots)
963
+ && !isInside(target, path.join(files.delivery, "reviews", "prototype-pages"))
964
+ && !isInside(target, managerReceipts))
965
+ || path.resolve(String(entry.temporary || "")) !== path.resolve(expectedTemporary)
966
+ || path.resolve(String(entry.backup || "")) !== path.resolve(expectedBackup)
967
+ || typeof entry.had_original !== "boolean"
968
+ || !/^[a-f0-9]{64}$/.test(entry.new_checksum || "")
969
+ || (entry.had_original && !/^[a-f0-9]{64}$/.test(entry.original_checksum || ""))) {
970
+ throw new Error("Formal state transaction journal contains an invalid file entry.");
971
+ }
972
+ }
973
+ return journal;
974
+ }
975
+
976
+ function removeFileWithChecksum(file, expectedChecksum, label) {
977
+ if (!fs.existsSync(file)) return;
978
+ if (!fs.statSync(file).isFile() || fileChecksum(file) !== expectedChecksum) {
979
+ throw new Error(`Formal state transaction ${label} checksum mismatch: ${file}`);
980
+ }
981
+ fs.rmSync(file, { force: true });
982
+ }
983
+
984
+ function rollbackPreparedTransaction(files, journal) {
985
+ for (const entry of [...journal.entries].reverse()) {
986
+ if (fs.existsSync(entry.backup)) {
987
+ if (!fs.statSync(entry.backup).isFile() || fileChecksum(entry.backup) !== entry.original_checksum) {
988
+ throw new Error(`Formal state transaction backup checksum mismatch: ${entry.backup}`);
989
+ }
990
+ if (fs.existsSync(entry.file)) {
991
+ removeFileWithChecksum(entry.file, entry.new_checksum, "new target");
992
+ }
993
+ fs.renameSync(entry.backup, entry.file);
994
+ } else if (entry.had_original) {
995
+ if (!fs.existsSync(entry.file) || !fs.statSync(entry.file).isFile()
996
+ || fileChecksum(entry.file) !== entry.original_checksum) {
997
+ throw new Error(`Formal state transaction original target is unavailable: ${entry.file}`);
998
+ }
999
+ } else {
1000
+ removeFileWithChecksum(entry.file, entry.new_checksum, "new target");
1001
+ }
1002
+ removeFileWithChecksum(entry.temporary, entry.new_checksum, "temporary file");
1003
+ }
1004
+ fs.rmSync(files.transactionJournal, { force: true });
1005
+ }
1006
+
1007
+ function finalizeCommittedTransaction(files, journal) {
1008
+ for (const entry of journal.entries) {
1009
+ if (!fs.existsSync(entry.file) || !fs.statSync(entry.file).isFile()
1010
+ || fileChecksum(entry.file) !== entry.new_checksum) {
1011
+ throw new Error(`Committed formal state transaction target is invalid: ${entry.file}`);
1012
+ }
1013
+ removeFileWithChecksum(entry.temporary, entry.new_checksum, "temporary file");
1014
+ if (entry.had_original) {
1015
+ removeFileWithChecksum(entry.backup, entry.original_checksum, "backup file");
1016
+ } else if (fs.existsSync(entry.backup)) {
1017
+ throw new Error(`Unexpected formal state transaction backup: ${entry.backup}`);
1018
+ }
1019
+ }
1020
+ fs.rmSync(files.transactionJournal, { force: true });
1021
+ }
1022
+
1023
+ function recoverPendingTransaction(files) {
1024
+ if (!fs.existsSync(files.transactionJournal)) return;
1025
+ const journal = validateTransactionJournal(
1026
+ files,
1027
+ JSON.parse(fs.readFileSync(files.transactionJournal, "utf8")),
1028
+ );
1029
+ if (journal.phase === "committed") finalizeCommittedTransaction(files, journal);
1030
+ else rollbackPreparedTransaction(files, journal);
1031
+ }
1032
+
1033
+ function assertNoPendingTransaction(files) {
1034
+ if (fs.existsSync(files.transactionJournal)) {
1035
+ throw new Error("Formal state transaction is incomplete; run rebuild or retry the mutation to recover it.");
1036
+ }
1037
+ }
1038
+
1039
+ function withReadableFormalState(files, action) {
1040
+ return withLock(files, "formal-state-read", action, { recover: false });
1041
+ }
1042
+
1043
+ function withZeroWriteFormalState(files, action) {
1044
+ const assertStable = () => {
1045
+ assertNoPendingTransaction(files);
1046
+ const lockFile = path.join(files.lockRoot, "formal-state.lock");
1047
+ if (fs.existsSync(lockFile)) {
1048
+ throw new Error("Formal state is locked; zero-write inspection cannot continue.");
1049
+ }
1050
+ };
1051
+ assertStable();
1052
+ try {
1053
+ return action();
1054
+ } finally {
1055
+ assertStable();
1056
+ }
1057
+ }
1058
+
1059
+ export function withFormalStateReadLock(projectRoot, action) {
1060
+ if (typeof action !== "function") throw new Error("Formal state read lock requires an action.");
1061
+ return withReadableFormalState(statePaths(projectRoot), action);
1062
+ }
1063
+
1064
+ function atomicWriteSet(files, entries, options = {}, transactionId = null) {
1065
+ transactionId = transactionId || `${process.pid}.${Date.now()}.${crypto.randomUUID()}`;
1066
+ const targets = entries.map(({ file }) => path.resolve(file));
1067
+ if (new Set(targets).size !== targets.length) {
1068
+ throw new Error("Formal state transaction cannot write the same target more than once.");
1069
+ }
1070
+ const staged = entries.map(({ file, content }, index) => ({
1071
+ file: path.resolve(file),
1072
+ content,
1073
+ temporary: `${path.resolve(file)}.${transactionId}.${index}.tmp`,
1074
+ backup: `${path.resolve(file)}.${transactionId}.${index}.bak`,
1075
+ had_original: false,
1076
+ original_checksum: null,
1077
+ new_checksum: sha256(content),
1078
+ }));
1079
+ for (const entry of staged) {
1080
+ fs.mkdirSync(path.dirname(entry.file), { recursive: true });
1081
+ if (fs.existsSync(entry.file) && !fs.statSync(entry.file).isFile()) {
1082
+ throw new Error(`Transactional write target must be a file: ${entry.file}`);
1083
+ }
1084
+ entry.had_original = fs.existsSync(entry.file);
1085
+ entry.original_checksum = entry.had_original ? fileChecksum(entry.file) : null;
1086
+ }
1087
+ const journal = {
1088
+ schema_version: 1,
1089
+ transaction_id: transactionId,
1090
+ phase: "prepared",
1091
+ entries: staged.map(({ content: ignored, ...entry }) => entry),
1092
+ };
1093
+ try {
1094
+ for (const entry of staged) durableWrite(entry.temporary, entry.content);
1095
+ writeTransactionJournal(files, journal);
1096
+ for (const [index, entry] of staged.entries()) {
1097
+ if (entry.had_original) fs.renameSync(entry.file, entry.backup);
1098
+ fs.renameSync(entry.temporary, entry.file);
1099
+ if (typeof options.onTransactionCommit === "function") {
1100
+ options.onTransactionCommit({ index, file: entry.file, transaction_id: transactionId });
1101
+ }
1102
+ }
1103
+ journal.phase = "committed";
1104
+ writeTransactionJournal(files, journal);
1105
+ } catch (error) {
1106
+ if (fs.existsSync(files.transactionJournal)) {
1107
+ try {
1108
+ recoverPendingTransaction(files);
1109
+ } catch (recoveryError) {
1110
+ throw new AggregateError([error, recoveryError], "Formal state transaction failed and requires recovery.");
1111
+ }
1112
+ } else {
1113
+ for (const entry of staged) fs.rmSync(entry.temporary, { force: true });
1114
+ }
1115
+ throw error;
1116
+ }
1117
+ finalizeCommittedTransaction(files, journal);
1118
+ }
1119
+
1120
+ function eventLogContent(files, events) {
1121
+ if (fs.existsSync(files.events) && !fs.statSync(files.events).isFile()) {
1122
+ throw new Error(`Transactional write target must be a file: ${files.events}`);
1123
+ }
373
1124
  const existing = fs.existsSync(files.events) ? fs.readFileSync(files.events, "utf8") : "";
374
1125
  const prefix = existing && !existing.endsWith("\n") ? `${existing}\n` : existing;
375
- atomicWrite(files.events, `${prefix}${JSON.stringify(event)}\n`);
1126
+ return `${prefix}${events.map((event) => JSON.stringify(event)).join("\n")}\n`;
1127
+ }
1128
+
1129
+ function unsignedRecordChecksum(record, yaml) {
1130
+ const { record_checksum: ignored, ...unsigned } = record;
1131
+ return sha256(yaml.stringify(unsigned));
1132
+ }
1133
+
1134
+ function previousStreamChecksum(files, stream, beforeRevision, options) {
1135
+ const directory = path.join(files.eventShards, stream);
1136
+ const checksumFromAnchor = () => {
1137
+ if (beforeRevision === 0) return null;
1138
+ const trusted = readLatestHistoryAnchor(files, options);
1139
+ const metadata = trusted?.anchor.streams?.[stream];
1140
+ if (!metadata || metadata.revision !== beforeRevision) {
1141
+ throw new Error(
1142
+ `Formal event chain head is unavailable for ${stream} revision ${beforeRevision}.`,
1143
+ );
1144
+ }
1145
+ return metadata.record_checksum;
1146
+ };
1147
+ if (!fs.existsSync(directory)) return checksumFromAnchor();
1148
+ if (!fs.statSync(directory).isDirectory()) {
1149
+ throw new Error(`Formal event stream must be a directory: ${stream}`);
1150
+ }
1151
+ const names = fs.readdirSync(directory).filter((name) => /\.ya?ml$/i.test(name)).sort();
1152
+ if (!names.length) return checksumFromAnchor();
1153
+ const previous = readYaml(path.join(directory, names.at(-1)), options.yaml, `formal event shard ${stream}/${names.at(-1)}`);
1154
+ if (previous.revision !== beforeRevision) {
1155
+ throw new Error(`Formal event chain revision mismatch for ${stream}: expected ${beforeRevision}, actual ${previous.revision}.`);
1156
+ }
1157
+ return previous.record_checksum || unsignedRecordChecksum(previous, options.yaml);
1158
+ }
1159
+
1160
+ function artifactRegistryOperations(registry, artifacts, handoffs = []) {
1161
+ const {
1162
+ artifacts: ignoredArtifacts,
1163
+ handoffs: ignoredHandoffs,
1164
+ ...metadata
1165
+ } = stableValue(registry);
1166
+ return [
1167
+ { action: "set-registry-metadata", fields: metadata },
1168
+ ...artifacts.map((artifact) => ({
1169
+ action: "upsert-artifact",
1170
+ artifact_id: artifact.artifact_id || artifact.id,
1171
+ artifact: stableValue(artifact),
1172
+ })),
1173
+ ...handoffs.map((handoff) => ({
1174
+ action: "append-artifact-handoff",
1175
+ handoff: stableValue(handoff),
1176
+ })),
1177
+ ];
1178
+ }
1179
+
1180
+ function artifactRegistryTransaction(event, registry, beforeRevision, artifacts, handoffs = []) {
1181
+ const subject = event.artifact_id || (event.artifact_ids || []).join(",") || event.type;
1182
+ return {
1183
+ schema_version: 2,
1184
+ before_revision: beforeRevision,
1185
+ after_revision: registry.revision,
1186
+ idempotency_key: `artifact-registry:${event.type}:${subject}:r${beforeRevision}-${registry.revision}`,
1187
+ operations: artifactRegistryOperations(registry, artifacts, handoffs),
1188
+ };
1189
+ }
1190
+
1191
+ function workflowStateOperations(state, scopeName, beforeRevision) {
1192
+ const { development_state: ignoredDevelopment, release_state: ignoredRelease, ...metadata } = stableValue(state);
1193
+ const scopes = beforeRevision === 0 ? ["development", "release"] : [scopeName];
1194
+ return [
1195
+ { action: "set-workflow-metadata", fields: metadata },
1196
+ ...scopes.map((name) => ({
1197
+ action: "replace-scope",
1198
+ scope: name,
1199
+ value: stableValue(scopeState(state, name)),
1200
+ })),
1201
+ ];
1202
+ }
1203
+
1204
+ function workflowStateTransaction(event, state, scopeName, beforeRevision) {
1205
+ const subject = event.gate_id || event.node || event.artifact_id || event.manifest_id
1206
+ || event.warning_id || event.iteration_id || event.type;
1207
+ return {
1208
+ schema_version: 2,
1209
+ before_revision: beforeRevision,
1210
+ after_revision: state.revision,
1211
+ idempotency_key: `workflow-state:${event.type}:${scopeName}:${subject}:r${beforeRevision}-${state.revision}`,
1212
+ operations: workflowStateOperations(state, scopeName, beforeRevision),
1213
+ };
376
1214
  }
377
1215
 
378
- function writeStream(files, stream, file, value, event, options) {
379
- const serialized = options.yaml.stringify(value);
1216
+ function prepareStreamMutation(files, mutation, options, transactionId) {
1217
+ const { stream, file, value, event, transaction = null } = mutation;
1218
+ const serialized = options.yaml.stringify(transaction ? stableValue(value) : value);
380
1219
  const eventId = crypto.randomUUID();
381
1220
  const eventRecord = {
382
1221
  event_id: eventId,
@@ -386,51 +1225,565 @@ function writeStream(files, stream, file, value, event, options) {
386
1225
  ...event,
387
1226
  after_checksum: sha256(serialized),
388
1227
  };
1228
+ const shardRecord = transaction
1229
+ ? {
1230
+ ...eventRecord,
1231
+ transaction_id: transactionId,
1232
+ ...transaction,
1233
+ previous_checksum: previousStreamChecksum(files, stream, transaction.before_revision, options),
1234
+ }
1235
+ : { ...eventRecord, after_value: value };
1236
+ const record = {
1237
+ ...shardRecord,
1238
+ record_checksum: unsignedRecordChecksum(shardRecord, options.yaml),
1239
+ };
389
1240
  const shardFile = path.join(files.eventShards, stream, `${String(value.revision).padStart(6, "0")}-${eventId}.yaml`);
390
- atomicWrite(shardFile, options.yaml.stringify({ ...eventRecord, after_value: value }));
391
- atomicWrite(file, serialized);
392
- appendEvent(files, eventRecord);
1241
+ const indexedEvent = transaction ? record : eventRecord;
1242
+ return {
1243
+ indexedEvent,
1244
+ writes: [
1245
+ { file: shardFile, content: options.yaml.stringify(record) },
1246
+ { file, content: serialized },
1247
+ ],
1248
+ };
1249
+ }
1250
+
1251
+ function commitFormalTransaction(files, mutations, extraWrites, options) {
1252
+ if (!Array.isArray(mutations) || !mutations.length) {
1253
+ throw new Error("Formal state transaction requires at least one stream mutation.");
1254
+ }
1255
+ const transactionId = `TXN-${crypto.randomUUID()}`;
1256
+ const prepared = mutations.map((mutation) =>
1257
+ prepareStreamMutation(files, mutation, options, transactionId));
1258
+ const managerWrites = typeof options.managerTransactionWriteFactory === "function"
1259
+ ? options.managerTransactionWriteFactory({
1260
+ transaction_id: transactionId,
1261
+ mutations: mutations.map((mutation) => ({
1262
+ stream: mutation.stream,
1263
+ event: mutation.event,
1264
+ value: mutation.value,
1265
+ })),
1266
+ })
1267
+ : [];
1268
+ if (!Array.isArray(managerWrites)) {
1269
+ throw new Error("Manager transaction write factory must return an array.");
1270
+ }
1271
+ const managerEventsFile = path.resolve(
1272
+ files.root, ".workflow", "control", "events", "workflow-manager-events.jsonl",
1273
+ );
1274
+ const managerReceiptsRoot = path.resolve(files.root, ".workflow", "control", "receipts");
1275
+ for (const write of managerWrites) {
1276
+ const target = path.resolve(String(write?.file || ""));
1277
+ const receiptRelative = path.relative(managerReceiptsRoot, target);
1278
+ const isReceipt = Boolean(receiptRelative)
1279
+ && !receiptRelative.startsWith("..")
1280
+ && !path.isAbsolute(receiptRelative)
1281
+ && path.extname(target) === ".json";
1282
+ if (target !== managerEventsFile && !isReceipt) {
1283
+ throw new Error("Manager transaction writes are limited to its Receipt and Event stores.");
1284
+ }
1285
+ if (typeof write.content !== "string" && !Buffer.isBuffer(write.content)) {
1286
+ throw new Error("Manager transaction write content is invalid.");
1287
+ }
1288
+ }
1289
+ atomicWriteSet(files, [
1290
+ ...prepared.flatMap(({ writes }) => writes.slice(0, 1)),
1291
+ { file: files.events, content: eventLogContent(files, prepared.map(({ indexedEvent }) => indexedEvent)) },
1292
+ ...prepared.flatMap(({ writes }) => writes.slice(1)),
1293
+ ...extraWrites,
1294
+ ...managerWrites,
1295
+ ], options, transactionId);
1296
+ }
1297
+
1298
+ function applyArtifactRegistryOperations(value, record) {
1299
+ const registry = normalizeArtifactRegistry(structuredClone(value || { artifacts: [] }));
1300
+ for (const operation of record.operations || []) {
1301
+ if (operation.action === "set-registry-metadata") {
1302
+ if (!operation.fields || typeof operation.fields !== "object" || Array.isArray(operation.fields)) {
1303
+ throw new Error(`Artifact registry metadata operation is invalid at revision ${record.revision}.`);
1304
+ }
1305
+ Object.assign(registry, operation.fields);
1306
+ continue;
1307
+ }
1308
+ if (operation.action === "upsert-artifact") {
1309
+ if (!operation.artifact_id || !operation.artifact || typeof operation.artifact !== "object") {
1310
+ throw new Error(`Artifact registry upsert operation is invalid at revision ${record.revision}.`);
1311
+ }
1312
+ const index = registry.artifacts.findIndex((item) => (item.artifact_id || item.id) === operation.artifact_id);
1313
+ if (index === -1) registry.artifacts.push(operation.artifact);
1314
+ else registry.artifacts[index] = operation.artifact;
1315
+ continue;
1316
+ }
1317
+ if (operation.action === "append-artifact-handoff") {
1318
+ if (!operation.handoff || typeof operation.handoff !== "object"
1319
+ || !Array.isArray(operation.handoff.artifact_ids)
1320
+ || !operation.handoff.artifact_ids.length) {
1321
+ throw new Error(`Artifact handoff operation is invalid at revision ${record.revision}.`);
1322
+ }
1323
+ registry.handoffs = [
1324
+ ...(Array.isArray(registry.handoffs) ? registry.handoffs : []),
1325
+ stableValue(operation.handoff),
1326
+ ];
1327
+ continue;
1328
+ }
1329
+ throw new Error(`Unsupported artifact registry operation at revision ${record.revision}: ${operation.action || "missing"}.`);
1330
+ }
1331
+ registry.revision = record.after_revision;
1332
+ return stableValue(registry);
1333
+ }
1334
+
1335
+ function applyWorkflowStateOperations(value, record) {
1336
+ const state = structuredClone(value || {});
1337
+ for (const operation of record.operations || []) {
1338
+ if (operation.action === "set-workflow-metadata") {
1339
+ if (!operation.fields || typeof operation.fields !== "object" || Array.isArray(operation.fields)) {
1340
+ throw new Error(`Workflow state metadata operation is invalid at revision ${record.revision}.`);
1341
+ }
1342
+ Object.assign(state, operation.fields);
1343
+ continue;
1344
+ }
1345
+ if (operation.action === "replace-scope") {
1346
+ if (!new Set(["development", "release"]).has(operation.scope)
1347
+ || !operation.value || typeof operation.value !== "object" || Array.isArray(operation.value)) {
1348
+ throw new Error(`Workflow state scope operation is invalid at revision ${record.revision}.`);
1349
+ }
1350
+ state[operation.scope === "release" ? "release_state" : "development_state"] = stableValue(operation.value);
1351
+ continue;
1352
+ }
1353
+ throw new Error(`Unsupported workflow state operation at revision ${record.revision}: ${operation.action || "missing"}.`);
1354
+ }
1355
+ state.revision = record.after_revision;
1356
+ return stableValue(state);
1357
+ }
1358
+
1359
+ function replayStreamRecords(stream, records, options, base = {}) {
1360
+ const ordered = [...records].sort((left, right) => left.revision - right.revision);
1361
+ let value = base.value ? structuredClone(base.value) : null;
1362
+ let previousChecksum = base.recordChecksum ?? null;
1363
+ let previousRevision = base.revision ?? 0;
1364
+ for (const record of ordered) {
1365
+ if (record.revision !== previousRevision + 1) {
1366
+ throw new Error(`Formal event shard revision gap: ${stream}/${record.revision}; expected ${previousRevision + 1}.`);
1367
+ }
1368
+ if (record.after_value) {
1369
+ value = record.after_value;
1370
+ } else if (record.schema_version === 2 && Array.isArray(record.operations)) {
1371
+ if (record.before_revision !== record.revision - 1 || record.after_revision !== record.revision) {
1372
+ throw new Error(`Formal event shard revision metadata mismatch: ${stream}/${record.revision}.`);
1373
+ }
1374
+ if (record.previous_checksum !== previousChecksum) {
1375
+ throw new Error(`Formal event shard checksum chain mismatch: ${stream}/${record.revision}.`);
1376
+ }
1377
+ const applyOperations = FORMAL_STREAMS.get(stream)?.applyOperations;
1378
+ if (!applyOperations) throw new Error(`Unsupported formal event stream: ${stream}.`);
1379
+ value = applyOperations(value, record);
1380
+ } else {
1381
+ throw new Error(`Formal event shard has no replayable value: ${stream}/${record.revision}.`);
1382
+ }
1383
+ const replayedChecksum = sha256(options.yaml.stringify(value));
1384
+ if (replayedChecksum !== record.after_checksum) {
1385
+ throw new Error(
1386
+ `Formal event shard checksum mismatch: ${stream}/${record.revision}; expected ${record.after_checksum}, replayed ${replayedChecksum}.`,
1387
+ );
1388
+ }
1389
+ if (record.record_checksum && unsignedRecordChecksum(record, options.yaml) !== record.record_checksum) {
1390
+ throw new Error(`Formal event shard record checksum mismatch: ${stream}/${record.revision}.`);
1391
+ }
1392
+ previousChecksum = record.record_checksum || unsignedRecordChecksum(record, options.yaml);
1393
+ previousRevision = record.revision;
1394
+ }
1395
+ return value;
1396
+ }
1397
+
1398
+ function validateEventShardRecord(record, stream, name) {
1399
+ const expectedName = `${String(record.revision).padStart(6, "0")}-${record.event_id}.yaml`;
1400
+ if (record.stream !== stream || !Number.isInteger(record.revision) || !record.event_id
1401
+ || name !== expectedName || (!record.after_value && !Array.isArray(record.operations))) {
1402
+ throw new Error(`Formal event shard is invalid: ${stream}/${name}`);
1403
+ }
1404
+ return record;
393
1405
  }
394
1406
 
395
- function readEventShards(files, options) {
1407
+ function readEventShardRecord(files, options, stream, name) {
1408
+ return validateEventShardRecord(readYaml(
1409
+ path.join(files.eventShards, stream, name),
1410
+ options.yaml,
1411
+ `formal event shard ${stream}/${name}`,
1412
+ ), stream, name);
1413
+ }
1414
+
1415
+ function readEventShards(files, options, afterRevisions = null, indexedEvents = []) {
396
1416
  if (!fs.existsSync(files.eventShards)) return [];
397
1417
  const records = [];
398
- for (const stream of fs.readdirSync(files.eventShards).sort()) {
399
- const directory = path.join(files.eventShards, stream);
400
- if (!fs.statSync(directory).isDirectory()) continue;
401
- for (const name of fs.readdirSync(directory).filter((item) => /\.ya?ml$/i.test(item)).sort()) {
402
- const record = readYaml(path.join(directory, name), options.yaml, `formal event shard ${stream}/${name}`);
403
- if (record.stream !== stream || !Number.isInteger(record.revision) || !record.after_value) {
404
- throw new Error(`Formal event shard is invalid: ${stream}/${name}`);
1418
+ if (afterRevisions !== null) {
1419
+ for (const event of indexedEvents) {
1420
+ if (!Object.hasOwn(afterRevisions, event.stream)
1421
+ || !Number.isInteger(event.revision)
1422
+ || event.revision <= Number(afterRevisions[event.stream] || 0)) {
1423
+ continue;
1424
+ }
1425
+ const eventId = String(event.event_id || "");
1426
+ if (!eventId || eventId.includes("/") || eventId.includes("\\")) {
1427
+ throw new Error(`Formal audit event ID is invalid: ${event.stream}/${event.revision}.`);
1428
+ }
1429
+ const name = `${String(event.revision).padStart(6, "0")}-${eventId}.yaml`;
1430
+ records.push(readEventShardRecord(files, options, event.stream, name));
1431
+ }
1432
+ } else {
1433
+ for (const stream of fs.readdirSync(files.eventShards).sort()) {
1434
+ const directory = path.join(files.eventShards, stream);
1435
+ if (!fs.statSync(directory).isDirectory()) continue;
1436
+ for (const name of fs.readdirSync(directory).filter((item) => /\.ya?ml$/i.test(item)).sort()) {
1437
+ records.push(readEventShardRecord(files, options, stream, name));
405
1438
  }
406
- const checksum = sha256(options.yaml.stringify(record.after_value));
407
- if (checksum !== record.after_checksum) throw new Error(`Formal event shard checksum mismatch: ${stream}/${name}`);
408
- records.push(record);
409
1439
  }
410
1440
  }
411
- return records.sort((left, right) =>
1441
+ const sorted = records.sort((left, right) =>
412
1442
  String(left.occurred_at).localeCompare(String(right.occurred_at))
413
1443
  || left.stream.localeCompare(right.stream)
414
1444
  || left.revision - right.revision);
1445
+ for (const stream of new Set(sorted.map((record) => record.stream))) {
1446
+ if (afterRevisions === null) {
1447
+ replayStreamRecords(stream, sorted.filter((record) => record.stream === stream), options);
1448
+ }
1449
+ }
1450
+ return sorted;
1451
+ }
1452
+
1453
+ function readLatestHistoryAnchor(files, options) {
1454
+ const verified = readVerifiedHistoryAnchor({
1455
+ historySnapshots: files.historySnapshots,
1456
+ events: files.events,
1457
+ streams: FORMAL_STREAMS,
1458
+ readStructured: (file, label) => readYaml(file, options.yaml, label),
1459
+ stringify: options.yaml.stringify,
1460
+ });
1461
+ return verified ? { anchor: verified.anchor, snapshots: verified.snapshots } : null;
1462
+ }
1463
+
1464
+ function portableReference(files, target) {
1465
+ const relative = path.relative(files.root, target);
1466
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
1467
+ throw new Error(`Version archive reference is outside the project: ${target}.`);
1468
+ }
1469
+ return relative.replaceAll("\\", "/");
1470
+ }
1471
+
1472
+ function versionArchiveReferences(files, flags, options) {
1473
+ const iterationId = requiredFlag(flags, "--iteration-id");
1474
+ const requestedCandidateId = optionalFlag(flags, "--candidate-id");
1475
+ return withReadableFormalState(files, () => {
1476
+ const trusted = readLatestHistoryAnchor(files, options);
1477
+ if (!trusted) throw new Error(`Version archive requires a trusted history anchor for ${iterationId}.`);
1478
+ const scope = trusted.snapshots["workflow-state"]?.development_state;
1479
+ if (scope?.scope_id !== iterationId) {
1480
+ throw new Error(`Latest trusted history anchor does not contain version ${iterationId}.`);
1481
+ }
1482
+
1483
+ const manifests = Array.isArray(scope.scope_manifests) ? scope.scope_manifests : [];
1484
+ const manifestIndex = manifests.findIndex((item) => item?.manifest_id === scope.current_scope_manifest_id);
1485
+ if (manifestIndex < 0) {
1486
+ throw new Error(`Version archive requires the anchored scope manifest for ${iterationId}.`);
1487
+ }
1488
+ const manifest = manifests[manifestIndex];
1489
+ const anchoredRegistry = trusted.snapshots["artifact-registry"] || { artifacts: [] };
1490
+ const artifacts = anchoredRegistry.artifacts || [];
1491
+ const candidateId = scope.latest_candidate_id || null;
1492
+ if (!candidateId) {
1493
+ throw new Error(`Version archive requires an anchored candidate for ${iterationId}.`);
1494
+ }
1495
+ if (requestedCandidateId && requestedCandidateId !== candidateId) {
1496
+ throw new Error(
1497
+ `Requested candidate ${requestedCandidateId} does not match anchored selected candidate ${candidateId}.`,
1498
+ );
1499
+ }
1500
+ const candidateIndex = artifacts.findIndex((item) =>
1501
+ (item.artifact_id || item.id) === candidateId);
1502
+ if (candidateIndex < 0) {
1503
+ throw new Error(`Version archive candidate is not present in the trusted anchor: ${candidateId}.`);
1504
+ }
1505
+ const candidate = artifacts[candidateIndex];
1506
+ if (projectedArtifactStatus(anchoredRegistry, candidate) !== "immutable") {
1507
+ throw new Error(`Anchored selected candidate is not immutable: ${candidateId}.`);
1508
+ }
1509
+ const anchorDirectory = path.join(files.historySnapshots, trusted.anchor.anchor_id);
1510
+ const workflowSnapshotFile = path.join(anchorDirectory, FORMAL_STREAMS.get("workflow-state").snapshotFile);
1511
+ const registrySnapshotFile = path.join(anchorDirectory, FORMAL_STREAMS.get("artifact-registry").snapshotFile);
1512
+ const workflowRef = portableReference(files, workflowSnapshotFile);
1513
+ const registryRef = portableReference(files, registrySnapshotFile);
1514
+ const gateDecisions = Array.isArray(scope.gate_decisions) ? scope.gate_decisions : [];
1515
+ return {
1516
+ command: "snapshot references",
1517
+ references: {
1518
+ history_anchor: {
1519
+ anchor_id: trusted.anchor.anchor_id,
1520
+ anchor_checksum: trusted.anchor.anchor_checksum,
1521
+ ref: portableReference(files, path.join(anchorDirectory, "anchor.yaml")),
1522
+ workflow_state_checksum: trusted.anchor.streams["workflow-state"].view_checksum,
1523
+ artifact_registry_checksum: trusted.anchor.streams["artifact-registry"].view_checksum,
1524
+ },
1525
+ scope: {
1526
+ manifest_id: manifest.manifest_id,
1527
+ manifest_checksum: manifest.manifest_checksum,
1528
+ ref: `${workflowRef}#/development_state/scope_manifests/${manifestIndex}`,
1529
+ },
1530
+ candidate: {
1531
+ artifact_id: candidate.artifact_id || candidate.id,
1532
+ checksum_algorithm: candidate.checksum_algorithm || "sha256",
1533
+ checksum: candidate.checksum,
1534
+ ref: `${registryRef}#/artifacts/${candidateIndex}`,
1535
+ },
1536
+ gates: gateDecisions.map((decision, index) => ({
1537
+ gate_id: decision.gate_id,
1538
+ decision: decision.decision,
1539
+ actor: decision.actor,
1540
+ decided_at: decision.decided_at,
1541
+ checksum_algorithm: "sha256",
1542
+ checksum: sha256(JSON.stringify(stableValue(decision))),
1543
+ ref: `${workflowRef}#/development_state/gate_decisions/${index}`,
1544
+ })),
1545
+ },
1546
+ };
1547
+ });
1548
+ }
1549
+
1550
+ function readArtifactRetentionReceipts(files, options) {
1551
+ if (!fs.existsSync(files.artifactRetention)) return [];
1552
+ return fs.readdirSync(files.artifactRetention)
1553
+ .filter((name) => /\.ya?ml$/i.test(name))
1554
+ .sort()
1555
+ .map((name) => {
1556
+ const receipt = readYaml(
1557
+ path.join(files.artifactRetention, name),
1558
+ options.yaml,
1559
+ `artifact retention receipt ${name}`,
1560
+ );
1561
+ const expectedName = `${historyAnchorId(receipt.anchor_id)}.yaml`;
1562
+ if (name !== expectedName
1563
+ || receipt.schema_version !== 1
1564
+ || receipt.stream !== "artifact-registry"
1565
+ || !/^[a-f0-9]{64}$/.test(receipt.anchor_checksum || "")
1566
+ || !Number.isInteger(receipt.pruned_through_revision)
1567
+ || receipt.pruned_through_revision < 0
1568
+ || receipt.retained_from_revision !== receipt.pruned_through_revision + 1
1569
+ || !Number.isInteger(receipt.deleted_shard_count)
1570
+ || receipt.deleted_shard_count < 1
1571
+ || !/^[a-f0-9]{64}$/.test(receipt.deleted_shards_checksum || "")
1572
+ || structuredDocumentChecksum(receipt, "receipt_checksum", options.yaml.stringify)
1573
+ !== receipt.receipt_checksum) {
1574
+ throw new Error(`Artifact retention receipt is invalid: ${name}.`);
1575
+ }
1576
+ const anchorFile = path.join(files.historySnapshots, receipt.anchor_id, "anchor.yaml");
1577
+ const anchor = readYaml(anchorFile, options.yaml, `retention anchor ${receipt.anchor_id}`);
1578
+ if (anchor.anchor_checksum !== receipt.anchor_checksum
1579
+ || structuredDocumentChecksum(anchor, "anchor_checksum", options.yaml.stringify)
1580
+ !== anchor.anchor_checksum
1581
+ || anchor.streams?.["artifact-registry"]?.revision !== receipt.pruned_through_revision) {
1582
+ throw new Error(`Artifact retention receipt does not match its trusted anchor: ${name}.`);
1583
+ }
1584
+ return receipt;
1585
+ });
1586
+ }
1587
+
1588
+ function createFormalSnapshot(files, flags, options) {
1589
+ return withLock(files, "formal-state-snapshot", () => {
1590
+ const project = readYaml(files.project, options.yaml, "project.yaml");
1591
+ const rawState = readYaml(files.workflowState, options.yaml, "workflow-state.yaml");
1592
+ const rawRegistry = readYaml(files.artifactRegistry, options.yaml, "artifact-registry.yaml");
1593
+ const views = formalStreamViews(files, {
1594
+ "workflow-state": rawState,
1595
+ "artifact-registry": rawRegistry,
1596
+ });
1597
+ const state = views.get("workflow-state").value;
1598
+ const registry = views.get("artifact-registry").value;
1599
+ const streamRevisions = stableValue(Object.fromEntries(
1600
+ [...views].map(([stream, view]) => [stream, view.value.revision]),
1601
+ ));
1602
+ const reason = requiredFlag(flags, "--reason");
1603
+ if (!new Set(["version-boundary", "configured-interval"]).has(reason)) {
1604
+ throw new Error(`Unsupported history snapshot reason: ${reason}.`);
1605
+ }
1606
+ const eventCount = Object.values(streamRevisions).reduce((total, revision) => total + revision, 0);
1607
+ const latest = readLatestHistoryAnchor(files, options);
1608
+ if (reason === "configured-interval") {
1609
+ const interval = Number(project.formal_state_snapshot_interval);
1610
+ if (!Number.isInteger(interval) || interval <= 0) {
1611
+ verifyFormalState(files, options);
1612
+ return {
1613
+ command: "snapshot create",
1614
+ created: false,
1615
+ reason: "snapshot interval is not configured",
1616
+ event_count: eventCount,
1617
+ };
1618
+ }
1619
+ const anchoredEventCount = latest?.anchor.event_index.event_count || 0;
1620
+ if (eventCount - anchoredEventCount < interval) {
1621
+ verifyFormalState(files, options);
1622
+ return {
1623
+ command: "snapshot create",
1624
+ created: false,
1625
+ reason: "snapshot interval has not been reached",
1626
+ event_count: eventCount,
1627
+ events_until_snapshot: interval - (eventCount - anchoredEventCount),
1628
+ anchor_id: latest?.anchor.anchor_id || null,
1629
+ };
1630
+ }
1631
+ }
1632
+ const anchorId = historyAnchorId(
1633
+ optionalFlag(flags, "--id")
1634
+ || (reason === "configured-interval" ? `ANCHOR-W${state.revision}-A${registry.revision}` : ""),
1635
+ );
1636
+ const directory = path.join(files.historySnapshots, anchorId);
1637
+ if (fs.existsSync(directory)
1638
+ && latest?.anchor.anchor_id !== anchorId
1639
+ && fs.lstatSync(directory).isDirectory()
1640
+ && !fs.lstatSync(directory).isSymbolicLink()
1641
+ && fs.readdirSync(directory).length === 0) {
1642
+ fs.rmdirSync(directory);
1643
+ }
1644
+ if (fs.existsSync(directory)) {
1645
+ const existing = latest?.anchor;
1646
+ const unchanged = existing?.anchor_id === anchorId
1647
+ && existing.reason === reason
1648
+ && [...views].every(([stream, view]) =>
1649
+ existing.streams[stream].revision === view.value.revision
1650
+ && existing.streams[stream].view_checksum === fileChecksum(view.file));
1651
+ if (!unchanged) throw new Error(`History anchor already exists: ${anchorId}.`);
1652
+ verifyFormalState(files, options, { history: true, sinceAnchor: true });
1653
+ return {
1654
+ command: "snapshot create",
1655
+ created: false,
1656
+ deduplicated: true,
1657
+ anchor_id: anchorId,
1658
+ reason,
1659
+ anchor_checksum: existing.anchor_checksum,
1660
+ stream_revisions: streamRevisions,
1661
+ event_count: eventCount,
1662
+ };
1663
+ }
1664
+ verifyFormalState(files, options, {
1665
+ history: true,
1666
+ sinceAnchor: Boolean(latest),
1667
+ });
1668
+ const snapshotWrites = [...views].map(([, view]) => ({
1669
+ file: path.join(directory, view.snapshotFile),
1670
+ content: fs.readFileSync(view.file, "utf8"),
1671
+ }));
1672
+ const timestamp = (options.now || (() => new Date().toISOString()))();
1673
+ const anchorBase = stableValue({
1674
+ schema_version: 1,
1675
+ anchor_id: anchorId,
1676
+ project_id: project.project_id || state.project_id || null,
1677
+ reason,
1678
+ created_at: timestamp,
1679
+ event_index: {
1680
+ offset_bytes: fs.existsSync(files.events) ? fs.statSync(files.events).size : 0,
1681
+ event_count: eventCount,
1682
+ },
1683
+ streams: Object.fromEntries([...views].map(([stream, view]) => [stream, {
1684
+ revision: view.value.revision,
1685
+ record_checksum: previousStreamChecksum(files, stream, view.value.revision, options),
1686
+ view_checksum: fileChecksum(view.file),
1687
+ }])),
1688
+ });
1689
+ const anchor = {
1690
+ ...anchorBase,
1691
+ anchor_checksum: sha256(options.yaml.stringify(anchorBase)),
1692
+ };
1693
+ const pointerBase = stableValue({
1694
+ schema_version: 1,
1695
+ anchor_id: anchorId,
1696
+ anchor_checksum: anchor.anchor_checksum,
1697
+ updated_at: timestamp,
1698
+ });
1699
+ const pointer = {
1700
+ ...pointerBase,
1701
+ pointer_checksum: sha256(options.yaml.stringify(pointerBase)),
1702
+ };
1703
+ atomicWriteSet(files, [
1704
+ ...snapshotWrites,
1705
+ { file: path.join(directory, "anchor.yaml"), content: options.yaml.stringify(stableValue(anchor)) },
1706
+ { file: path.join(files.historySnapshots, "latest.yaml"), content: options.yaml.stringify(stableValue(pointer)) },
1707
+ ], options);
1708
+ return {
1709
+ command: "snapshot create",
1710
+ created: true,
1711
+ anchor_id: anchorId,
1712
+ reason,
1713
+ anchor_checksum: anchor.anchor_checksum,
1714
+ stream_revisions: streamRevisions,
1715
+ event_count: eventCount,
1716
+ };
1717
+ }, { waitForActive: true });
415
1718
  }
416
1719
 
417
1720
  function rebuildFormalViews(files, options) {
418
1721
  return withLock(files, "formal-state-rebuild", () => {
1722
+ const retentionReceipts = readArtifactRetentionReceipts(files, options);
1723
+ const historyArchives = readArtifactHistoryArchives(files, options);
1724
+ if (retentionReceipts.length || historyArchives.length) {
1725
+ const trusted = readLatestHistoryAnchor(files, options);
1726
+ if (!trusted) throw new Error("Retained history rebuild requires a trusted history anchor.");
1727
+ const errors = [];
1728
+ const events = readEvents(files, errors, {
1729
+ offsetBytes: trusted.anchor.event_index.offset_bytes,
1730
+ lineOffset: trusted.anchor.event_index.event_count,
1731
+ });
1732
+ if (errors.length) {
1733
+ throw new Error(`Retained history rebuild failed:\n- ${errors.join("\n- ")}`);
1734
+ }
1735
+ const baseRevisions = Object.fromEntries(
1736
+ Object.entries(trusted.anchor.streams).map(([stream, metadata]) => [stream, metadata.revision]),
1737
+ );
1738
+ const records = readEventShards(files, options, baseRevisions, events);
1739
+ const rebuilt = Object.fromEntries([...FORMAL_STREAMS].map(([stream]) => [stream, replayStreamRecords(
1740
+ stream,
1741
+ records.filter((record) => record.stream === stream),
1742
+ options,
1743
+ {
1744
+ value: trusted.snapshots[stream],
1745
+ recordChecksum: trusted.anchor.streams[stream].record_checksum,
1746
+ revision: trusted.anchor.streams[stream].revision,
1747
+ },
1748
+ )]));
1749
+ atomicWriteSet(files, [...FORMAL_STREAMS].map(([stream, descriptor]) => {
1750
+ const streamRecords = records.filter((record) => record.stream === stream);
1751
+ return {
1752
+ file: descriptor.viewFile(files),
1753
+ content: streamRecords.length
1754
+ ? options.yaml.stringify(rebuilt[stream])
1755
+ : fs.readFileSync(
1756
+ path.join(files.historySnapshots, trusted.anchor.anchor_id, descriptor.snapshotFile),
1757
+ "utf8",
1758
+ ),
1759
+ };
1760
+ }), options);
1761
+ return {
1762
+ command: "rebuild",
1763
+ rebuilt: true,
1764
+ history_base_anchor_id: trusted.anchor.anchor_id,
1765
+ event_shard_count: records.length,
1766
+ workflow_revision: rebuilt["workflow-state"]?.revision ?? 0,
1767
+ artifact_registry_revision: rebuilt["artifact-registry"]?.revision ?? 0,
1768
+ };
1769
+ }
419
1770
  const records = readEventShards(files, options);
420
1771
  if (!records.length) {
421
1772
  return { command: "rebuild", rebuilt: false, event_shard_count: 0, message: "No formal event shards exist; legacy views were left unchanged." };
422
1773
  }
423
- const latest = new Map();
424
- for (const record of records) {
425
- const current = latest.get(record.stream);
426
- if (!current || record.revision > current.revision) latest.set(record.stream, record);
427
- }
428
- const workflow = latest.get("workflow-state");
429
- const registry = latest.get("artifact-registry");
430
- if (workflow) atomicWrite(files.workflowState, options.yaml.stringify(workflow.after_value));
431
- if (registry) atomicWrite(files.artifactRegistry, options.yaml.stringify(registry.after_value));
432
- const index = records.map(({ after_value: ignored, ...record }) => JSON.stringify(record)).join("\n");
433
- atomicWrite(files.events, `${index}\n`);
1774
+ const workflowRecords = records.filter((record) => record.stream === "workflow-state");
1775
+ const registryRecords = records.filter((record) => record.stream === "artifact-registry");
1776
+ const workflow = workflowRecords.length ? replayStreamRecords("workflow-state", workflowRecords, options) : null;
1777
+ const registry = registryRecords.length ? replayStreamRecords("artifact-registry", registryRecords, options) : null;
1778
+ const index = records.map((record) => {
1779
+ const { after_value: ignored, ...indexed } = record;
1780
+ if (record.after_value) delete indexed.record_checksum;
1781
+ return JSON.stringify(indexed);
1782
+ }).join("\n");
1783
+ const writes = [{ file: files.events, content: `${index}\n` }];
1784
+ if (workflow) writes.unshift({ file: files.workflowState, content: options.yaml.stringify(workflow) });
1785
+ if (registry) writes.unshift({ file: files.artifactRegistry, content: options.yaml.stringify(registry) });
1786
+ atomicWriteSet(files, writes, options);
434
1787
  return {
435
1788
  command: "rebuild",
436
1789
  rebuilt: true,
@@ -460,6 +1813,10 @@ function registerArtifact(files, flags, options) {
460
1813
  throw new Error(`Artifact ID is already registered: ${artifactId}`);
461
1814
  }
462
1815
  const artifactPath = requiredFlag(flags, "--path").replaceAll("\\", "/");
1816
+ const status = artifactStatus(flags);
1817
+ if (WORKING_DRAFT_STATUSES.has(status)) {
1818
+ throw new Error("Working drafts are mutable and must not be registered in the formal artifact registry.");
1819
+ }
463
1820
  if (!artifactPath.startsWith(".workflow/")) {
464
1821
  throw new Error("Artifact path must be project-relative and start with .workflow/.");
465
1822
  }
@@ -482,14 +1839,37 @@ function registerArtifact(files, flags, options) {
482
1839
  if (supersedes) {
483
1840
  predecessor = registry.artifacts.find((item) => (item.artifact_id || item.id) === supersedes);
484
1841
  if (!predecessor) throw new Error(`Superseded artifact is not registered: ${supersedes}`);
485
- if (predecessor.status === "superseded") {
486
- throw new Error(`Artifact is already superseded: ${supersedes}`);
1842
+ const successor = artifactSuperseder(registry, predecessor);
1843
+ if (predecessor.status === "superseded" || successor) {
1844
+ const successorId = successor?.artifact_id || successor?.id;
1845
+ throw new Error(
1846
+ `Artifact is already superseded: ${supersedes}${successorId ? ` by ${successorId}` : ""}`,
1847
+ );
487
1848
  }
488
1849
  }
489
1850
  const node = requiredFlag(flags, "--node");
1851
+ assertNotLegacyDevelopmentNode(node);
490
1852
  if (!NODE_IDS.has(node)) throw new Error(`Unknown workflow node: ${node}`);
491
1853
  const artifactScope = scopeNameForNode(node);
492
1854
  const artifactScopeState = scopeState(workflowState, artifactScope);
1855
+ const candidateId = optionalFlag(flags, "--candidate-id");
1856
+ if (node === "05-candidate-assurance") {
1857
+ if (!candidateId) {
1858
+ throw new Error("05-candidate-assurance artifacts require --candidate-id.");
1859
+ }
1860
+ const attempt = Number(artifactScopeState.node_attempts[node] || 0);
1861
+ const binding = artifactScopeState.node_candidate_bindings[node];
1862
+ if (!artifactScopeState.active_nodes.includes(node)
1863
+ || Number(binding?.node_attempt) !== attempt
1864
+ || binding?.candidate_id !== candidateId) {
1865
+ throw new Error(`Assurance artifact candidate ${candidateId} does not match the active node attempt.`);
1866
+ }
1867
+ const candidate = registry.artifacts.find((item) =>
1868
+ (item.artifact_id || item.id) === candidateId);
1869
+ if (!candidate || projectedArtifactStatus(registry, candidate) !== "immutable") {
1870
+ throw new Error(`Assurance artifact candidate is not selected and immutable: ${candidateId}.`);
1871
+ }
1872
+ }
493
1873
  const artifact = {
494
1874
  artifact_id: artifactId,
495
1875
  artifact_type: optionalFlag(flags, "--artifact-type"),
@@ -497,40 +1877,39 @@ function registerArtifact(files, flags, options) {
497
1877
  node,
498
1878
  scope: artifactScope,
499
1879
  iteration_id: artifactScopeState.scope_id || null,
500
- status: artifactStatus(flags),
1880
+ status,
501
1881
  version: optionalFlag(flags, "--version"),
502
1882
  producer_skill: optionalFlag(flags, "--producer-skill"),
1883
+ candidate_id: candidateId,
503
1884
  supersedes,
504
1885
  checksum_algorithm: "sha256",
505
1886
  checksum: sha256(content),
506
1887
  registered_at: timestamp,
507
1888
  };
508
- if (predecessor) {
509
- const previous = predecessor.status || "unknown";
510
- predecessor.status = "superseded";
511
- predecessor.superseded_by = artifactId;
512
- predecessor.status_history = [
513
- ...(Array.isArray(predecessor.status_history) ? predecessor.status_history : []),
514
- {
515
- from: previous,
516
- to: "superseded",
517
- evidence: [artifactId],
518
- changed_at: timestamp,
519
- },
520
- ];
521
- }
522
1889
  registry.schema_version = 1;
523
1890
  registry.project_id = registry.project_id || project.project_id || workflowState.project_id || null;
524
1891
  registry.iteration_id = workflowState.development_state.scope_id || registry.iteration_id || null;
525
1892
  registry.revision += 1;
526
1893
  registry.updated_at = timestamp;
527
1894
  registry.artifacts = [...registry.artifacts, artifact];
528
- writeStream(files, "artifact-registry", files.artifactRegistry, registry, {
1895
+ const event = {
529
1896
  type: "artifact-registered",
530
1897
  occurred_at: timestamp,
531
1898
  artifact_id: artifactId,
532
1899
  path: artifactPath,
533
- }, options);
1900
+ };
1901
+ commitFormalTransaction(files, [{
1902
+ stream: "artifact-registry",
1903
+ file: files.artifactRegistry,
1904
+ value: registry,
1905
+ event,
1906
+ transaction: artifactRegistryTransaction(
1907
+ event,
1908
+ registry,
1909
+ expected,
1910
+ [artifact],
1911
+ ),
1912
+ }], [], options);
534
1913
  return {
535
1914
  command: "artifact register",
536
1915
  project_id: registry.project_id,
@@ -539,7 +1918,7 @@ function registerArtifact(files, flags, options) {
539
1918
  artifact,
540
1919
  artifact_registry: registry,
541
1920
  };
542
- });
1921
+ }, { waitForActive: true });
543
1922
  }
544
1923
 
545
1924
  function changeArtifactStatus(files, flags, options) {
@@ -558,8 +1937,11 @@ function changeArtifactStatus(files, flags, options) {
558
1937
  const evidence = repeatedFlag(flags, "--evidence");
559
1938
  if (!evidence.length) throw new Error("artifact status requires at least one --evidence reference.");
560
1939
  if (artifact.status === status) throw new Error(`Artifact already has status ${status}: ${artifactId}`);
561
- if (artifact.status === "superseded" || artifact.status === "archived") {
562
- throw new Error(`Terminal artifact status cannot change: ${artifact.status}`);
1940
+ if (!WORKING_DRAFT_STATUSES.has(artifact.status)) {
1941
+ throw new Error(`Immutable artifact revision cannot change status: ${artifact.status}`);
1942
+ }
1943
+ if (WORKING_DRAFT_STATUSES.has(status)) {
1944
+ throw new Error("Legacy draft migration must target an immutable semantic status.");
563
1945
  }
564
1946
 
565
1947
  const timestamp = (options.now || (() => new Date().toISOString()))();
@@ -572,43 +1954,763 @@ function changeArtifactStatus(files, flags, options) {
572
1954
  registry.schema_version = 1;
573
1955
  registry.revision += 1;
574
1956
  registry.updated_at = timestamp;
575
- writeStream(files, "artifact-registry", files.artifactRegistry, registry, {
1957
+ const event = {
576
1958
  type: "artifact-status-changed",
577
1959
  occurred_at: timestamp,
578
1960
  artifact_id: artifactId,
579
1961
  from: previous,
580
1962
  to: status,
581
1963
  evidence,
582
- }, options);
1964
+ };
1965
+ commitFormalTransaction(files, [{
1966
+ stream: "artifact-registry",
1967
+ file: files.artifactRegistry,
1968
+ value: registry,
1969
+ event,
1970
+ transaction: artifactRegistryTransaction(event, registry, expected, [artifact]),
1971
+ }], [], options);
1972
+ return {
1973
+ command: "artifact status",
1974
+ revision: registry.revision,
1975
+ artifact,
1976
+ artifact_registry: registry,
1977
+ };
1978
+ }, { waitForActive: true });
1979
+ }
1980
+
1981
+ function recoverArtifactPrune(files, apply, options) {
1982
+ if (!fs.existsSync(files.pruneRuntime)) return 0;
1983
+ const pending = fs.readdirSync(files.pruneRuntime, { withFileTypes: true })
1984
+ .filter((entry) => entry.isDirectory())
1985
+ .map((entry) => path.join(files.pruneRuntime, entry.name));
1986
+ if (pending.length && !apply) {
1987
+ throw new Error("An interrupted artifact prune requires explicit --apply recovery.");
1988
+ }
1989
+ let recovered = 0;
1990
+ for (const directory of pending) {
1991
+ const planFile = path.join(directory, "plan.json");
1992
+ if (!fs.existsSync(planFile)) throw new Error(`Artifact prune recovery plan is missing: ${directory}.`);
1993
+ let plan;
1994
+ try {
1995
+ plan = JSON.parse(fs.readFileSync(planFile, "utf8"));
1996
+ } catch {
1997
+ throw new Error(`Artifact prune recovery plan is invalid: ${directory}.`);
1998
+ }
1999
+ if (plan?.schema_version !== 1 || !plan.anchor_id || !Array.isArray(plan.shards)
2000
+ || plan.shards.some((item) => !/^[^/\\]+\.ya?ml$/i.test(item?.name || "")
2001
+ || !/^[a-f0-9]{64}$/.test(item?.checksum || ""))) {
2002
+ throw new Error(`Artifact prune recovery plan is invalid: ${directory}.`);
2003
+ }
2004
+ const receiptFile = path.join(files.artifactRetention, `${historyAnchorId(plan.anchor_id)}.yaml`);
2005
+ if (fs.existsSync(receiptFile)) {
2006
+ const receipt = readArtifactRetentionReceipts(files, options)
2007
+ .find((item) => item.anchor_id === plan.anchor_id);
2008
+ const plannedShardsChecksum = sha256(JSON.stringify(stableValue(plan.shards)));
2009
+ if (!receipt
2010
+ || receipt.deleted_shard_count !== plan.shards.length
2011
+ || receipt.deleted_shards_checksum !== plannedShardsChecksum) {
2012
+ throw new Error(`Artifact prune receipt does not match its recovery plan: ${plan.anchor_id}.`);
2013
+ }
2014
+ fs.rmSync(directory, { recursive: true, force: true });
2015
+ recovered += 1;
2016
+ continue;
2017
+ }
2018
+ const shardDirectory = path.join(files.eventShards, "artifact-registry");
2019
+ fs.mkdirSync(shardDirectory, { recursive: true });
2020
+ for (const { name, checksum } of plan.shards) {
2021
+ const staged = path.join(directory, name);
2022
+ if (!fs.existsSync(staged)) continue;
2023
+ if (fileChecksum(staged) !== checksum) {
2024
+ throw new Error(`Artifact prune staged shard checksum mismatch: ${name}.`);
2025
+ }
2026
+ const destination = path.join(shardDirectory, name);
2027
+ if (fs.existsSync(destination)) {
2028
+ if (fileChecksum(destination) !== checksum) {
2029
+ throw new Error(`Artifact prune recovery destination conflicts: ${name}.`);
2030
+ }
2031
+ fs.rmSync(staged, { force: true });
2032
+ } else {
2033
+ fs.renameSync(staged, destination);
2034
+ }
2035
+ }
2036
+ fs.rmSync(directory, { recursive: true, force: true });
2037
+ recovered += 1;
2038
+ }
2039
+ return recovered;
2040
+ }
2041
+
2042
+ function legacyArtifactHistoryArchive(files, trusted, options) {
2043
+ verifyFormalState(files, options, { history: true });
2044
+ const anchoredRevision = trusted.anchor.streams["artifact-registry"].revision;
2045
+ const shardDirectory = path.join(files.eventShards, "artifact-registry");
2046
+ const legacyShards = fs.existsSync(shardDirectory)
2047
+ ? fs.readdirSync(shardDirectory)
2048
+ .filter((name) => /\.ya?ml$/i.test(name))
2049
+ .map((name) => ({
2050
+ name,
2051
+ file: path.join(shardDirectory, name),
2052
+ record: readEventShardRecord(files, options, "artifact-registry", name),
2053
+ }))
2054
+ .filter(({ record }) => record.after_value && record.revision <= anchoredRevision)
2055
+ .sort((left, right) => left.record.revision - right.record.revision)
2056
+ : [];
2057
+ if (!legacyShards.length) {
2058
+ throw new Error("Legacy artifact history compact requires anchored full-snapshot shards.");
2059
+ }
2060
+ const replayed = replayStreamRecords(
2061
+ "artifact-registry",
2062
+ legacyShards.map(({ record }) => record),
2063
+ options,
2064
+ );
2065
+ const anchoredRegistry = trusted.snapshots["artifact-registry"];
2066
+ if (JSON.stringify(stableValue(normalizeArtifactRegistry(replayed)))
2067
+ !== JSON.stringify(stableValue(normalizeArtifactRegistry(anchoredRegistry)))) {
2068
+ throw new Error("Legacy artifact history replay does not reproduce the anchored registry.");
2069
+ }
2070
+ const finalRecord = legacyShards.at(-1).record;
2071
+ const anchoredChecksum = trusted.anchor.streams["artifact-registry"].view_checksum;
2072
+ if (finalRecord.revision !== anchoredRevision || finalRecord.after_checksum !== anchoredChecksum) {
2073
+ throw new Error("Legacy artifact history replay does not reproduce the anchored registry checksum.");
2074
+ }
2075
+
2076
+ const digest = crypto.createHash("sha256");
2077
+ const entries = legacyShards.map(({ name, file, record }) => {
2078
+ const content = fs.readFileSync(file);
2079
+ digest.update(name);
2080
+ digest.update("\0");
2081
+ digest.update(content);
2082
+ digest.update("\0");
2083
+ return {
2084
+ path: name,
2085
+ revision: record.revision,
2086
+ size_bytes: content.length,
2087
+ sha256: sha256(content),
2088
+ content_base64: content.toString("base64"),
2089
+ };
2090
+ });
2091
+ const sourceHistoryRootDigest = digest.digest("hex");
2092
+ const coverage = {
2093
+ first_revision: legacyShards[0].record.revision,
2094
+ last_revision: finalRecord.revision,
2095
+ event_count: legacyShards.length,
2096
+ };
2097
+ const archiveId = trusted.anchor.anchor_id;
2098
+ const bundle = stableValue({
2099
+ schema_version: 1,
2100
+ archive_id: archiveId,
2101
+ stream: "artifact-registry",
2102
+ source_root: portableReference(files, shardDirectory),
2103
+ source_history_root_digest: sourceHistoryRootDigest,
2104
+ source_history_root_digest_algorithm: "sha256-path-null-bytes-null-v1",
2105
+ coverage,
2106
+ anchor: {
2107
+ anchor_id: trusted.anchor.anchor_id,
2108
+ anchor_checksum: trusted.anchor.anchor_checksum,
2109
+ registry_revision: anchoredRevision,
2110
+ registry_checksum: anchoredChecksum,
2111
+ },
2112
+ entries,
2113
+ });
2114
+ const archiveBytes = zlib.gzipSync(Buffer.from(`${JSON.stringify(bundle, null, 2)}\n`), {
2115
+ level: 9,
2116
+ mtime: 0,
2117
+ });
2118
+ const archiveFile = path.join(files.artifactHistoryArchives, `${archiveId}.json.gz`);
2119
+ const manifestFile = path.join(files.artifactHistoryArchives, `${archiveId}.yaml`);
2120
+ const looseBytes = entries.reduce((total, entry) => total + entry.size_bytes, 0);
2121
+ const compactedAt = (options.now || (() => new Date().toISOString()))();
2122
+ const manifestBase = stableValue({
2123
+ schema_version: 1,
2124
+ archive_id: archiveId,
2125
+ stream: "artifact-registry",
2126
+ anchor_id: trusted.anchor.anchor_id,
2127
+ anchor_checksum: trusted.anchor.anchor_checksum,
2128
+ registry_revision: anchoredRevision,
2129
+ registry_checksum: anchoredChecksum,
2130
+ source_root: bundle.source_root,
2131
+ source_history_root_digest: sourceHistoryRootDigest,
2132
+ source_history_root_digest_algorithm: bundle.source_history_root_digest_algorithm,
2133
+ coverage,
2134
+ loose_file_count: entries.length,
2135
+ loose_bytes: looseBytes,
2136
+ archive_format: "gzip-json-v1",
2137
+ archive_path: portableReference(files, archiveFile),
2138
+ archive_checksum: sha256(archiveBytes),
2139
+ compressed_bundle_bytes: archiveBytes.length,
2140
+ compacted_at: compactedAt,
2141
+ });
2142
+ const manifest = {
2143
+ ...manifestBase,
2144
+ manifest_checksum: sha256(options.yaml.stringify(manifestBase)),
2145
+ };
2146
+ const manifestContent = options.yaml.stringify(stableValue(manifest));
2147
+ const archiveStorageBytes = archiveBytes.length + Buffer.byteLength(manifestContent);
2148
+ return {
2149
+ bundle,
2150
+ archiveBytes,
2151
+ archiveFile,
2152
+ entries,
2153
+ manifest,
2154
+ manifestContent,
2155
+ manifestFile,
2156
+ report: {
2157
+ command: "compact",
2158
+ anchor_id: trusted.anchor.anchor_id,
2159
+ anchor_checksum: trusted.anchor.anchor_checksum,
2160
+ source_history_root_digest: sourceHistoryRootDigest,
2161
+ coverage,
2162
+ loose_file_count: entries.length,
2163
+ loose_bytes: looseBytes,
2164
+ compressed_bundle_bytes: archiveBytes.length,
2165
+ manifest_bytes: Buffer.byteLength(manifestContent),
2166
+ archive_bytes: archiveStorageBytes,
2167
+ expected_freed_bytes: looseBytes - archiveStorageBytes,
2168
+ archive_path: portableReference(files, archiveFile),
2169
+ manifest_path: portableReference(files, manifestFile),
2170
+ },
2171
+ };
2172
+ }
2173
+
2174
+ function readArtifactHistoryArchives(files, options) {
2175
+ assertPathInside(files.root, files.artifactHistoryArchives);
2176
+ if (!fs.existsSync(files.artifactHistoryArchives)) return [];
2177
+ return fs.readdirSync(files.artifactHistoryArchives)
2178
+ .filter((name) => /\.ya?ml$/i.test(name))
2179
+ .sort()
2180
+ .map((name) => {
2181
+ const manifestFile = path.join(files.artifactHistoryArchives, name);
2182
+ const manifest = readYaml(manifestFile, options.yaml, `artifact history archive manifest ${name}`);
2183
+ const archiveId = historyAnchorId(manifest.archive_id);
2184
+ const expectedManifestName = `${archiveId}.yaml`;
2185
+ const archiveFile = path.join(files.artifactHistoryArchives, `${archiveId}.json.gz`);
2186
+ const coverage = manifest.coverage;
2187
+ if (name !== expectedManifestName
2188
+ || manifest.schema_version !== 1
2189
+ || manifest.stream !== "artifact-registry"
2190
+ || manifest.anchor_id !== archiveId
2191
+ || manifest.archive_format !== "gzip-json-v1"
2192
+ || manifest.archive_path !== portableReference(files, archiveFile)
2193
+ || !/^[a-f0-9]{64}$/.test(manifest.anchor_checksum || "")
2194
+ || !/^[a-f0-9]{64}$/.test(manifest.registry_checksum || "")
2195
+ || !/^[a-f0-9]{64}$/.test(manifest.source_history_root_digest || "")
2196
+ || manifest.source_history_root_digest_algorithm !== "sha256-path-null-bytes-null-v1"
2197
+ || !/^[a-f0-9]{64}$/.test(manifest.archive_checksum || "")
2198
+ || !Number.isSafeInteger(manifest.registry_revision)
2199
+ || manifest.registry_revision < 1
2200
+ || !Number.isSafeInteger(coverage?.first_revision)
2201
+ || !Number.isSafeInteger(coverage?.last_revision)
2202
+ || !Number.isSafeInteger(coverage?.event_count)
2203
+ || coverage.first_revision < 1
2204
+ || coverage.last_revision < coverage.first_revision
2205
+ || coverage.event_count !== coverage.last_revision - coverage.first_revision + 1
2206
+ || coverage.last_revision !== manifest.registry_revision
2207
+ || !Number.isSafeInteger(manifest.loose_file_count)
2208
+ || manifest.loose_file_count !== coverage.event_count
2209
+ || !Number.isSafeInteger(manifest.loose_bytes)
2210
+ || manifest.loose_bytes < 0
2211
+ || !Number.isSafeInteger(manifest.compressed_bundle_bytes)
2212
+ || manifest.compressed_bundle_bytes < 1
2213
+ || structuredDocumentChecksum(manifest, "manifest_checksum", options.yaml.stringify)
2214
+ !== manifest.manifest_checksum
2215
+ || !fs.existsSync(archiveFile)
2216
+ || fs.statSync(archiveFile).size !== manifest.compressed_bundle_bytes
2217
+ || sha256(fs.readFileSync(archiveFile)) !== manifest.archive_checksum) {
2218
+ throw new Error(`Artifact history archive manifest is invalid: ${name}.`);
2219
+ }
2220
+ let bundle;
2221
+ try {
2222
+ bundle = JSON.parse(zlib.gunzipSync(fs.readFileSync(archiveFile)).toString("utf8"));
2223
+ } catch {
2224
+ throw new Error(`Artifact history archive bundle is invalid: ${archiveId}.`);
2225
+ }
2226
+ if (bundle?.schema_version !== 1
2227
+ || bundle.archive_id !== archiveId
2228
+ || bundle.stream !== "artifact-registry"
2229
+ || bundle.source_root !== manifest.source_root
2230
+ || bundle.source_history_root_digest !== manifest.source_history_root_digest
2231
+ || bundle.source_history_root_digest_algorithm !== manifest.source_history_root_digest_algorithm
2232
+ || JSON.stringify(bundle.coverage) !== JSON.stringify(manifest.coverage)
2233
+ || bundle.anchor?.anchor_id !== manifest.anchor_id
2234
+ || bundle.anchor?.anchor_checksum !== manifest.anchor_checksum
2235
+ || bundle.anchor?.registry_revision !== manifest.registry_revision
2236
+ || bundle.anchor?.registry_checksum !== manifest.registry_checksum
2237
+ || !Array.isArray(bundle.entries)
2238
+ || bundle.entries.length !== manifest.loose_file_count) {
2239
+ throw new Error(`Artifact history archive bundle metadata is invalid: ${archiveId}.`);
2240
+ }
2241
+ const digest = crypto.createHash("sha256");
2242
+ const names = new Set();
2243
+ let entryBytes = 0;
2244
+ const records = bundle.entries.map((entry, index) => {
2245
+ if (!/^[^/\\]+\.ya?ml$/i.test(entry?.path || "") || names.has(entry.path)) {
2246
+ throw new Error(`Artifact history archive entry path is invalid: ${archiveId}.`);
2247
+ }
2248
+ names.add(entry.path);
2249
+ if (!Number.isSafeInteger(entry.size_bytes) || entry.size_bytes < 0
2250
+ || !Number.isSafeInteger(entry.revision)
2251
+ || entry.revision !== coverage.first_revision + index) {
2252
+ throw new Error(`Artifact history archive entry metadata is invalid: ${archiveId}/${entry.path}.`);
2253
+ }
2254
+ const content = Buffer.from(String(entry.content_base64 || ""), "base64");
2255
+ if (content.length !== entry.size_bytes || sha256(content) !== entry.sha256) {
2256
+ throw new Error(`Artifact history archive entry checksum is invalid: ${archiveId}/${entry.path}.`);
2257
+ }
2258
+ entryBytes += entry.size_bytes;
2259
+ digest.update(entry.path);
2260
+ digest.update("\0");
2261
+ digest.update(content);
2262
+ digest.update("\0");
2263
+ let record;
2264
+ try {
2265
+ record = options.yaml.parse(content.toString("utf8"));
2266
+ } catch {
2267
+ throw new Error(`Artifact history archive entry is unreadable: ${archiveId}/${entry.path}.`);
2268
+ }
2269
+ const validated = validateEventShardRecord(record, "artifact-registry", entry.path);
2270
+ if (validated.revision !== entry.revision) {
2271
+ throw new Error(`Artifact history archive entry revision is invalid: ${archiveId}/${entry.path}.`);
2272
+ }
2273
+ return validated;
2274
+ });
2275
+ if (digest.digest("hex") !== manifest.source_history_root_digest
2276
+ || entryBytes !== manifest.loose_bytes
2277
+ || records.length !== manifest.coverage?.event_count
2278
+ || records[0]?.revision !== manifest.coverage?.first_revision
2279
+ || records.at(-1)?.revision !== manifest.coverage?.last_revision
2280
+ || records.some((record, index) => record.revision !== coverage.first_revision + index)) {
2281
+ throw new Error(`Artifact history archive coverage is invalid: ${archiveId}.`);
2282
+ }
2283
+ const replayed = replayStreamRecords("artifact-registry", records, options);
2284
+ const anchorFile = path.join(files.historySnapshots, archiveId, "anchor.yaml");
2285
+ const snapshotFile = path.join(files.historySnapshots, archiveId, "artifact-registry.yaml");
2286
+ const anchor = readYaml(anchorFile, options.yaml, `artifact history archive anchor ${archiveId}`);
2287
+ const snapshot = normalizeArtifactRegistry(readYaml(
2288
+ snapshotFile,
2289
+ options.yaml,
2290
+ `artifact history archive snapshot ${archiveId}`,
2291
+ ));
2292
+ if (anchor.anchor_checksum !== manifest.anchor_checksum
2293
+ || structuredDocumentChecksum(anchor, "anchor_checksum", options.yaml.stringify)
2294
+ !== anchor.anchor_checksum
2295
+ || anchor.streams?.["artifact-registry"]?.revision !== manifest.registry_revision
2296
+ || anchor.streams?.["artifact-registry"]?.view_checksum !== manifest.registry_checksum
2297
+ || sha256(fs.readFileSync(snapshotFile)) !== manifest.registry_checksum
2298
+ || JSON.stringify(stableValue(normalizeArtifactRegistry(replayed)))
2299
+ !== JSON.stringify(stableValue(snapshot))) {
2300
+ throw new Error(`Artifact history archive replay does not match its anchor: ${archiveId}.`);
2301
+ }
2302
+ return { manifest, manifestFile, archiveFile, bundle };
2303
+ });
2304
+ }
2305
+
2306
+ function restoreCompactionStaging(files, directory, plan) {
2307
+ const shardDirectory = path.join(files.eventShards, "artifact-registry");
2308
+ fs.mkdirSync(shardDirectory, { recursive: true });
2309
+ for (const entry of plan.entries) {
2310
+ const staged = path.join(directory, entry.path);
2311
+ if (!fs.existsSync(staged)) continue;
2312
+ if (sha256(fs.readFileSync(staged)) !== entry.sha256) {
2313
+ throw new Error(`Artifact history compact staged shard checksum mismatch: ${entry.path}.`);
2314
+ }
2315
+ const destination = path.join(shardDirectory, entry.path);
2316
+ if (fs.existsSync(destination)) {
2317
+ if (sha256(fs.readFileSync(destination)) !== entry.sha256) {
2318
+ throw new Error(`Artifact history compact recovery destination conflicts: ${entry.path}.`);
2319
+ }
2320
+ fs.rmSync(staged, { force: true });
2321
+ } else {
2322
+ fs.renameSync(staged, destination);
2323
+ }
2324
+ }
2325
+ }
2326
+
2327
+ function recoverArtifactHistoryCompaction(files, apply, options) {
2328
+ if (!fs.existsSync(files.compactRuntime)) return 0;
2329
+ const pending = fs.readdirSync(files.compactRuntime, { withFileTypes: true })
2330
+ .filter((entry) => entry.isDirectory())
2331
+ .map((entry) => path.join(files.compactRuntime, entry.name));
2332
+ if (pending.length && !apply) {
2333
+ throw new Error("An interrupted artifact history compact requires explicit apply recovery.");
2334
+ }
2335
+ let recovered = 0;
2336
+ for (const directory of pending) {
2337
+ const planFile = path.join(directory, "plan.json");
2338
+ if (!fs.existsSync(planFile)) {
2339
+ const interruptedPlanWrites = fs.readdirSync(directory, { withFileTypes: true });
2340
+ if (interruptedPlanWrites.every((entry) =>
2341
+ entry.isFile() && /^plan\.json\.\d+\.\d+\.tmp$/.test(entry.name))) {
2342
+ fs.rmSync(directory, { recursive: true, force: true });
2343
+ recovered += 1;
2344
+ continue;
2345
+ }
2346
+ throw new Error(`Artifact history compact recovery plan is invalid: ${directory}.`);
2347
+ }
2348
+ let plan;
2349
+ try {
2350
+ plan = JSON.parse(fs.readFileSync(planFile, "utf8"));
2351
+ } catch {
2352
+ throw new Error(`Artifact history compact recovery plan is invalid: ${directory}.`);
2353
+ }
2354
+ if (plan?.schema_version !== 1 || !plan.archive_id
2355
+ || !/^[a-f0-9]{64}$/.test(plan.archive_checksum || "")
2356
+ || !Array.isArray(plan.entries) || !plan.entries.length
2357
+ || plan.entries.some((entry) => !/^[^/\\]+\.ya?ml$/i.test(entry?.path || "")
2358
+ || !/^[a-f0-9]{64}$/.test(entry?.sha256 || ""))) {
2359
+ throw new Error(`Artifact history compact recovery plan is invalid: ${directory}.`);
2360
+ }
2361
+ const plannedEntries = new Map(plan.entries.map((entry) => [entry.path, entry.sha256]));
2362
+ const stagedEntries = fs.readdirSync(directory, { withFileTypes: true })
2363
+ .filter((entry) => entry.name !== "plan.json");
2364
+ if (plannedEntries.size !== plan.entries.length
2365
+ || stagedEntries.some((entry) => !entry.isFile() || !plannedEntries.has(entry.name))) {
2366
+ throw new Error(`Artifact history compact recovery plan is invalid: ${directory}.`);
2367
+ }
2368
+ const changedStagedEntry = stagedEntries.find((entry) =>
2369
+ sha256(fs.readFileSync(path.join(directory, entry.name))) !== plannedEntries.get(entry.name));
2370
+ if (changedStagedEntry) {
2371
+ throw new Error(`Artifact history compact staged shard checksum mismatch: ${changedStagedEntry.name}.`);
2372
+ }
2373
+ let archive = null;
2374
+ try {
2375
+ archive = readArtifactHistoryArchives(files, options)
2376
+ .find(({ manifest }) => manifest.archive_id === plan.archive_id) || null;
2377
+ } catch {
2378
+ archive = null;
2379
+ }
2380
+ const archivedEntries = new Map(
2381
+ (archive?.bundle.entries || []).map((entry) => [entry.path, entry.sha256]),
2382
+ );
2383
+ const archiveMatches = archive?.manifest.archive_checksum === plan.archive_checksum
2384
+ && plan.entries.every((entry) => archivedEntries.get(entry.path) === entry.sha256);
2385
+ if (!archiveMatches) {
2386
+ restoreCompactionStaging(files, directory, plan);
2387
+ fs.rmSync(directory, { recursive: true, force: true });
2388
+ throw new Error(`Artifact history compact recovery plan does not match its verified archive: ${plan.archive_id}.`);
2389
+ }
2390
+ fs.rmSync(directory, { recursive: true, force: true });
2391
+ recovered += 1;
2392
+ }
2393
+ return recovered;
2394
+ }
2395
+
2396
+ function removeCompactedLooseEntries(files, archiveId, archiveChecksum, entries) {
2397
+ if (!entries.length) return;
2398
+ const stagingDirectory = path.join(files.compactRuntime, crypto.randomUUID());
2399
+ fs.mkdirSync(stagingDirectory, { recursive: true });
2400
+ atomicWrite(path.join(stagingDirectory, "plan.json"), `${JSON.stringify({
2401
+ schema_version: 1,
2402
+ archive_id: archiveId,
2403
+ archive_checksum: archiveChecksum,
2404
+ entries: entries.map((entry) => ({ path: entry.path, sha256: entry.sha256 })),
2405
+ }, null, 2)}\n`);
2406
+ const shardDirectory = path.join(files.eventShards, "artifact-registry");
2407
+ try {
2408
+ for (const entry of entries) {
2409
+ const source = path.join(shardDirectory, entry.path);
2410
+ if (sha256(fs.readFileSync(source)) !== entry.sha256) {
2411
+ throw new Error(`Artifact history shard changed during compact: ${entry.path}.`);
2412
+ }
2413
+ fs.renameSync(source, path.join(stagingDirectory, entry.path));
2414
+ }
2415
+ fs.rmSync(stagingDirectory, { recursive: true, force: true });
2416
+ } catch (error) {
2417
+ restoreCompactionStaging(files, stagingDirectory, { entries });
2418
+ fs.rmSync(stagingDirectory, { recursive: true, force: true });
2419
+ throw error;
2420
+ }
2421
+ }
2422
+
2423
+ function compactArtifactHistory(files, flags, options) {
2424
+ const apply = flags.has("--apply");
2425
+ const action = () => {
2426
+ assertPathInside(files.root, files.artifactHistoryArchives);
2427
+ assertPathInside(files.root, files.compactRuntime);
2428
+ assertPathInside(files.root, path.join(files.eventShards, "artifact-registry"));
2429
+ const recoveredCompactionCount = recoverArtifactHistoryCompaction(files, apply, options);
2430
+ const trusted = readLatestHistoryAnchor(files, options);
2431
+ if (!trusted) throw new Error("Legacy artifact history compact requires a trusted history anchor.");
2432
+ const existingArchive = readArtifactHistoryArchives(files, options)
2433
+ .find(({ manifest }) => manifest.archive_id === trusted.anchor.anchor_id);
2434
+ if (existingArchive) {
2435
+ verifyFormalState(files, options, { history: true });
2436
+ const shardDirectory = path.join(files.eventShards, "artifact-registry");
2437
+ const looseEntries = existingArchive.bundle.entries.filter((entry) => {
2438
+ const looseFile = path.join(shardDirectory, entry.path);
2439
+ if (!fs.existsSync(looseFile)) return false;
2440
+ if (sha256(fs.readFileSync(looseFile)) !== entry.sha256) {
2441
+ throw new Error(`Artifact history loose shard conflicts with verified archive: ${entry.path}.`);
2442
+ }
2443
+ return true;
2444
+ });
2445
+ const looseBytes = looseEntries.reduce((total, entry) => total + entry.size_bytes, 0);
2446
+ const manifestBytes = fs.statSync(existingArchive.manifestFile).size;
2447
+ const result = {
2448
+ command: "compact",
2449
+ anchor_id: existingArchive.manifest.anchor_id,
2450
+ anchor_checksum: existingArchive.manifest.anchor_checksum,
2451
+ source_history_root_digest: existingArchive.manifest.source_history_root_digest,
2452
+ coverage: existingArchive.manifest.coverage,
2453
+ loose_file_count: looseEntries.length,
2454
+ loose_bytes: looseBytes,
2455
+ compressed_bundle_bytes: existingArchive.manifest.compressed_bundle_bytes,
2456
+ manifest_bytes: manifestBytes,
2457
+ archive_bytes: existingArchive.manifest.compressed_bundle_bytes + manifestBytes,
2458
+ expected_freed_bytes: looseBytes,
2459
+ archive_path: existingArchive.manifest.archive_path,
2460
+ manifest_path: portableReference(files, existingArchive.manifestFile),
2461
+ mode: apply ? "apply" : "dry-run",
2462
+ applied: false,
2463
+ deduplicated: true,
2464
+ recovered_compaction_count: recoveredCompactionCount,
2465
+ };
2466
+ if (!apply || !looseEntries.length) return result;
2467
+ removeCompactedLooseEntries(
2468
+ files,
2469
+ trusted.anchor.anchor_id,
2470
+ existingArchive.manifest.archive_checksum,
2471
+ looseEntries,
2472
+ );
2473
+ return { ...result, applied: true };
2474
+ }
2475
+ const plan = legacyArtifactHistoryArchive(files, trusted, options);
2476
+ const result = {
2477
+ ...plan.report,
2478
+ mode: apply ? "apply" : "dry-run",
2479
+ applied: false,
2480
+ deduplicated: false,
2481
+ recovered_compaction_count: recoveredCompactionCount,
2482
+ };
2483
+ if (!apply) return result;
2484
+ if (fs.existsSync(plan.manifestFile)) {
2485
+ throw new Error(`Artifact history archive already exists: ${trusted.anchor.anchor_id}.`);
2486
+ }
2487
+ fs.mkdirSync(files.artifactHistoryArchives, { recursive: true });
2488
+ const orphanArchiveExists = fs.existsSync(plan.archiveFile);
2489
+ if (orphanArchiveExists
2490
+ && (fs.statSync(plan.archiveFile).size !== plan.archiveBytes.length
2491
+ || sha256(fs.readFileSync(plan.archiveFile)) !== plan.manifest.archive_checksum)) {
2492
+ throw new Error(`Artifact history archive conflicts with the compact plan: ${trusted.anchor.anchor_id}.`);
2493
+ }
2494
+ let archiveWritten = false;
2495
+ let manifestWritten = false;
2496
+ try {
2497
+ if (!orphanArchiveExists) {
2498
+ atomicWrite(plan.archiveFile, plan.archiveBytes);
2499
+ archiveWritten = true;
2500
+ }
2501
+ atomicWrite(plan.manifestFile, plan.manifestContent);
2502
+ manifestWritten = true;
2503
+ const verified = readArtifactHistoryArchives(files, options);
2504
+ if (!verified.some(({ manifest }) => manifest.archive_id === trusted.anchor.anchor_id)) {
2505
+ throw new Error(`Artifact history archive verification failed: ${trusted.anchor.anchor_id}.`);
2506
+ }
2507
+ } catch (error) {
2508
+ if (manifestWritten) fs.rmSync(plan.manifestFile, { force: true });
2509
+ if (archiveWritten) fs.rmSync(plan.archiveFile, { force: true });
2510
+ throw error;
2511
+ }
2512
+
2513
+ removeCompactedLooseEntries(
2514
+ files,
2515
+ trusted.anchor.anchor_id,
2516
+ plan.manifest.archive_checksum,
2517
+ plan.entries,
2518
+ );
2519
+ return { ...result, applied: true };
2520
+ };
2521
+ return apply
2522
+ ? withLock(files, "artifact-history-compact", action)
2523
+ : withZeroWriteFormalState(files, action);
2524
+ }
2525
+
2526
+ function pruneArtifactHistory(files, flags, options) {
2527
+ const apply = flags.has("--apply");
2528
+ const action = () => {
2529
+ const recovered_prune_count = recoverArtifactPrune(files, apply, options);
2530
+ const trusted = readLatestHistoryAnchor(files, options);
2531
+ if (!trusted) throw new Error("Artifact prune requires a trusted history anchor.");
2532
+ verifyFormalState(files, options, { history: true });
2533
+ const anchoredRevision = trusted.anchor.streams["artifact-registry"].revision;
2534
+ const shardDirectory = path.join(files.eventShards, "artifact-registry");
2535
+ const eligibleShards = fs.existsSync(shardDirectory)
2536
+ ? fs.readdirSync(shardDirectory)
2537
+ .filter((name) => /\.ya?ml$/i.test(name))
2538
+ .map((name) => ({ name, record: readEventShardRecord(files, options, "artifact-registry", name) }))
2539
+ .filter(({ record }) => record.revision <= anchoredRevision)
2540
+ .sort((left, right) => left.record.revision - right.record.revision)
2541
+ : [];
2542
+ const result = {
2543
+ command: "artifact prune",
2544
+ mode: apply ? "apply" : "dry-run",
2545
+ applied: false,
2546
+ recovered_prune_count,
2547
+ anchor_id: trusted.anchor.anchor_id,
2548
+ anchor_checksum: trusted.anchor.anchor_checksum,
2549
+ pruned_through_revision: anchoredRevision,
2550
+ retained_from_revision: anchoredRevision + 1,
2551
+ eligible_shard_count: eligibleShards.length,
2552
+ eligible_shards: eligibleShards.map(({ name }) =>
2553
+ path.relative(files.root, path.join(shardDirectory, name)).replaceAll("\\", "/")),
2554
+ };
2555
+ if (!apply || !eligibleShards.length) return result;
2556
+
2557
+ const deletedShards = eligibleShards.map(({ name }) => ({
2558
+ name,
2559
+ checksum: fileChecksum(path.join(shardDirectory, name)),
2560
+ }));
2561
+ const timestamp = (options.now || (() => new Date().toISOString()))();
2562
+ const receiptBase = stableValue({
2563
+ schema_version: 1,
2564
+ stream: "artifact-registry",
2565
+ anchor_id: trusted.anchor.anchor_id,
2566
+ anchor_checksum: trusted.anchor.anchor_checksum,
2567
+ pruned_through_revision: anchoredRevision,
2568
+ retained_from_revision: anchoredRevision + 1,
2569
+ deleted_shard_count: deletedShards.length,
2570
+ deleted_shards_checksum: sha256(JSON.stringify(stableValue(deletedShards))),
2571
+ applied_at: timestamp,
2572
+ });
2573
+ const receipt = {
2574
+ ...receiptBase,
2575
+ receipt_checksum: sha256(options.yaml.stringify(receiptBase)),
2576
+ };
2577
+ const receiptFile = path.join(files.artifactRetention, `${trusted.anchor.anchor_id}.yaml`);
2578
+ if (fs.existsSync(receiptFile)) {
2579
+ throw new Error(`Artifact prune receipt already exists: ${trusted.anchor.anchor_id}.`);
2580
+ }
2581
+
2582
+ const stagingDirectory = path.join(files.pruneRuntime, crypto.randomUUID());
2583
+ fs.mkdirSync(stagingDirectory, { recursive: true });
2584
+ atomicWrite(path.join(stagingDirectory, "plan.json"), `${JSON.stringify({
2585
+ schema_version: 1,
2586
+ anchor_id: trusted.anchor.anchor_id,
2587
+ shards: deletedShards,
2588
+ }, null, 2)}\n`);
2589
+ let receiptWritten = false;
2590
+ try {
2591
+ for (const { name, checksum } of deletedShards) {
2592
+ const source = path.join(shardDirectory, name);
2593
+ if (fileChecksum(source) !== checksum) throw new Error(`Artifact shard changed during prune: ${name}.`);
2594
+ fs.renameSync(source, path.join(stagingDirectory, name));
2595
+ }
2596
+ atomicWrite(receiptFile, options.yaml.stringify(stableValue(receipt)));
2597
+ receiptWritten = true;
2598
+ fs.rmSync(stagingDirectory, { recursive: true, force: true });
2599
+ } catch (error) {
2600
+ if (!receiptWritten) {
2601
+ for (const { name } of deletedShards) {
2602
+ const staged = path.join(stagingDirectory, name);
2603
+ if (fs.existsSync(staged)) fs.renameSync(staged, path.join(shardDirectory, name));
2604
+ }
2605
+ fs.rmSync(stagingDirectory, { recursive: true, force: true });
2606
+ }
2607
+ throw error;
2608
+ }
2609
+ verifyFormalState(files, options, { history: true, sinceAnchor: true });
583
2610
  return {
584
- command: "artifact status",
585
- revision: registry.revision,
586
- artifact,
587
- artifact_registry: registry,
2611
+ ...result,
2612
+ applied: true,
2613
+ deleted_shard_count: deletedShards.length,
2614
+ receipt_path: path.relative(files.root, receiptFile).replaceAll("\\", "/"),
2615
+ receipt_checksum: receipt.receipt_checksum,
588
2616
  };
589
- });
2617
+ };
2618
+ return apply
2619
+ ? withLock(files, "artifact-prune", action)
2620
+ : withZeroWriteFormalState(files, action);
590
2621
  }
591
2622
 
592
2623
  function allowedCompletionStatuses(node) {
593
- if (node === "12-version-closeout") return new Set(["release-ready"]);
2624
+ if (node === "06-version-closeout" || node === "12-version-closeout") {
2625
+ return new Set(["release-ready"]);
2626
+ }
594
2627
  if (node === "R08-production-deployment") return new Set(["deployed"]);
595
2628
  if (node === "R09-production-verification") return new Set(["passed"]);
596
2629
  if (node === "R10-release-archive") return new Set(["released", "archived"]);
597
2630
  return SUCCESSFUL_ARTIFACT_STATUSES;
598
2631
  }
599
2632
 
2633
+ function nodeModel(node) {
2634
+ if (SEVEN_NODE_DEVELOPMENT_ID_SET.has(node)) return "development-seven-node";
2635
+ if (LEGACY_DEVELOPMENT_ID_SET.has(node)) return "development-legacy-13";
2636
+ if (RELEASE_NODE_ID_SET.has(node)) return "release-r00-r10";
2637
+ return "legacy-production-read-only";
2638
+ }
2639
+
2640
+ function nodeContractCatalog() {
2641
+ return Object.fromEntries([...NODE_RULES].map(([node, rule]) => [node, {
2642
+ node,
2643
+ model: nodeModel(node),
2644
+ scope: rule.scope,
2645
+ prerequisites: [...rule.prerequisites],
2646
+ internal_gates: [...rule.gates],
2647
+ completion: {
2648
+ requires_scope_manifest: rule.scope === "development" && SCOPED_COMPLETION_NODES.has(node),
2649
+ requires_candidate: rule.scope === "development" && CANDIDATE_REQUIRED_NODES.has(node),
2650
+ binds_candidate_on_start: CANDIDATE_BOUND_NODES.has(node),
2651
+ requires_candidate_assurance: node === "06-version-closeout",
2652
+ allowed_evidence_statuses: [...allowedCompletionStatuses(node)].sort(),
2653
+ },
2654
+ }]));
2655
+ }
2656
+
600
2657
  function satisfiesMandatoryGate(node, decision) {
601
2658
  return decision?.decision === "approved"
602
- || (node === "04-experience-design" && decision?.decision === "not-required");
2659
+ || (UX_GATE_NODES.has(node) && decision?.decision === "not-required");
603
2660
  }
604
2661
 
605
2662
  function isProductTerminalEvidence(artifact) {
606
- return artifact?.node === "02-product-definition"
2663
+ return PRODUCT_TERMINAL_NODES.has(artifact?.node)
607
2664
  && /terminal/i.test(artifact.artifact_type || "")
608
2665
  && SUCCESSFUL_ARTIFACT_STATUSES.has(artifact.status);
609
2666
  }
610
2667
 
611
- function persistScopeMutation(files, state, scope, timestamp, transition, eventRecord, options) {
2668
+ function artifactHandoffMutation(files, transition, timestamp, options) {
2669
+ const artifactIds = [...new Set(transition.evidence || [])];
2670
+ if (!artifactIds.length) return null;
2671
+ const registry = normalizeArtifactRegistry(
2672
+ readYaml(files.artifactRegistry, options.yaml, "artifact-registry.yaml"),
2673
+ );
2674
+ for (const artifactId of artifactIds) {
2675
+ const artifact = registry.artifacts.find((item) => (item.artifact_id || item.id) === artifactId);
2676
+ if (!artifact) throw new Error(`Handoff artifact is not registered: ${artifactId}`);
2677
+ }
2678
+ const handoff = stableValue({
2679
+ workflow_event: transition.event,
2680
+ scope: transition.scope,
2681
+ iteration_id: transition.iteration_id,
2682
+ artifact_ids: artifactIds,
2683
+ ...(transition.node ? { node: transition.node } : {}),
2684
+ ...(transition.gate_id ? { gate_id: transition.gate_id } : {}),
2685
+ occurred_at: timestamp,
2686
+ });
2687
+ registry.handoffs = [
2688
+ ...(Array.isArray(registry.handoffs) ? registry.handoffs : []),
2689
+ handoff,
2690
+ ];
2691
+ const beforeRevision = registry.revision;
2692
+ registry.revision += 1;
2693
+ registry.updated_at = timestamp;
2694
+ const event = {
2695
+ type: "artifact-handoff-recorded",
2696
+ occurred_at: timestamp,
2697
+ workflow_event: transition.event,
2698
+ scope: transition.scope,
2699
+ iteration_id: transition.iteration_id,
2700
+ artifact_ids: artifactIds,
2701
+ };
2702
+ return {
2703
+ stream: "artifact-registry",
2704
+ file: files.artifactRegistry,
2705
+ value: registry,
2706
+ event,
2707
+ transaction: artifactRegistryTransaction(event, registry, beforeRevision, [], [handoff]),
2708
+ };
2709
+ }
2710
+
2711
+ function persistScopeMutation(files, context, options) {
2712
+ const { state, scope, timestamp, transition, extraWrites = [] } = context;
2713
+ const beforeRevision = state.revision;
612
2714
  state.schema_version = 2;
613
2715
  state.revision += 1;
614
2716
  scope.revision += 1;
@@ -616,23 +2718,199 @@ function persistScopeMutation(files, state, scope, timestamp, transition, eventR
616
2718
  state.updated_at = timestamp;
617
2719
  scope.last_transition = transition;
618
2720
  state.last_transition = transition;
619
- writeStream(files, "workflow-state", files.workflowState, state, eventRecord, options);
2721
+ const { event: type, ...eventDetails } = transition;
2722
+ const eventRecord = { type, ...eventDetails };
2723
+ const mutations = [{
2724
+ stream: "workflow-state",
2725
+ file: files.workflowState,
2726
+ value: state,
2727
+ event: eventRecord,
2728
+ transaction: workflowStateTransaction(eventRecord, state, transition.scope, beforeRevision),
2729
+ }];
2730
+ const handoffMutation = artifactHandoffMutation(files, transition, timestamp, options);
2731
+ if (handoffMutation) mutations.push(handoffMutation);
2732
+ commitFormalTransaction(files, mutations, extraWrites, options);
620
2733
  exposeLegacyScopeAliases(state);
621
2734
  }
622
2735
 
623
- function transitionWorkflow(files, flags, options) {
624
- const expected = expectedRevision(flags);
2736
+ function scopeManifestFromFlags(flags, scope, timestamp, amendment) {
2737
+ const manifestId = requiredFlag(flags, "--manifest-id");
2738
+ if (scope.scope_manifests.some((manifest) => manifest?.manifest_id === manifestId)) {
2739
+ throw new Error(`Scope manifest ID already exists: ${manifestId}.`);
2740
+ }
2741
+ const current = currentScopeManifest(scope);
2742
+ if (amendment) {
2743
+ if (!current) throw new Error("scope amend requires an existing frozen scope manifest.");
2744
+ const supersedes = requiredFlag(flags, "--supersedes");
2745
+ if (supersedes !== current.manifest_id) {
2746
+ throw new Error(`scope amend must supersede current manifest ${current.manifest_id}.`);
2747
+ }
2748
+ } else if (current) {
2749
+ throw new Error(`Scope is already frozen by ${current.manifest_id}; use scope amend.`);
2750
+ }
2751
+ const approvedBy = requiredFlag(flags, "--approved-by");
2752
+ const reason = requiredFlag(flags, "--reason");
2753
+ const manifest = {
2754
+ manifest_id: manifestId,
2755
+ iteration_id: scope.scope_id,
2756
+ revision: Number(current?.revision || 0) + 1,
2757
+ status: "frozen",
2758
+ supersedes: amendment ? current.manifest_id : null,
2759
+ slices: uniqueSortedValues(repeatedFlag(flags, "--slice"), "--slice"),
2760
+ requirements: uniqueSortedValues(repeatedFlag(flags, "--requirement"), "--requirement"),
2761
+ acceptance: uniqueSortedValues(repeatedFlag(flags, "--acceptance"), "--acceptance"),
2762
+ required_tasks: scopedReferences(flags, "--required-task", "task_id"),
2763
+ required_artifacts: scopedReferences(flags, "--required-artifact", "artifact_id"),
2764
+ approved_by: approvedBy,
2765
+ approval_reason: reason,
2766
+ frozen_at: timestamp,
2767
+ };
2768
+ manifest.manifest_checksum = scopeManifestChecksum(manifest);
2769
+ return manifest;
2770
+ }
2771
+
2772
+ function mutateScopeManifest(files, flags, options, amendment = false) {
2773
+ return withLock(files, "workflow-state", () => {
2774
+ const state = normalizeWorkflowState(readYaml(files.workflowState, options.yaml, "workflow-state.yaml"));
2775
+ assertWorkflowRevision(flags, state, "development");
2776
+ const scope = state.development_state;
2777
+ const iterationId = requiredFlag(flags, "--iteration-id");
2778
+ if (scope.scope_id !== iterationId) {
2779
+ throw new Error(`Iteration mismatch for development scope: state is ${scope.scope_id}, command is ${iterationId}.`);
2780
+ }
2781
+ if (!amendment
2782
+ && ![...SCOPE_FREEZE_NODES].some((node) =>
2783
+ scope.active_nodes.includes(node) || scope.completed_nodes.includes(node))) {
2784
+ throw new Error(
2785
+ "scope freeze requires Delivery Readiness (03-delivery-readiness or legacy 07-iteration-planning) to be active or completed.",
2786
+ );
2787
+ }
2788
+ const timestamp = (options.now || (() => new Date().toISOString()))();
2789
+ const manifest = scopeManifestFromFlags(flags, scope, timestamp, amendment);
2790
+ scope.scope_contract_version = 1;
2791
+ scope.scope_manifests = [...scope.scope_manifests, manifest];
2792
+ scope.current_scope_manifest_id = manifest.manifest_id;
2793
+ const transition = {
2794
+ event: amendment ? "scope-amended" : "scope-frozen",
2795
+ scope: "development",
2796
+ iteration_id: iterationId,
2797
+ manifest_id: manifest.manifest_id,
2798
+ supersedes: manifest.supersedes,
2799
+ approved_by: manifest.approved_by,
2800
+ reason: manifest.approval_reason,
2801
+ occurred_at: timestamp,
2802
+ };
2803
+ persistScopeMutation(files, { state, scope, timestamp, transition }, options);
2804
+ return {
2805
+ command: amendment ? "scope amend" : "scope freeze",
2806
+ revision: state.revision,
2807
+ scope_revision: scope.revision,
2808
+ scope_manifest: manifest,
2809
+ workflow_state: state,
2810
+ };
2811
+ }, { waitForActive: flags.has("--expected-scope-revision") });
2812
+ }
2813
+
2814
+ function recordScopeWarning(files, flags, options) {
625
2815
  return withLock(files, "workflow-state", () => {
626
2816
  const state = normalizeWorkflowState(readYaml(files.workflowState, options.yaml, "workflow-state.yaml"));
627
- if (state.revision !== expected) {
628
- throw new Error(`Workflow state revision conflict: expected ${expected}, actual ${state.revision}.`);
2817
+ assertWorkflowRevision(flags, state, "development");
2818
+ const scope = state.development_state;
2819
+ const iterationId = requiredFlag(flags, "--iteration-id");
2820
+ if (scope.scope_id !== iterationId) {
2821
+ throw new Error(`Iteration mismatch for development scope: state is ${scope.scope_id}, command is ${iterationId}.`);
2822
+ }
2823
+ const manifest = currentScopeManifest(scope);
2824
+ if (!manifest) throw new Error("scope warn requires a frozen scope manifest.");
2825
+ const metric = requiredFlag(flags, "--metric").toLowerCase();
2826
+ if (!SLICE_BUDGET_LIMITS.has(metric)) {
2827
+ throw new Error(`Unsupported Slice budget metric: ${metric}.`);
629
2828
  }
2829
+ const observed = Number(requiredFlag(flags, "--observed"));
2830
+ const requestedLimit = optionalFlag(flags, "--limit");
2831
+ const limit = requestedLimit === null ? SLICE_BUDGET_LIMITS.get(metric) : Number(requestedLimit);
2832
+ if (!Number.isFinite(observed) || observed < 0 || !Number.isFinite(limit) || limit < 0) {
2833
+ throw new Error("Slice budget observed and limit values must be non-negative numbers.");
2834
+ }
2835
+ if (observed <= limit) throw new Error(`Slice budget is not exceeded for ${metric}: ${observed} <= ${limit}.`);
2836
+ const reason = requiredFlag(flags, "--reason");
2837
+ const timestamp = (options.now || (() => new Date().toISOString()))();
2838
+ const warning = stableValue({
2839
+ warning_id: optionalFlag(flags, "--warning-id")
2840
+ || `WARN-${sha256(`${iterationId}:${metric}:${observed}:${limit}:${scope.scope_warnings.length + 1}`).slice(0, 20)}`,
2841
+ iteration_id: iterationId,
2842
+ scope_manifest_id: manifest.manifest_id,
2843
+ scope_manifest_checksum: manifest.manifest_checksum,
2844
+ metric,
2845
+ observed,
2846
+ limit,
2847
+ exceeded: true,
2848
+ action: "warn-only",
2849
+ reslice_performed: false,
2850
+ reason,
2851
+ recorded_at: timestamp,
2852
+ });
2853
+ if (scope.scope_warnings.some((item) => item?.warning_id === warning.warning_id)) {
2854
+ throw new Error(`Scope warning ID already exists: ${warning.warning_id}.`);
2855
+ }
2856
+ scope.scope_warnings = [...scope.scope_warnings, warning];
2857
+ const transition = {
2858
+ event: "scope-budget-warning",
2859
+ scope: "development",
2860
+ iteration_id: iterationId,
2861
+ manifest_id: manifest.manifest_id,
2862
+ warning_id: warning.warning_id,
2863
+ metric,
2864
+ observed,
2865
+ limit,
2866
+ action: warning.action,
2867
+ occurred_at: timestamp,
2868
+ };
2869
+ persistScopeMutation(files, { state, scope, timestamp, transition }, options);
2870
+ return {
2871
+ command: "scope warn",
2872
+ revision: state.revision,
2873
+ scope_revision: scope.revision,
2874
+ warning,
2875
+ scope_manifest: manifest,
2876
+ workflow_state: state,
2877
+ };
2878
+ }, { waitForActive: flags.has("--expected-scope-revision") });
2879
+ }
2880
+
2881
+ function completionReceiptFromProjection(scope, node, projection, evidence, timestamp) {
2882
+ const manifest = currentScopeManifest(scope);
2883
+ const receipt = stableValue({
2884
+ receipt_id: `RECEIPT-${sha256(`${scope.scope_id}:${node}:${projection.node_attempt}:${manifest?.manifest_id || "legacy"}`).slice(0, 24)}`,
2885
+ iteration_id: scope.scope_id,
2886
+ node,
2887
+ node_attempt: projection.node_attempt,
2888
+ scope_manifest_id: manifest?.manifest_id || null,
2889
+ scope_manifest_revision: manifest?.revision || null,
2890
+ scope_manifest_checksum: manifest?.manifest_checksum || null,
2891
+ required_task_ids: projection.required_task_ids,
2892
+ required_artifact_ids: projection.required_artifact_ids,
2893
+ task_facts: projection.task_facts,
2894
+ artifact_facts: projection.artifact_facts,
2895
+ gate_facts: projection.gate_facts,
2896
+ candidate_id: projection.candidate_id,
2897
+ blocking_defect_ids: projection.blocking_defect_ids,
2898
+ completion_evidence: [...new Set(evidence)].sort(),
2899
+ completed_at: timestamp,
2900
+ });
2901
+ receipt.receipt_checksum = completionReceiptChecksum(receipt);
2902
+ return receipt;
2903
+ }
2904
+
2905
+ function transitionWorkflow(files, flags, options) {
2906
+ return withLock(files, "workflow-state", () => {
2907
+ const state = normalizeWorkflowState(readYaml(files.workflowState, options.yaml, "workflow-state.yaml"));
630
2908
  const event = requiredFlag(flags, "--event");
631
2909
  const iterationId = requiredFlag(flags, "--iteration-id");
632
2910
  const evidence = repeatedFlag(flags, "--evidence");
633
2911
  const timestamp = (options.now || (() => new Date().toISOString()))();
634
2912
 
635
- const persist = (scopeName, details = {}) => {
2913
+ const persist = (scopeName, details = {}, extraWrites = []) => {
636
2914
  const scope = scopeState(state, scopeName);
637
2915
  const transition = {
638
2916
  event,
@@ -642,19 +2920,19 @@ function transitionWorkflow(files, flags, options) {
642
2920
  ...details,
643
2921
  occurred_at: timestamp,
644
2922
  };
645
- persistScopeMutation(files, state, scope, timestamp, transition, {
646
- type: event,
647
- occurred_at: timestamp,
648
- scope: scopeName,
649
- iteration_id: iterationId,
650
- evidence,
651
- ...details,
2923
+ persistScopeMutation(files, {
2924
+ state,
2925
+ scope,
2926
+ timestamp,
2927
+ transition,
2928
+ extraWrites,
652
2929
  }, options);
653
2930
  return { command: "transition", event, revision: state.revision, scope_revision: scope.revision, workflow_state: state };
654
2931
  };
655
2932
 
656
2933
  if (event === "iteration-started" || event === "release-started") {
657
2934
  const scopeName = event === "release-started" ? "release" : "development";
2935
+ assertWorkflowRevision(flags, state, scopeName);
658
2936
  const current = scopeState(state, scopeName);
659
2937
  if (current.scope_id === iterationId) throw new Error(`${scopeName} scope is already current: ${iterationId}`);
660
2938
  if (current.active_nodes.length || current.blocked_nodes.length || current.pending_gates.length) {
@@ -665,12 +2943,14 @@ function transitionWorkflow(files, flags, options) {
665
2943
  state[scopeName === "release" ? "release_state" : "development_state"] = normalizeScope({
666
2944
  scope_id: iterationId,
667
2945
  revision: nextRevision,
2946
+ ...(scopeName === "development" ? { scope_contract_version: 1 } : {}),
668
2947
  });
669
2948
  const project = readYaml(files.project, options.yaml, "project.yaml");
670
2949
  if (scopeName === "development") project.current_iteration_id = iterationId;
671
2950
  else project.current_release_id = iterationId;
672
- atomicWrite(files.project, options.yaml.stringify(project));
673
- return persist(scopeName);
2951
+ return persist(scopeName, {}, [
2952
+ { file: files.project, content: options.yaml.stringify(project) },
2953
+ ]);
674
2954
  }
675
2955
 
676
2956
  if (event === "candidate-selected" || event === "release-selected") {
@@ -678,13 +2958,18 @@ function transitionWorkflow(files, flags, options) {
678
2958
  const registry = normalizeArtifactRegistry(readYaml(files.artifactRegistry, options.yaml, "artifact-registry.yaml"));
679
2959
  const artifact = registry.artifacts.find((item) => (item.artifact_id || item.id) === artifactId);
680
2960
  if (!artifact) throw new Error(`Selected artifact is not registered: ${artifactId}`);
2961
+ assertNotLegacyDevelopmentNode(artifact.node);
2962
+ if (artifactSuperseder(registry, artifact)) {
2963
+ throw new Error(`Selected artifact is superseded: ${artifactId}`);
2964
+ }
681
2965
  const expectedNodes = event === "candidate-selected"
682
- ? new Set(["09-development", "R04-release-candidate"])
2966
+ ? new Set(["04-implementation", "09-development", "R04-release-candidate"])
683
2967
  : new Set(["12-release-preparation", "R06-release-preparation"]);
684
2968
  if (!expectedNodes.has(artifact.node)) {
685
2969
  throw new Error(`Selected artifact ${artifactId} belongs to ${artifact.node}, not ${[...expectedNodes].join(" or ")}.`);
686
2970
  }
687
2971
  const scopeName = event === "release-selected" ? "release" : scopeNameForNode(artifact.node);
2972
+ assertWorkflowRevision(flags, state, scopeName);
688
2973
  const scope = scopeState(state, scopeName);
689
2974
  if (!scope.scope_id) throw new Error(`${scopeName} scope must be started before ${event}.`);
690
2975
  if (scope.scope_id !== iterationId) {
@@ -703,6 +2988,7 @@ function transitionWorkflow(files, flags, options) {
703
2988
 
704
2989
  if (event === "version-closed" || event === "release-archived") {
705
2990
  const scopeName = event === "release-archived" ? "release" : "development";
2991
+ assertWorkflowRevision(flags, state, scopeName);
706
2992
  const scope = scopeState(state, scopeName);
707
2993
  if (scope.scope_id !== iterationId) {
708
2994
  throw new Error(`Iteration mismatch for ${scopeName} scope: state is ${scope.scope_id}, command is ${iterationId}.`);
@@ -711,10 +2997,20 @@ function transitionWorkflow(files, flags, options) {
711
2997
  const registry = normalizeArtifactRegistry(readYaml(files.artifactRegistry, options.yaml, "artifact-registry.yaml"));
712
2998
  const artifact = registry.artifacts.find((item) => (item.artifact_id || item.id) === artifactId);
713
2999
  if (!artifact) throw new Error(`Milestone artifact is not registered: ${artifactId}`);
714
- const expectedNode = event === "version-closed" ? "12-version-closeout" : "R10-release-archive";
3000
+ assertNotLegacyDevelopmentNode(artifact.node);
3001
+ const expectedNodes = event === "version-closed"
3002
+ ? ["06-version-closeout"]
3003
+ : ["R10-release-archive"];
715
3004
  const expectedStatus = event === "version-closed" ? "release-ready" : "released";
716
- if (!scope.completed_nodes.includes(expectedNode)) throw new Error(`${event} requires ${expectedNode} to be completed.`);
717
- if (artifact.node !== expectedNode) throw new Error(`Milestone artifact ${artifactId} belongs to ${artifact.node}, not ${expectedNode}.`);
3005
+ if (!expectedNodes.some((node) => scope.completed_nodes.includes(node))) {
3006
+ throw new Error(`${event} requires ${expectedNodes.join(" or ")} to be completed.`);
3007
+ }
3008
+ if (!expectedNodes.includes(artifact.node)) {
3009
+ throw new Error(`Milestone artifact ${artifactId} belongs to ${artifact.node}, not ${expectedNodes.join(" or ")}.`);
3010
+ }
3011
+ if (!scope.completed_nodes.includes(artifact.node)) {
3012
+ throw new Error(`${event} requires its artifact node ${artifact.node} to be completed.`);
3013
+ }
718
3014
  if (artifact.status !== expectedStatus) {
719
3015
  throw new Error(`Milestone artifact ${artifactId} must have status ${expectedStatus}, not ${artifact.status || "missing"}.`);
720
3016
  }
@@ -742,12 +3038,15 @@ function transitionWorkflow(files, flags, options) {
742
3038
  throw new Error(`Unknown workflow node: ${node}`);
743
3039
  }
744
3040
  const scopeName = scopeNameForNode(node);
3041
+ assertWorkflowRevision(flags, state, scopeName);
745
3042
  const scope = scopeState(state, scopeName);
746
3043
  if (!scope.scope_id) throw new Error(`${scopeName} scope must be started before node ${node}.`);
747
3044
  if (scope.scope_id !== iterationId) {
748
3045
  throw new Error(`Iteration mismatch for ${scopeName} scope: state is ${scope.scope_id}, command is ${iterationId}.`);
749
3046
  }
750
3047
  let reason = null;
3048
+ let completionReceipt = null;
3049
+ let boundCandidateId = null;
751
3050
 
752
3051
  if (event === "node-started") {
753
3052
  if (scope.blocked_nodes.includes(node)) throw new Error(`Blocked node must be unblocked before start: ${node}`);
@@ -772,8 +3071,40 @@ function transitionWorkflow(files, flags, options) {
772
3071
  && !evidenceArtifacts.some((artifact) => artifact.node === prerequisite
773
3072
  && SUCCESSFUL_ARTIFACT_STATUSES.has(artifact.status)));
774
3073
  if (missing.length) throw new Error(`Node prerequisite is not satisfied: ${missing.join(", ")}.`);
3074
+ const nextAttempt = Number(scope.node_attempts[node] || 0) + 1;
3075
+ if (CANDIDATE_BOUND_NODES.has(node)) {
3076
+ const registry = normalizeArtifactRegistry(
3077
+ readYaml(files.artifactRegistry, options.yaml, "artifact-registry.yaml"),
3078
+ );
3079
+ const candidate = registry.artifacts.find((artifact) =>
3080
+ (artifact.artifact_id || artifact.id) === scope.latest_candidate_id);
3081
+ if (!candidate || projectedArtifactStatus(registry, candidate) !== "immutable") {
3082
+ throw new Error(`Node ${node} requires a selected immutable candidate before start.`);
3083
+ }
3084
+ const integrity = artifactFileIntegrity(files, candidate);
3085
+ if (!integrity.valid) {
3086
+ throw new Error(`Selected candidate ${scope.latest_candidate_id} failed integrity: ${integrity.code}.`);
3087
+ }
3088
+ boundCandidateId = scope.latest_candidate_id;
3089
+ if (node === "06-version-closeout") {
3090
+ const manifest = currentScopeManifest(scope);
3091
+ const assuranceReceipt = [...scope.node_completion_receipts].reverse().find((receipt) =>
3092
+ receipt?.node === "05-candidate-assurance"
3093
+ && receipt.candidate_id === boundCandidateId
3094
+ && (!manifest || (receipt.scope_manifest_id === manifest.manifest_id
3095
+ && receipt.scope_manifest_checksum === manifest.manifest_checksum)));
3096
+ if (!assuranceReceipt) {
3097
+ throw new Error(`Candidate Assurance has not completed for selected candidate ${boundCandidateId}.`);
3098
+ }
3099
+ }
3100
+ scope.node_candidate_bindings[node] = {
3101
+ node_attempt: nextAttempt,
3102
+ candidate_id: boundCandidateId,
3103
+ bound_at: timestamp,
3104
+ };
3105
+ }
775
3106
  scope.active_nodes = [...scope.active_nodes, node];
776
- scope.node_attempts[node] = Number(scope.node_attempts[node] || 0) + 1;
3107
+ scope.node_attempts[node] = nextAttempt;
777
3108
  } else if (event === "node-completed") {
778
3109
  if (!scope.active_nodes.includes(node)) throw new Error(`Node is not active: ${node}`);
779
3110
  if (!evidence.length) throw new Error("node-completed requires at least one --evidence artifact ID.");
@@ -783,27 +3114,54 @@ function transitionWorkflow(files, flags, options) {
783
3114
  if (unsuccessful) {
784
3115
  throw new Error(`Completion evidence ${unsuccessful.artifact_id || unsuccessful.id} has unsuccessful status ${unsuccessful.status}.`);
785
3116
  }
3117
+ if (node === "04-implementation") {
3118
+ const selectedCandidate = artifacts.find((artifact) =>
3119
+ (artifact.artifact_id || artifact.id) === scope.latest_candidate_id
3120
+ && artifact.status === "immutable");
3121
+ if (!selectedCandidate) {
3122
+ throw new Error("Node completion conditions are not satisfied for 04-implementation: candidate:selected.");
3123
+ }
3124
+ }
3125
+ if (node === "05-candidate-assurance") {
3126
+ const attempt = Number(scope.node_attempts[node] || 1);
3127
+ const binding = scope.node_candidate_bindings[node];
3128
+ const mismatched = artifacts.find((artifact) =>
3129
+ Number(binding?.node_attempt) !== attempt
3130
+ || artifact.candidate_id !== binding?.candidate_id);
3131
+ if (mismatched) {
3132
+ throw new Error(
3133
+ `Assurance evidence ${mismatched.artifact_id || mismatched.id} does not bind candidate ${binding?.candidate_id || "missing"}.`,
3134
+ );
3135
+ }
3136
+ }
786
3137
  const attempt = Number(scope.node_attempts[node] || 1);
787
3138
  const pendingForNode = scope.pending_gates.filter((gate) =>
788
3139
  typeof gate === "string" || (gate?.node === node && Number(gate.node_attempt || attempt) === attempt));
789
3140
  if (pendingForNode.length) throw new Error(`Node has pending gates: ${node}`);
790
- const latestGateDecisions = new Map();
791
- for (const decision of scope.gate_decisions) {
792
- if (decision?.node === node
793
- && (!decision.iteration_id || decision.iteration_id === iterationId)
794
- && Number(decision.node_attempt || attempt) === attempt
795
- && decision.gate_id) latestGateDecisions.set(decision.gate_id, decision);
796
- }
797
- const nonApproving = [...latestGateDecisions.values()].find((decision) =>
3141
+ const gateDecisions = latestGateDecisions(scope, node);
3142
+ const nonApproving = [...gateDecisions.values()].find((decision) =>
798
3143
  !new Set(["approved", "not-required"]).has(decision.decision));
799
3144
  if (nonApproving) {
800
3145
  throw new Error(`Node has a non-approving Gate decision ${nonApproving.gate_id}: ${nonApproving.decision}.`);
801
3146
  }
802
3147
  const missingGates = (NODE_RULES.get(node)?.gates || []).filter((gateId) => {
803
- const decision = latestGateDecisions.get(gateId);
3148
+ const decision = gateDecisions.get(gateId);
804
3149
  return !satisfiesMandatoryGate(node, decision);
805
3150
  });
806
3151
  if (missingGates.length) throw new Error(`Node is missing mandatory Gate decisions: ${missingGates.join(", ")}.`);
3152
+ if (scopeName === "development" && scope.scope_contract_version === 1
3153
+ && SCOPED_COMPLETION_NODES.has(node)) {
3154
+ const registry = normalizeArtifactRegistry(
3155
+ readYaml(files.artifactRegistry, options.yaml, "artifact-registry.yaml"),
3156
+ );
3157
+ const projection = deriveNodeStates(files, state, registry, options)[node];
3158
+ if (projection.state !== "ready-to-complete") {
3159
+ const conditions = [...projection.missing_conditions, ...projection.blocking_conditions];
3160
+ throw new Error(`Node completion conditions are not satisfied for ${node}: ${conditions.join(", ") || projection.state}.`);
3161
+ }
3162
+ completionReceipt = completionReceiptFromProjection(scope, node, projection, evidence, timestamp);
3163
+ scope.node_completion_receipts = [...scope.node_completion_receipts, completionReceipt];
3164
+ }
807
3165
  scope.active_nodes = scope.active_nodes.filter((item) => item !== node);
808
3166
  scope.completed_nodes = [...new Set([...scope.completed_nodes, node])];
809
3167
  } else if (event === "node-blocked") {
@@ -822,8 +3180,14 @@ function transitionWorkflow(files, flags, options) {
822
3180
  + "node-blocked, node-unblocked, candidate-selected, release-selected, version-closed, or release-archived.",
823
3181
  );
824
3182
  }
825
- return persist(scopeName, { node, ...(reason ? { reason } : {}) });
826
- });
3183
+ const result = persist(scopeName, {
3184
+ node,
3185
+ ...(boundCandidateId ? { candidate_id: boundCandidateId } : {}),
3186
+ ...(reason ? { reason } : {}),
3187
+ });
3188
+ if (completionReceipt) result.completion_receipt = completionReceipt;
3189
+ return result;
3190
+ }, { waitForActive: flags.has("--expected-scope-revision") });
827
3191
  }
828
3192
 
829
3193
  function registeredEvidence(files, evidence, options, node = null) {
@@ -834,6 +3198,9 @@ function registeredEvidence(files, evidence, options, node = null) {
834
3198
  return evidence.map((artifactId) => {
835
3199
  const artifact = registry.artifacts.find((item) => (item.artifact_id || item.id) === artifactId);
836
3200
  if (!artifact) throw new Error(`Evidence artifact is not registered: ${artifactId}`);
3201
+ if (artifactSuperseder(registry, artifact)) {
3202
+ throw new Error(`Evidence artifact is superseded: ${artifactId}`);
3203
+ }
837
3204
  if (node && artifact.node && artifact.node !== node) {
838
3205
  throw new Error(`Evidence ${artifactId} belongs to ${artifact.node}, not ${node}.`);
839
3206
  }
@@ -841,16 +3208,160 @@ function registeredEvidence(files, evidence, options, node = null) {
841
3208
  });
842
3209
  }
843
3210
 
844
- function requestGate(files, flags, options) {
845
- const expected = expectedRevision(flags);
3211
+ function retiredGateContractPlan(state, gateId) {
3212
+ assertRetiredGateContract(gateId);
3213
+ const affectedIterations = [];
3214
+ const expectedScopeRevisions = {};
3215
+ for (const [scopeName, scope] of [
3216
+ ["development", state.development_state],
3217
+ ["release", state.release_state],
3218
+ ]) {
3219
+ const requests = scope.pending_gates
3220
+ .map((gate) => typeof gate === "string" ? { gate_id: gate, node: null } : gate)
3221
+ .filter((gate) => gate?.gate_id === gateId);
3222
+ if (!requests.length) continue;
3223
+ expectedScopeRevisions[scopeName] = scope.revision;
3224
+ for (const request of requests) {
3225
+ affectedIterations.push({
3226
+ iteration_id: request.iteration_id || scope.scope_id,
3227
+ scope: scopeName,
3228
+ node: request.node || null,
3229
+ node_attempt: Number(request.node_attempt || scope.node_attempts[request.node] || 1),
3230
+ scope_revision: scope.revision,
3231
+ });
3232
+ }
3233
+ }
3234
+ return {
3235
+ command: "contract retire",
3236
+ dry_run: true,
3237
+ applied: false,
3238
+ gate_id: gateId,
3239
+ replacement_gate_id: gateId === "UX-LF" ? "UX-UI" : null,
3240
+ fresh_approval_required: gateId === "UX-LF",
3241
+ affected_iterations: affectedIterations,
3242
+ expected_scope_revisions: expectedScopeRevisions,
3243
+ };
3244
+ }
3245
+
3246
+ function retireGateContract(files, flags, options) {
3247
+ const gateId = requiredFlag(flags, "--gate-id");
3248
+ if (flags.has("--dry-run")) return withReadableFormalState(files, () => {
3249
+ const state = normalizeWorkflowState(
3250
+ readYaml(files.workflowState, options.yaml, "workflow-state.yaml"),
3251
+ );
3252
+ return retiredGateContractPlan(state, gateId);
3253
+ });
846
3254
  return withLock(files, "workflow-state", () => {
847
3255
  const state = normalizeWorkflowState(
848
3256
  readYaml(files.workflowState, options.yaml, "workflow-state.yaml"),
849
3257
  );
850
- if (state.revision !== expected) {
851
- throw new Error(`Workflow state revision conflict: expected ${expected}, actual ${state.revision}.`);
3258
+ const plan = retiredGateContractPlan(state, gateId);
3259
+ if (!plan.affected_iterations.length) {
3260
+ return { ...plan, dry_run: false, applied: false, receipts: [], workflow_state: state };
3261
+ }
3262
+ const affectedScopes = Object.keys(plan.expected_scope_revisions);
3263
+ if (affectedScopes.length !== 1) {
3264
+ throw new Error(`${gateId} retirement must affect exactly one formal scope per transaction.`);
3265
+ }
3266
+ const scopeName = affectedScopes[0];
3267
+ assertWorkflowRevision(flags, state, scopeName);
3268
+ const actor = requiredFlag(flags, "--actor");
3269
+ if (actor !== "system:package-upgrade") {
3270
+ throw new Error("Contract retirement must be executed by system:package-upgrade.");
852
3271
  }
3272
+ const reason = requiredFlag(flags, "--reason");
3273
+ const contractVersion = requiredFlag(flags, "--contract-version");
3274
+ const scope = scopeState(state, scopeName);
3275
+ const pending = scope.pending_gates
3276
+ .map((gate) => typeof gate === "string" ? { gate_id: gate, node: null } : gate);
3277
+ const retiredRequests = pending.filter((gate) => gate?.gate_id === gateId);
3278
+ const timestamp = (options.now || (() => new Date().toISOString()))();
3279
+ const receipts = retiredRequests.map((request) => {
3280
+ const requestChecksum = sha256(JSON.stringify(stableValue(request)));
3281
+ const receipt = stableValue({
3282
+ receipt_id: `RETIRE-${gateId}-${requestChecksum.slice(0, 20).toUpperCase()}`,
3283
+ gate_id: gateId,
3284
+ iteration_id: request.iteration_id || scope.scope_id,
3285
+ scope: scopeName,
3286
+ node: request.node || null,
3287
+ node_attempt: Number(request.node_attempt || scope.node_attempts[request.node] || 1),
3288
+ request_checksum: requestChecksum,
3289
+ request_evidence: [...new Set(request.evidence || [])].sort(),
3290
+ contract_version: contractVersion,
3291
+ disposition: "contract-retired",
3292
+ human_approval_created: false,
3293
+ renewed_gate_id: gateId === "UX-LF" ? "UX-UI" : null,
3294
+ retired_by: actor,
3295
+ reason,
3296
+ retired_at: timestamp,
3297
+ });
3298
+ receipt.receipt_checksum = contractRetirementReceiptChecksum(receipt);
3299
+ return receipt;
3300
+ });
3301
+ const remaining = pending.filter((gate) => gate?.gate_id !== gateId);
3302
+ const existingUxUiRequests = remaining.filter((gate) => gate?.gate_id === "UX-UI");
3303
+ const renewalSources = existingUxUiRequests.length ? existingUxUiRequests : retiredRequests;
3304
+ const renewedRequests = gateId === "UX-LF"
3305
+ ? renewalSources.map((request, index) => {
3306
+ const retiredIndex = Math.max(0, retiredRequests.findIndex((candidate) =>
3307
+ (candidate.iteration_id || scope.scope_id) === (request.iteration_id || scope.scope_id)));
3308
+ const retiredRequest = retiredRequests[retiredIndex] || retiredRequests[index] || request;
3309
+ const receipt = receipts[retiredIndex] || receipts[index];
3310
+ return {
3311
+ ...request,
3312
+ gate_id: "UX-UI",
3313
+ renewed_from_gate_id: gateId,
3314
+ iteration_id: request.iteration_id || scope.scope_id,
3315
+ scope: scopeName,
3316
+ node: "02-solution-design",
3317
+ node_attempt: Number(retiredRequest.node_attempt || request.node_attempt
3318
+ || scope.node_attempts[retiredRequest.node] || 1),
3319
+ evidence: [],
3320
+ prototype_revision_required: true,
3321
+ required_evidence_type: "prototype-revision",
3322
+ retirement_receipt_checksum: receipt.receipt_checksum,
3323
+ requested_at: request.requested_at || timestamp,
3324
+ };
3325
+ })
3326
+ : [];
3327
+ scope.pending_gates = [
3328
+ ...remaining.filter((gate) => gate?.gate_id !== "UX-UI"),
3329
+ ...renewedRequests,
3330
+ ];
3331
+ scope.contract_retirement_receipts = [
3332
+ ...scope.contract_retirement_receipts,
3333
+ ...receipts,
3334
+ ];
3335
+ const transition = {
3336
+ event: "gate-contract-retired",
3337
+ scope: scopeName,
3338
+ gate_id: gateId,
3339
+ iteration_id: scope.scope_id,
3340
+ retirement_receipt_ids: receipts.map((receipt) => receipt.receipt_id),
3341
+ renewed_gate_ids: renewedRequests.map((request) => request.gate_id),
3342
+ occurred_at: timestamp,
3343
+ };
3344
+ persistScopeMutation(files, { state, scope, timestamp, transition }, options);
3345
+ return {
3346
+ ...plan,
3347
+ dry_run: false,
3348
+ applied: true,
3349
+ receipts,
3350
+ renewed_requests: renewedRequests,
3351
+ revision: state.revision,
3352
+ scope_revision: scope.revision,
3353
+ workflow_state: state,
3354
+ };
3355
+ }, { waitForActive: flags.has("--expected-scope-revision") });
3356
+ }
3357
+
3358
+ function requestGate(files, flags, options) {
3359
+ return withLock(files, "workflow-state", () => {
3360
+ const state = normalizeWorkflowState(
3361
+ readYaml(files.workflowState, options.yaml, "workflow-state.yaml"),
3362
+ );
853
3363
  const gateId = requiredFlag(flags, "--gate-id");
3364
+ assertGateContractWritable(gateId);
854
3365
  const iterationId = requiredFlag(flags, "--iteration-id");
855
3366
  const node = requiredFlag(flags, "--node");
856
3367
  if (!WRITABLE_NODE_IDS.has(node)) {
@@ -858,6 +3369,7 @@ function requestGate(files, flags, options) {
858
3369
  throw new Error(`Unknown workflow node: ${node}`);
859
3370
  }
860
3371
  const scopeName = scopeNameForNode(node);
3372
+ assertWorkflowRevision(flags, state, scopeName);
861
3373
  const scope = scopeState(state, scopeName);
862
3374
  if (scope.scope_id !== iterationId) {
863
3375
  throw new Error(`Iteration mismatch for ${scopeName} scope: state is ${scope.scope_id}, command is ${iterationId}.`);
@@ -889,34 +3401,24 @@ function requestGate(files, flags, options) {
889
3401
  evidence,
890
3402
  occurred_at: timestamp,
891
3403
  };
892
- persistScopeMutation(files, state, scope, timestamp, transition, {
893
- type: "gate-requested",
894
- occurred_at: timestamp,
895
- scope: scopeName,
896
- gate_id: gateId,
897
- node,
898
- iteration_id: iterationId,
899
- evidence,
900
- }, options);
3404
+ persistScopeMutation(files, { state, scope, timestamp, transition }, options);
901
3405
  return {
902
3406
  command: "gate request",
903
3407
  revision: state.revision,
3408
+ scope_revision: scope.revision,
904
3409
  gate: request,
905
3410
  workflow_state: state,
906
3411
  };
907
- });
3412
+ }, { waitForActive: flags.has("--expected-scope-revision") });
908
3413
  }
909
3414
 
910
3415
  function decideGate(files, flags, options) {
911
- const expected = expectedRevision(flags);
912
3416
  return withLock(files, "workflow-state", () => {
913
3417
  const state = normalizeWorkflowState(
914
3418
  readYaml(files.workflowState, options.yaml, "workflow-state.yaml"),
915
3419
  );
916
- if (state.revision !== expected) {
917
- throw new Error(`Workflow state revision conflict: expected ${expected}, actual ${state.revision}.`);
918
- }
919
3420
  const gateId = requiredFlag(flags, "--gate-id");
3421
+ assertGateContractWritable(gateId);
920
3422
  const scopes = [state.development_state, state.release_state];
921
3423
  let owningScope = null;
922
3424
  let pending = [];
@@ -933,7 +3435,9 @@ function decideGate(files, flags, options) {
933
3435
  }
934
3436
  }
935
3437
  if (!request) throw new Error(`Gate is not pending: ${gateId}`);
3438
+ assertNotLegacyDevelopmentNode(request.node);
936
3439
  const scopeName = request.scope || scopeNameForNode(request.node);
3440
+ assertWorkflowRevision(flags, state, scopeName);
937
3441
  const decision = requiredFlag(flags, "--decision").toLowerCase().replaceAll("_", "-");
938
3442
  if (!GATE_DECISIONS.has(decision)) throw new Error(`Unsupported gate decision: ${decision}`);
939
3443
  const actor = requiredFlag(flags, "--actor");
@@ -941,8 +3445,15 @@ function decideGate(files, flags, options) {
941
3445
  const evidence = repeatedFlag(flags, "--evidence");
942
3446
  const evidenceArtifacts = registeredEvidence(files, evidence, options);
943
3447
  const crossNodeEvidence = evidenceArtifacts.filter((artifact) => artifact.node !== request.node);
3448
+ const freshPrototypeRevisionRequired = request.prototype_revision_required === true;
3449
+ if (freshPrototypeRevisionRequired && decision === "approved" && !options.pageReviewReceiptWrite) {
3450
+ throw new Error("Renewed UX-UI approval requires a fresh Prototype revision through Workflow Manager prototype review.");
3451
+ }
3452
+ if (freshPrototypeRevisionRequired && decision === "not-required") {
3453
+ throw new Error("Renewed UX-UI cannot be not-required; a fresh Prototype revision and Workflow Manager approval are required.");
3454
+ }
944
3455
  if (decision === "not-required") {
945
- if (request.node !== "04-experience-design" || !UX_GATE_IDS.has(gateId)) {
3456
+ if (!UX_GATE_NODES.has(request.node) || !UX_GATE_IDS.has(gateId)) {
946
3457
  throw new Error(`Gate ${gateId} cannot use not-required.`);
947
3458
  }
948
3459
  if (!evidenceArtifacts.some(isProductTerminalEvidence)) {
@@ -973,6 +3484,68 @@ function decideGate(files, flags, options) {
973
3484
  evidence,
974
3485
  decided_at: timestamp,
975
3486
  };
3487
+ const extraWrites = [];
3488
+ if (options.pageReviewReceiptWrite) {
3489
+ if (gateId !== "UX-UI" || decision !== "approved") {
3490
+ throw new Error("A Prototype Page review receipt can only complete an approved UX-UI decision.");
3491
+ }
3492
+ const receiptWrite = options.pageReviewReceiptWrite;
3493
+ const receiptRoot = path.resolve(files.delivery, "reviews", "prototype-pages");
3494
+ const receiptFile = path.resolve(String(receiptWrite.file || ""));
3495
+ const relativeReceipt = path.relative(receiptRoot, receiptFile);
3496
+ if (!relativeReceipt || relativeReceipt.startsWith("..") || path.isAbsolute(relativeReceipt)) {
3497
+ throw new Error("Prototype Page review receipt must stay within the formal review receipt directory.");
3498
+ }
3499
+ if (fs.existsSync(receiptFile)) throw new Error("Prototype Page review receipt is immutable and already exists.");
3500
+ const receipt = options.yaml.parse(String(receiptWrite.content || ""));
3501
+ if (!receipt || receipt.artifact_type !== "prototype-page-review-receipt"
3502
+ || receipt.decision !== "approved" || receipt.actor !== actor
3503
+ || receipt.iteration_id !== record.iteration_id) {
3504
+ throw new Error("Prototype Page review receipt does not match the UX-UI decision.");
3505
+ }
3506
+ const { receipt_checksum: ignored, ...unsignedReceipt } = receipt;
3507
+ if (receipt.receipt_checksum !== sha256(JSON.stringify(stableValue(unsignedReceipt)))) {
3508
+ throw new Error("Prototype Page review receipt checksum is invalid.");
3509
+ }
3510
+ const expectedReceiptFile = path.resolve(
3511
+ receiptRoot,
3512
+ String(receipt.prototype_revision_id || ""),
3513
+ `${String(receipt.receipt_id || "")}.yaml`,
3514
+ );
3515
+ if (receiptFile !== expectedReceiptFile) {
3516
+ throw new Error("Prototype Page review receipt path does not match its identities.");
3517
+ }
3518
+ const requestedEvidenceIds = new Set(request.evidence || []);
3519
+ const revisionEvidence = evidenceArtifacts.find((artifact) =>
3520
+ (freshPrototypeRevisionRequired || requestedEvidenceIds.has(artifact.artifact_id || artifact.id))
3521
+ && artifact.artifact_type === "prototype-revision"
3522
+ && artifact.iteration_id === receipt.iteration_id
3523
+ && artifact.checksum_algorithm === "sha256"
3524
+ && artifact.checksum === receipt.prototype_revision_checksum);
3525
+ if (!revisionEvidence || !artifactFileIntegrity(files, revisionEvidence).valid) {
3526
+ throw new Error("UX-UI Gate does not bind the exact Prototype revision evidence in the Page receipt.");
3527
+ }
3528
+ const revision = readYaml(
3529
+ path.resolve(files.root, revisionEvidence.path),
3530
+ options.yaml,
3531
+ "Prototype revision Gate evidence",
3532
+ );
3533
+ if (revision.artifact_type !== "prototype-revision"
3534
+ || revision.artifact_id !== receipt.prototype_revision_id
3535
+ || revision.prototype_id !== receipt.prototype_id
3536
+ || revision.iteration_id !== receipt.iteration_id) {
3537
+ throw new Error("UX-UI Gate does not bind the exact Prototype revision evidence in the Page receipt.");
3538
+ }
3539
+ record.prototype_revision_id = receipt.prototype_revision_id;
3540
+ record.prototype_revision_checksum = receipt.prototype_revision_checksum;
3541
+ record.page_review_receipt = {
3542
+ receipt_id: receipt.receipt_id,
3543
+ page_id: receipt.page_id,
3544
+ ref: path.relative(files.root, receiptFile).replaceAll("\\", "/"),
3545
+ checksum: receipt.receipt_checksum,
3546
+ };
3547
+ extraWrites.push({ file: receiptFile, content: receiptWrite.content });
3548
+ }
976
3549
  owningScope.pending_gates = pending.filter((gate) => gate?.gate_id !== gateId);
977
3550
  owningScope.gate_decisions = [...owningScope.gate_decisions, record];
978
3551
  const transition = {
@@ -981,42 +3554,82 @@ function decideGate(files, flags, options) {
981
3554
  ...record,
982
3555
  occurred_at: timestamp,
983
3556
  };
984
- persistScopeMutation(files, state, owningScope, timestamp, transition, {
985
- type: "gate-decided",
986
- occurred_at: timestamp,
987
- scope: scopeName,
988
- ...record,
3557
+ persistScopeMutation(files, {
3558
+ state,
3559
+ scope: owningScope,
3560
+ timestamp,
3561
+ transition,
3562
+ extraWrites,
989
3563
  }, options);
990
3564
  return {
991
3565
  command: "gate decide",
992
3566
  revision: state.revision,
3567
+ scope_revision: owningScope.revision,
993
3568
  gate: record,
994
3569
  workflow_state: state,
995
3570
  };
996
- });
3571
+ }, { waitForActive: flags.has("--expected-scope-revision") });
997
3572
  }
998
3573
 
999
- function readEvents(files, errors) {
3574
+ function readEvents(files, errors, readOptions = {}) {
1000
3575
  if (!fs.existsSync(files.events)) return [];
1001
3576
  const events = [];
1002
- const lines = fs.readFileSync(files.events, "utf8").split(/\r?\n/);
3577
+ const offsetBytes = Number(readOptions.offsetBytes || 0);
3578
+ const lineOffset = Number(readOptions.lineOffset || 0);
3579
+ const descriptor = fs.openSync(files.events, "r");
3580
+ let content;
3581
+ try {
3582
+ const size = fs.fstatSync(descriptor).size;
3583
+ if (!Number.isInteger(offsetBytes) || offsetBytes < 0 || offsetBytes > size) {
3584
+ errors.push(`Audit event byte offset is invalid: ${offsetBytes}.`);
3585
+ return events;
3586
+ }
3587
+ content = Buffer.alloc(size - offsetBytes);
3588
+ let bytesRead = 0;
3589
+ while (bytesRead < content.length) {
3590
+ const count = fs.readSync(
3591
+ descriptor,
3592
+ content,
3593
+ bytesRead,
3594
+ content.length - bytesRead,
3595
+ offsetBytes + bytesRead,
3596
+ );
3597
+ if (count === 0) break;
3598
+ bytesRead += count;
3599
+ }
3600
+ if (bytesRead !== content.length) {
3601
+ errors.push(`Audit event suffix could not be read completely from byte offset ${offsetBytes}.`);
3602
+ return events;
3603
+ }
3604
+ } finally {
3605
+ fs.closeSync(descriptor);
3606
+ }
3607
+ const lines = content.toString("utf8").split(/\r?\n/);
1003
3608
  for (let index = 0; index < lines.length; index += 1) {
1004
3609
  if (!lines[index].trim()) continue;
1005
3610
  try {
1006
3611
  const event = JSON.parse(lines[index]);
1007
3612
  if (!event || typeof event !== "object" || Array.isArray(event)) {
1008
- errors.push(`Audit event at line ${index + 1} must be a JSON object.`);
3613
+ errors.push(`Audit event at line ${lineOffset + index + 1} must be a JSON object.`);
1009
3614
  } else {
1010
3615
  events.push(event);
1011
3616
  }
1012
3617
  } catch {
1013
- errors.push(`Audit event at line ${index + 1} is not valid JSON.`);
3618
+ errors.push(`Audit event at line ${lineOffset + index + 1} is not valid JSON.`);
1014
3619
  }
1015
3620
  }
1016
3621
  return events;
1017
3622
  }
1018
3623
 
1019
- function verifyStream(file, value, stream, events, errors, allowedSchemas = new Set([1])) {
3624
+ function verifyStream(
3625
+ file,
3626
+ value,
3627
+ stream,
3628
+ events,
3629
+ errors,
3630
+ allowedSchemas = new Set([1]),
3631
+ base = {},
3632
+ ) {
1020
3633
  if (!allowedSchemas.has(value.schema_version)) {
1021
3634
  errors.push(`${stream} schema_version must be one of ${[...allowedSchemas].join(", ")}.`);
1022
3635
  }
@@ -1024,18 +3637,27 @@ function verifyStream(file, value, stream, events, errors, allowedSchemas = new
1024
3637
  errors.push(`${stream} revision must be a non-negative integer.`);
1025
3638
  return;
1026
3639
  }
3640
+ const baseRevision = Number(base.revision || 0);
3641
+ if (!Number.isInteger(baseRevision) || baseRevision < 0 || value.revision < baseRevision) {
3642
+ errors.push(`${stream} revision predates the trusted history anchor.`);
3643
+ return;
3644
+ }
1027
3645
  const streamEvents = events.filter((event) => event.stream === stream);
1028
- if (value.revision === 0) {
1029
- if (streamEvents.length) errors.push(`${stream} revision is 0 but audit events exist.`);
3646
+ const expectedEventCount = value.revision - baseRevision;
3647
+ if (expectedEventCount === 0) {
3648
+ if (streamEvents.length) errors.push(`${stream} has audit events after its current revision.`);
3649
+ if (base.viewChecksum && fileChecksum(file) !== base.viewChecksum) {
3650
+ errors.push(`Audit checksum mismatch for ${stream}.`);
3651
+ }
1030
3652
  return;
1031
3653
  }
1032
- if (streamEvents.length !== value.revision) {
3654
+ if (streamEvents.length !== expectedEventCount) {
1033
3655
  errors.push(
1034
- `Audit event count mismatch for ${stream}: expected ${value.revision}, actual ${streamEvents.length}.`,
3656
+ `Audit event count mismatch for ${stream}: expected ${expectedEventCount}, actual ${streamEvents.length}.`,
1035
3657
  );
1036
3658
  }
1037
3659
  const revisions = streamEvents.map((event) => event.revision);
1038
- for (let revision = 1; revision <= value.revision; revision += 1) {
3660
+ for (let revision = baseRevision + 1; revision <= value.revision; revision += 1) {
1039
3661
  if (revisions.filter((item) => item === revision).length !== 1) {
1040
3662
  errors.push(`${stream} audit revision ${revision} must occur exactly once.`);
1041
3663
  }
@@ -1048,30 +3670,254 @@ function verifyStream(file, value, stream, events, errors, allowedSchemas = new
1048
3670
  }
1049
3671
  }
1050
3672
 
1051
- function verifyFormalState(files, options) {
3673
+ function verifyScopeContracts(scope, scopeName, errors) {
3674
+ if (scope.scope_contract_version !== null && scope.scope_contract_version !== 1) {
3675
+ errors.push(`Workflow ${scopeName} scope_contract_version must be 1 when present.`);
3676
+ }
3677
+ const manifests = Array.isArray(scope.scope_manifests) ? scope.scope_manifests : [];
3678
+ const manifestIds = new Set();
3679
+ for (const [index, manifest] of manifests.entries()) {
3680
+ if (!manifest?.manifest_id || manifestIds.has(manifest.manifest_id)) {
3681
+ errors.push(`Workflow ${scopeName} scope manifest ID is missing or duplicated.`);
3682
+ continue;
3683
+ }
3684
+ manifestIds.add(manifest.manifest_id);
3685
+ if (manifest.iteration_id !== scope.scope_id) {
3686
+ errors.push(`Scope manifest ${manifest.manifest_id} belongs to ${manifest.iteration_id}, not ${scope.scope_id}.`);
3687
+ }
3688
+ if (manifest.revision !== index + 1) {
3689
+ errors.push(`Scope manifest ${manifest.manifest_id} revision must be ${index + 1}.`);
3690
+ }
3691
+ const expectedSupersedes = index === 0 ? null : manifests[index - 1]?.manifest_id;
3692
+ if ((manifest.supersedes || null) !== expectedSupersedes) {
3693
+ errors.push(`Scope manifest ${manifest.manifest_id} must supersede ${expectedSupersedes || "nothing"}.`);
3694
+ }
3695
+ if (!manifest.approved_by || !manifest.approval_reason || manifest.status !== "frozen") {
3696
+ errors.push(`Scope manifest ${manifest.manifest_id} is not explicitly approved and frozen.`);
3697
+ }
3698
+ if (manifest.manifest_checksum !== scopeManifestChecksum(manifest)) {
3699
+ errors.push(`Scope manifest checksum mismatch: ${manifest.manifest_id}.`);
3700
+ }
3701
+ }
3702
+ if (scope.scope_contract_version === 1) {
3703
+ const requiresManifest = scopeName === "development"
3704
+ && (scope.completed_nodes.some((node) => SCOPED_COMPLETION_NODES.has(node))
3705
+ || [...scope.active_nodes, ...scope.blocked_nodes]
3706
+ .some((node) => SCOPED_COMPLETION_NODES.has(node) && !SCOPE_FREEZE_NODES.has(node)));
3707
+ if ((scope.current_scope_manifest_id && !manifestIds.has(scope.current_scope_manifest_id))
3708
+ || (!scope.current_scope_manifest_id && requiresManifest)) {
3709
+ errors.push(`Workflow ${scopeName} scope requires a valid current scope manifest.`);
3710
+ }
3711
+ } else if (scope.current_scope_manifest_id && !manifestIds.has(scope.current_scope_manifest_id)) {
3712
+ errors.push(`Workflow ${scopeName} current scope manifest is missing: ${scope.current_scope_manifest_id}.`);
3713
+ }
3714
+
3715
+ const manifestsById = new Map(manifests.map((manifest) => [manifest.manifest_id, manifest]));
3716
+ const warningIds = new Set();
3717
+ for (const warning of scope.scope_warnings) {
3718
+ const manifest = manifestsById.get(warning?.scope_manifest_id);
3719
+ if (!warning?.warning_id || warningIds.has(warning.warning_id)) {
3720
+ errors.push(`Workflow ${scopeName} scope warning ID is missing or duplicated.`);
3721
+ continue;
3722
+ }
3723
+ warningIds.add(warning.warning_id);
3724
+ if (!manifest || warning.scope_manifest_checksum !== manifest.manifest_checksum) {
3725
+ errors.push(`Scope warning ${warning.warning_id} does not bind a valid manifest.`);
3726
+ }
3727
+ if (warning.action !== "warn-only" || warning.reslice_performed !== false) {
3728
+ errors.push(`Scope warning ${warning.warning_id} must not reslice or change scope.`);
3729
+ }
3730
+ }
3731
+
3732
+ const receiptIds = new Set();
3733
+ for (const receipt of scope.node_completion_receipts) {
3734
+ if (!receipt?.receipt_id || receiptIds.has(receipt.receipt_id)) {
3735
+ errors.push(`Workflow ${scopeName} completion receipt ID is missing or duplicated.`);
3736
+ continue;
3737
+ }
3738
+ receiptIds.add(receipt.receipt_id);
3739
+ if (receipt.receipt_checksum !== completionReceiptChecksum(receipt)) {
3740
+ errors.push(`Node completion receipt checksum mismatch: ${receipt.receipt_id}.`);
3741
+ }
3742
+ if (receipt.scope_manifest_id) {
3743
+ const manifest = manifestsById.get(receipt.scope_manifest_id);
3744
+ if (!manifest || receipt.scope_manifest_checksum !== manifest.manifest_checksum) {
3745
+ errors.push(`Node completion receipt ${receipt.receipt_id} does not bind a valid scope manifest.`);
3746
+ }
3747
+ }
3748
+ if (Array.isArray(receipt.blocking_defect_ids) && receipt.blocking_defect_ids.length) {
3749
+ errors.push(`Node completion receipt ${receipt.receipt_id} retains blocking defects.`);
3750
+ }
3751
+ }
3752
+ const retirementReceiptIds = new Set();
3753
+ for (const receipt of scope.contract_retirement_receipts) {
3754
+ if (!receipt?.receipt_id || retirementReceiptIds.has(receipt.receipt_id)) {
3755
+ errors.push(`Workflow ${scopeName} contract retirement receipt ID is missing or duplicated.`);
3756
+ continue;
3757
+ }
3758
+ retirementReceiptIds.add(receipt.receipt_id);
3759
+ if (receipt.receipt_checksum !== contractRetirementReceiptChecksum(receipt)) {
3760
+ errors.push(`Contract retirement receipt checksum mismatch: ${receipt.receipt_id}.`);
3761
+ }
3762
+ if (!RETIRED_GATE_IDS.has(receipt.gate_id)
3763
+ || receipt.iteration_id !== scope.scope_id
3764
+ || receipt.scope !== scopeName
3765
+ || receipt.disposition !== "contract-retired"
3766
+ || receipt.human_approval_created !== false
3767
+ || receipt.retired_by !== "system:package-upgrade"
3768
+ || !receipt.contract_version
3769
+ || !/^[a-f0-9]{64}$/.test(receipt.request_checksum || "")) {
3770
+ errors.push(`Contract retirement receipt is invalid: ${receipt.receipt_id}.`);
3771
+ }
3772
+ }
3773
+ if (scope.scope_contract_version === 1 && scope.current_scope_manifest_id) {
3774
+ const current = manifestsById.get(scope.current_scope_manifest_id);
3775
+ for (const node of scope.completed_nodes.filter((item) => SCOPED_COMPLETION_NODES.has(item))) {
3776
+ const attempt = Number(scope.node_attempts[node] || 1);
3777
+ const receipt = scope.node_completion_receipts.find((item) =>
3778
+ item?.node === node
3779
+ && Number(item.node_attempt || attempt) === attempt
3780
+ && item.scope_manifest_id === current?.manifest_id
3781
+ && item.scope_manifest_checksum === current?.manifest_checksum);
3782
+ if (!receipt) errors.push(`Completed node ${node} lacks a receipt for the current scope manifest.`);
3783
+ }
3784
+ }
3785
+ }
3786
+
3787
+ function verifyFormalState(files, options, verification = {}) {
3788
+ const verifyHistory = verification.history === true;
3789
+ const sinceAnchor = verification.sinceAnchor === true;
3790
+ if (sinceAnchor && !verifyHistory) {
3791
+ throw new Error("--since-anchor requires --history.");
3792
+ }
1052
3793
  const project = readYaml(files.project, options.yaml, "project.yaml");
1053
3794
  const rawState = readYaml(files.workflowState, options.yaml, "workflow-state.yaml");
1054
3795
  const rawRegistry = readYaml(files.artifactRegistry, options.yaml, "artifact-registry.yaml");
1055
- const state = normalizeWorkflowState(rawState);
1056
- const registry = normalizeArtifactRegistry(rawRegistry);
3796
+ const views = formalStreamViews(files, {
3797
+ "workflow-state": rawState,
3798
+ "artifact-registry": rawRegistry,
3799
+ });
3800
+ const state = views.get("workflow-state").value;
3801
+ const registry = views.get("artifact-registry").value;
1057
3802
  const errors = [];
1058
- const events = readEvents(files, errors);
1059
- let shards = [];
3803
+ let retentionReceipts = [];
3804
+ let historyArchives = [];
1060
3805
  try {
1061
- shards = readEventShards(files, options);
3806
+ retentionReceipts = readArtifactRetentionReceipts(files, options);
1062
3807
  } catch (error) {
1063
3808
  errors.push(error.message);
1064
3809
  }
3810
+ if (verifyHistory) {
3811
+ try {
3812
+ historyArchives = readArtifactHistoryArchives(files, options);
3813
+ } catch (error) {
3814
+ errors.push(error.message);
3815
+ }
3816
+ }
3817
+ const compactedHistory = verifyHistory && !sinceAnchor
3818
+ && historyArchives.length > 0 && retentionReceipts.length === 0;
3819
+ const retainedHistory = verifyHistory && !sinceAnchor
3820
+ && (retentionReceipts.length > 0 || compactedHistory);
3821
+ const verificationMode = verifyHistory
3822
+ ? (sinceAnchor
3823
+ ? "incremental-history"
3824
+ : compactedHistory
3825
+ ? "compacted-history"
3826
+ : retainedHistory
3827
+ ? "retained-history"
3828
+ : "full-history")
3829
+ : "routine";
3830
+ let trustedAnchor = null;
3831
+ if (sinceAnchor || !verifyHistory || retainedHistory) {
3832
+ try {
3833
+ trustedAnchor = readLatestHistoryAnchor(files, options);
3834
+ if (sinceAnchor && !trustedAnchor) {
3835
+ errors.push("Incremental history verification requires a trusted history anchor.");
3836
+ } else if (retainedHistory && !trustedAnchor) {
3837
+ errors.push("Retained history verification requires a trusted history anchor.");
3838
+ }
3839
+ } catch (error) {
3840
+ errors.push(error.message);
3841
+ }
3842
+ }
3843
+ const anchor = trustedAnchor?.anchor || null;
3844
+ const baseRevisions = anchor
3845
+ ? Object.fromEntries(Object.entries(anchor.streams).map(([stream, metadata]) => [stream, metadata.revision]))
3846
+ : {};
3847
+ const events = readEvents(files, errors, anchor ? {
3848
+ offsetBytes: anchor.event_index.offset_bytes,
3849
+ lineOffset: anchor.event_index.event_count,
3850
+ } : {});
3851
+ let shards = [];
3852
+ if (verifyHistory && (!sinceAnchor || trustedAnchor)) {
3853
+ try {
3854
+ shards = readEventShards(
3855
+ files,
3856
+ options,
3857
+ anchor ? baseRevisions : null,
3858
+ anchor ? events : [],
3859
+ );
3860
+ } catch (error) {
3861
+ errors.push(error.message);
3862
+ }
3863
+ }
1065
3864
 
1066
- verifyStream(files.workflowState, rawState, "workflow-state", events, errors, new Set([1, 2]));
1067
- verifyStream(files.artifactRegistry, rawRegistry, "artifact-registry", events, errors);
1068
- if (shards.length) {
1069
- for (const [stream, value] of [["workflow-state", rawState], ["artifact-registry", rawRegistry]]) {
1070
- const latest = shards.filter((record) => record.stream === stream)
1071
- .sort((left, right) => right.revision - left.revision)[0];
1072
- if (latest && (latest.revision !== value.revision
1073
- || latest.after_checksum !== sha256(options.yaml.stringify(value)))) {
1074
- errors.push(`Derived view does not match latest authoritative shard for ${stream}.`);
3865
+ for (const [stream, view] of views) {
3866
+ verifyStream(
3867
+ view.file,
3868
+ view.raw,
3869
+ stream,
3870
+ events,
3871
+ errors,
3872
+ view.allowedSchemas,
3873
+ anchor ? {
3874
+ revision: anchor.streams[stream].revision,
3875
+ viewChecksum: anchor.streams[stream].view_checksum,
3876
+ } : {},
3877
+ );
3878
+ }
3879
+ if (verifyHistory) {
3880
+ for (const [stream, view] of views) {
3881
+ const value = view.value;
3882
+ const streamShards = shards.filter((record) => record.stream === stream);
3883
+ const streamEvents = events.filter((event) => event.stream === stream);
3884
+ const baseRevision = Number(baseRevisions[stream] || 0);
3885
+ const expectedShardCount = value.revision - baseRevision;
3886
+ if (expectedShardCount < 0) {
3887
+ errors.push(`${stream} revision predates the trusted history anchor.`);
3888
+ continue;
3889
+ }
3890
+ if (streamShards.length !== expectedShardCount) {
3891
+ errors.push(`Event shard count mismatch for ${stream}: expected ${expectedShardCount}, actual ${streamShards.length}.`);
3892
+ }
3893
+ for (let revision = baseRevision + 1; revision <= value.revision; revision += 1) {
3894
+ const revisionShards = streamShards.filter((record) => record.revision === revision);
3895
+ if (revisionShards.length !== 1) {
3896
+ errors.push(`${stream} event shard revision ${revision} must occur exactly once.`);
3897
+ continue;
3898
+ }
3899
+ const indexedEvent = streamEvents.find((event) => event.revision === revision);
3900
+ const { after_value: ignored, ...shardEvent } = revisionShards[0];
3901
+ if (!indexedEvent?.record_checksum) delete shardEvent.record_checksum;
3902
+ if (!indexedEvent
3903
+ || JSON.stringify(stableValue(shardEvent)) !== JSON.stringify(stableValue(indexedEvent))) {
3904
+ errors.push(`Event shard metadata mismatch for ${stream} revision ${revision}.`);
3905
+ }
3906
+ }
3907
+ try {
3908
+ const replayed = replayStreamRecords(stream, streamShards, options, trustedAnchor ? {
3909
+ value: trustedAnchor.snapshots[stream],
3910
+ recordChecksum: anchor.streams[stream].record_checksum,
3911
+ revision: baseRevision,
3912
+ } : {});
3913
+ if (value.revision > 0) {
3914
+ const normalizedReplayed = FORMAL_STREAMS.get(stream).normalize(replayed);
3915
+ if (JSON.stringify(stableValue(normalizedReplayed)) !== JSON.stringify(stableValue(value))) {
3916
+ errors.push(`Derived view does not match latest authoritative shard for ${stream}.`);
3917
+ }
3918
+ }
3919
+ } catch (error) {
3920
+ errors.push(error.message);
1075
3921
  }
1076
3922
  }
1077
3923
  }
@@ -1101,13 +3947,8 @@ function verifyFormalState(files, options) {
1101
3947
  if (hasScopedState && !scope.scope_id) {
1102
3948
  errors.push(`Workflow ${scopeName} scope has state but no scope_id.`);
1103
3949
  }
1104
- for (const node of active) {
1105
- if (blocked.has(node) || completed.has(node)) {
1106
- errors.push(`Workflow node appears in incompatible states in ${scopeName}: ${node}.`);
1107
- }
1108
- }
1109
- for (const node of blocked) {
1110
- if (completed.has(node)) errors.push(`Workflow node appears in incompatible states in ${scopeName}: ${node}.`);
3950
+ for (const conflict of findIncompatibleNodeStates(scope)) {
3951
+ errors.push(`Workflow node appears in incompatible states in ${scopeName}: ${conflict.node}.`);
1111
3952
  }
1112
3953
  for (const node of [...active, ...blocked, ...completed]) {
1113
3954
  if (!NODE_IDS.has(node)) errors.push(`Unknown workflow node in formal state: ${node}.`);
@@ -1115,6 +3956,7 @@ function verifyFormalState(files, options) {
1115
3956
  errors.push(`Workflow node is stored in the wrong ${scopeName} scope: ${node}.`);
1116
3957
  }
1117
3958
  }
3959
+ verifyScopeContracts(scope, scopeName, errors);
1118
3960
  }
1119
3961
 
1120
3962
  const artifactIds = new Set();
@@ -1128,25 +3970,24 @@ function verifyFormalState(files, options) {
1128
3970
  if (artifactIds.has(artifactId)) errors.push(`Duplicate artifact ID: ${artifactId}.`);
1129
3971
  artifactIds.add(artifactId);
1130
3972
 
1131
- const artifactPath = typeof artifact.path === "string" ? artifact.path.replaceAll("\\", "/") : "";
1132
- if (!artifactPath.startsWith(".workflow/")) {
3973
+ const integrity = artifactFileIntegrity(files, artifact);
3974
+ const artifactPath = integrity.artifact_path;
3975
+ if (integrity.code === "invalid-path") {
1133
3976
  errors.push(`Artifact path must start with .workflow/: ${artifactId}.`);
1134
3977
  continue;
1135
3978
  }
1136
3979
  if (artifactPaths.has(artifactPath)) errors.push(`Duplicate artifact path: ${artifactPath}.`);
1137
3980
  artifactPaths.add(artifactPath);
1138
3981
 
1139
- const absolute = path.resolve(files.root, artifactPath);
1140
- if (!isInside(absolute, files.workflow) || !fs.existsSync(absolute) || !fs.statSync(absolute).isFile()) {
3982
+ if (integrity.code === "missing-or-outside" || integrity.code === "unreadable") {
1141
3983
  errors.push(`Artifact file is missing or outside .workflow/: ${artifactId}.`);
1142
3984
  continue;
1143
3985
  }
1144
- if (!isInside(fs.realpathSync(absolute), fs.realpathSync(files.workflow))) {
3986
+ if (integrity.code === "resolved-outside") {
1145
3987
  errors.push(`Artifact path resolves outside .workflow/: ${artifactId}.`);
1146
3988
  continue;
1147
3989
  }
1148
- const checksum = sha256(fs.readFileSync(absolute));
1149
- if (artifact.checksum_algorithm !== "sha256" || artifact.checksum !== checksum) {
3990
+ if (integrity.code === "checksum-mismatch") {
1150
3991
  errors.push(`Checksum mismatch for artifact ${artifactId}.`);
1151
3992
  }
1152
3993
  }
@@ -1156,11 +3997,17 @@ function verifyFormalState(files, options) {
1156
3997
  command: "verify",
1157
3998
  valid: true,
1158
3999
  errors: [],
4000
+ verification_mode: verificationMode,
4001
+ anchor_id: anchor?.anchor_id || null,
4002
+ post_anchor_event_count: anchor ? events.length : null,
1159
4003
  workflow_revision: state.revision,
1160
4004
  artifact_registry_revision: registry.revision,
1161
4005
  artifact_count: registry.artifacts.length,
1162
- audit_event_count: events.length,
4006
+ audit_event_count: (anchor?.event_index.event_count || 0) + events.length,
1163
4007
  event_shard_count: shards.length,
4008
+ history_verified: verifyHistory,
4009
+ retention_receipt_count: retentionReceipts.length,
4010
+ history_archive_count: historyArchives.length,
1164
4011
  };
1165
4012
  }
1166
4013
 
@@ -1170,6 +4017,10 @@ export function executeDeliveryStateCommand(argv, options = {}) {
1170
4017
  const command = positional[0];
1171
4018
  const projectRoot = path.resolve(String(flags.get("--project") || options.projectRoot || process.cwd()));
1172
4019
  const files = statePaths(projectRoot);
4020
+ assertNoLegacyDevelopmentWrite(files, command, positional, flags, options);
4021
+ if (command === "gate" && ["request", "decide"].includes(positional[1])) {
4022
+ assertGateContractWritable(requiredFlag(flags, "--gate-id"));
4023
+ }
1173
4024
  const descriptor = lifecycleDescriptor(command, positional, flags);
1174
4025
  const before = dispatchLifecycle(options, files, descriptor, "before", flags);
1175
4026
  if (before.decision === "block") {
@@ -1183,33 +4034,63 @@ export function executeDeliveryStateCommand(argv, options = {}) {
1183
4034
  result = registerArtifact(files, flags, options);
1184
4035
  } else if (command === "artifact" && positional[1] === "status") {
1185
4036
  result = changeArtifactStatus(files, flags, options);
4037
+ } else if (command === "artifact" && positional[1] === "prune") {
4038
+ result = pruneArtifactHistory(files, flags, options);
4039
+ } else if (command === "artifact" && positional[1] === "compact") {
4040
+ result = compactArtifactHistory(files, flags, options);
1186
4041
  } else if (command === "gate" && positional[1] === "request") {
1187
4042
  result = requestGate(files, flags, options);
1188
4043
  } else if (command === "gate" && positional[1] === "decide") {
1189
4044
  result = decideGate(files, flags, options);
4045
+ } else if (command === "contract" && positional[1] === "retire") {
4046
+ result = retireGateContract(files, flags, options);
4047
+ } else if (command === "scope" && positional[1] === "freeze") {
4048
+ result = mutateScopeManifest(files, flags, options, false);
4049
+ } else if (command === "scope" && positional[1] === "amend") {
4050
+ result = mutateScopeManifest(files, flags, options, true);
4051
+ } else if (command === "scope" && positional[1] === "warn") {
4052
+ result = recordScopeWarning(files, flags, options);
1190
4053
  } else if (command === "transition") {
1191
4054
  result = transitionWorkflow(files, flags, options);
1192
4055
  } else if (command === "verify") {
1193
- result = verifyFormalState(files, options);
4056
+ result = withReadableFormalState(
4057
+ files,
4058
+ () => verifyFormalState(files, options, {
4059
+ history: flags.has("--history"),
4060
+ sinceAnchor: flags.has("--since-anchor"),
4061
+ }),
4062
+ );
4063
+ } else if (command === "snapshot" && positional[1] === "create") {
4064
+ result = createFormalSnapshot(files, flags, options);
4065
+ } else if (command === "snapshot" && positional[1] === "references") {
4066
+ result = versionArchiveReferences(files, flags, options);
1194
4067
  } else if (command === "rebuild") {
1195
4068
  result = rebuildFormalViews(files, options);
1196
4069
  } else if (command === "inspect") {
1197
- const project = readYaml(files.project, options.yaml, "project.yaml");
1198
- const workflowState = normalizeWorkflowState(
1199
- readYaml(files.workflowState, options.yaml, "workflow-state.yaml"),
1200
- );
1201
- const artifactRegistry = normalizeArtifactRegistry(
1202
- readYaml(files.artifactRegistry, options.yaml, "artifact-registry.yaml"),
1203
- );
1204
- result = {
1205
- command,
1206
- project_id: project.project_id || workflowState.project_id || path.basename(projectRoot),
1207
- project,
1208
- workflow_state: workflowState,
1209
- artifact_registry: artifactRegistry,
1210
- };
4070
+ result = withReadableFormalState(files, () => {
4071
+ const project = readYaml(files.project, options.yaml, "project.yaml");
4072
+ const workflowState = normalizeWorkflowState(
4073
+ readYaml(files.workflowState, options.yaml, "workflow-state.yaml"),
4074
+ );
4075
+ const artifactRegistry = normalizeArtifactRegistry(
4076
+ readYaml(files.artifactRegistry, options.yaml, "artifact-registry.yaml"),
4077
+ );
4078
+ return {
4079
+ command,
4080
+ project_id: project.project_id || workflowState.project_id || path.basename(projectRoot),
4081
+ project,
4082
+ workflow_state: workflowState,
4083
+ artifact_registry: artifactRegistry,
4084
+ node_contracts: nodeContractCatalog(),
4085
+ scope_manifest: currentScopeManifest(workflowState.development_state),
4086
+ derived_node_states: deriveNodeStates(files, workflowState, artifactRegistry, options),
4087
+ };
4088
+ });
1211
4089
  } else {
1212
- throw new Error("Use inspect, verify, rebuild, artifact register/status, transition, or gate request/decide.");
4090
+ throw new Error(
4091
+ "Use inspect, verify, rebuild, snapshot create, artifact register/status/prune/compact, scope freeze/amend/warn, "
4092
+ + "transition, gate request/decide, or contract retire.",
4093
+ );
1213
4094
  }
1214
4095
  dispatchLifecycle(options, files, descriptor, "after", flags, result);
1215
4096
  return result;