@mnstry/atelier 0.2.0-alpha.1 → 0.2.0-alpha.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (414) hide show
  1. package/CHANGELOG.md +413 -0
  2. package/README.md +402 -205
  3. package/SECURITY.md +6 -1
  4. package/contracts/atelier-adoption-policy.v1.schema.json +49 -0
  5. package/contracts/atelier-coauthor.v1.schema.json +163 -0
  6. package/contracts/atelier-decision-request.v1.schema.json +476 -0
  7. package/contracts/atelier-decision-result.v1.schema.json +500 -0
  8. package/contracts/atelier-guide.v1.schema.json +227 -0
  9. package/contracts/atelier-intake.v1.schema.json +151 -0
  10. package/contracts/atelier-migration.v2.schema.json +61 -0
  11. package/contracts/atelier-obsidian-acceptance-receipt.v1.schema.json +211 -0
  12. package/contracts/atelier-obsidian-apply-policy.v1.schema.json +298 -0
  13. package/contracts/atelier-obsidian-corpus-profile.v1.schema.json +98 -0
  14. package/contracts/atelier-obsidian-edit-operation.v1.schema.json +138 -0
  15. package/contracts/atelier-obsidian-ext-settings.v1.schema.json +287 -0
  16. package/contracts/atelier-obsidian-generation-manifest.v1.schema.json +353 -0
  17. package/contracts/atelier-obsidian-proposal-receipt.v1.schema.json +110 -0
  18. package/contracts/atelier-obsidian-publication-journal.v1.schema.json +167 -0
  19. package/contracts/atelier-obsidian-scope.v1.schema.json +264 -0
  20. package/contracts/atelier-obsidian-service-state.v1.schema.json +149 -0
  21. package/contracts/atelier-obsidian-source-snapshot.v1.schema.json +179 -0
  22. package/contracts/atelier-pane-presentation.v1.schema.json +76 -0
  23. package/contracts/atelier-presentation.v1.schema.json +1293 -0
  24. package/contracts/atelier-repository-observation.v1.schema.json +163 -0
  25. package/contracts/atelier-review.v1.schema.json +1601 -0
  26. package/contracts/atelier-skill-steward.v1.schema.json +692 -0
  27. package/contracts/atelier-upgrade-plan.v2.schema.json +398 -0
  28. package/contracts/atelier-upgrade-receipt.v1.schema.json +99 -0
  29. package/contracts/atelier-vault-publication.v1.schema.json +58 -0
  30. package/contracts/public-api-baseline.json +57 -0
  31. package/docs/assurance-controls.md +49 -0
  32. package/docs/atelier-runtime.md +28 -2
  33. package/docs/atelier-sync.md +172 -0
  34. package/docs/blocks/audience-visibility.md +9 -0
  35. package/docs/blocks/claims.md +61 -0
  36. package/docs/blocks/conformance-admission.md +15 -0
  37. package/docs/blocks/will-not-do.md +18 -0
  38. package/docs/coauthor-session.md +57 -0
  39. package/docs/connected-composition.md +75 -0
  40. package/docs/continuity.md +3 -4
  41. package/docs/decisions.md +167 -0
  42. package/docs/design.md +167 -0
  43. package/docs/exact-upgrades.md +159 -0
  44. package/docs/extension-pack-lifecycle.md +33 -0
  45. package/docs/guided-upgrades.md +99 -0
  46. package/docs/install.md +99 -16
  47. package/docs/intake-and-guides.md +65 -0
  48. package/docs/integration-and-review-plan.md +409 -0
  49. package/docs/integration-contract-decisions.md +22 -0
  50. package/docs/knowledge-graph.md +8 -4
  51. package/docs/local-review.md +84 -0
  52. package/docs/local-services.md +328 -0
  53. package/docs/local-state.md +107 -0
  54. package/docs/obsidian-contract.md +698 -0
  55. package/docs/obsidian.md +484 -0
  56. package/docs/portable-presentation.md +328 -0
  57. package/docs/presentation-consumer-boundaries.md +75 -0
  58. package/docs/presentation-review-disposition.md +47 -0
  59. package/docs/project-command-inventory.md +38 -0
  60. package/docs/project-options.md +53 -0
  61. package/docs/release-engineering.md +174 -12
  62. package/docs/repo-boundary-guard.md +12 -2
  63. package/docs/review-portability.md +55 -0
  64. package/docs/skill-steward.md +156 -0
  65. package/docs/upgrade.md +155 -5
  66. package/docs/vault-service.md +292 -0
  67. package/fixtures/atelier-coauthor/invalid/authority.v1.json +5 -0
  68. package/fixtures/atelier-coauthor/valid/config.v1.json +12 -0
  69. package/fixtures/atelier-guide/invalid/authority.v1.json +11 -0
  70. package/fixtures/atelier-guide/valid/capability.v1.json +10 -0
  71. package/fixtures/atelier-guide/valid/consent.v1.json +11 -0
  72. package/fixtures/atelier-guide/valid/engagement.v1.json +7 -0
  73. package/fixtures/atelier-guide/valid/offer.v1.json +10 -0
  74. package/fixtures/atelier-intake/invalid/authority.v1.json +9 -0
  75. package/fixtures/atelier-intake/valid/attempt.v1.json +8 -0
  76. package/fixtures/atelier-intake/valid/completion.v1.json +9 -0
  77. package/fixtures/atelier-intake/valid/source.v1.json +7 -0
  78. package/fixtures/atelier-repository-observation/invalid/complete-with-blocker.v1.json +18 -0
  79. package/fixtures/atelier-repository-observation/valid/complete-local.v1.json +48 -0
  80. package/fixtures/atelier-review/invalid/authenticated-identity.v1.json +22 -0
  81. package/fixtures/atelier-review/valid/bound.v1.json +229 -0
  82. package/fixtures/atelier-review/valid/bundle.v1.json +267 -0
  83. package/fixtures/atelier-review/valid/contribution.v1.json +22 -0
  84. package/fixtures/atelier-review/valid/lifecycle.v1.json +14 -0
  85. package/fixtures/atelier-skill-steward/audit/invalid/source-mutation.v1.json +26 -0
  86. package/fixtures/atelier-skill-steward/audit/valid/clean.v1.json +26 -0
  87. package/fixtures/atelier-skill-steward/candidates/invalid/workflow-key.v1.json +37 -0
  88. package/fixtures/atelier-skill-steward/candidates/valid/create.v1.json +37 -0
  89. package/fixtures/atelier-skill-steward/lock/invalid/bad-digest.v1.json +18 -0
  90. package/fixtures/atelier-skill-steward/lock/valid/installed.v1.json +23 -0
  91. package/fixtures/atelier-skill-steward/plan/invalid/delete-action.v1.json +33 -0
  92. package/fixtures/atelier-skill-steward/plan/valid/add.v1.json +33 -0
  93. package/fixtures/atelier-upgrade-transaction/adoption-policy/invalid/example.json +12 -0
  94. package/fixtures/atelier-upgrade-transaction/adoption-policy/valid/example.json +11 -0
  95. package/fixtures/atelier-upgrade-transaction/migration/invalid/example.json +20 -0
  96. package/fixtures/atelier-upgrade-transaction/migration/valid/example.json +19 -0
  97. package/fixtures/atelier-upgrade-transaction/upgrade-plan/invalid/example.json +58 -0
  98. package/fixtures/atelier-upgrade-transaction/upgrade-plan/valid/example.json +57 -0
  99. package/fixtures/atelier-upgrade-transaction/upgrade-receipt/invalid/example.json +15 -0
  100. package/fixtures/atelier-upgrade-transaction/upgrade-receipt/valid/example.json +14 -0
  101. package/fixtures/atelier-vault-publication/invalid/publication.json +5 -0
  102. package/fixtures/atelier-vault-publication/valid/publication.json +11 -0
  103. package/fixtures/decisions/request/invalid/unknown-authority.v1.json +61 -0
  104. package/fixtures/decisions/request/valid/triage.v1.json +60 -0
  105. package/fixtures/decisions/result/invalid/execution-authority.v1.json +50 -0
  106. package/fixtures/decisions/result/valid/abstained.v1.json +23 -0
  107. package/fixtures/decisions/result/valid/assessed.v1.json +50 -0
  108. package/fixtures/obsidian/acceptance/ap05-extra-notes.json +14 -0
  109. package/fixtures/obsidian/acceptance/receipts/G07.valid.v1.json +63 -0
  110. package/fixtures/obsidian/acceptance/receipts/G13.valid.v1.json +69 -0
  111. package/fixtures/obsidian/acceptance/receipts/G14.valid.v1.json +62 -0
  112. package/fixtures/obsidian/acceptance/receipts/G15.valid.v1.json +56 -0
  113. package/fixtures/obsidian/acceptance/receipts/G16.valid.v1.json +73 -0
  114. package/fixtures/obsidian/acceptance/receipts/G17.valid.v1.json +75 -0
  115. package/fixtures/obsidian/acceptance/receipts/G18.valid.v1.json +64 -0
  116. package/fixtures/obsidian/acceptance/service-sentinels.json +10 -0
  117. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/duplicate-evidence-name.v1.json +39 -0
  118. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/evidence-by-absolute-path.v1.json +34 -0
  119. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/missing-candidate-tree.v1.json +33 -0
  120. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/no-evidence.v1.json +28 -0
  121. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-outcome.v1.json +34 -0
  122. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-top-level-field.v1.json +35 -0
  123. package/fixtures/obsidian/contracts/acceptance-receipt/valid/node-gate.v1.json +34 -0
  124. package/fixtures/obsidian/contracts/apply-policy/invalid/automatic-without-edit-class.v1.json +27 -0
  125. package/fixtures/obsidian/contracts/apply-policy/invalid/conflict-overwrite.v1.json +29 -0
  126. package/fixtures/obsidian/contracts/apply-policy/invalid/missing-policy-digest.v1.json +28 -0
  127. package/fixtures/obsidian/contracts/apply-policy/invalid/unbounded-batch.v1.json +29 -0
  128. package/fixtures/obsidian/contracts/apply-policy/invalid/unimplemented-edit-class.v1.json +30 -0
  129. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-mode.v1.json +29 -0
  130. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-selector-operator.v1.json +22 -0
  131. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-top-level-field.v1.json +30 -0
  132. package/fixtures/obsidian/contracts/apply-policy/valid/automatic-scoped.v1.json +29 -0
  133. package/fixtures/obsidian/contracts/apply-policy/valid/manual-default.v1.json +20 -0
  134. package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-path-in-ext.v1.json +30 -0
  135. package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-repository-root.v1.json +27 -0
  136. package/fixtures/obsidian/contracts/corpus-profile/invalid/duplicate-repository-id.v1.json +27 -0
  137. package/fixtures/obsidian/contracts/corpus-profile/invalid/overlapping-managed-roots.v1.json +27 -0
  138. package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-enrollment.v1.json +27 -0
  139. package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-top-level-field.v1.json +28 -0
  140. package/fixtures/obsidian/contracts/corpus-profile/valid/three-repositories.v1.json +27 -0
  141. package/fixtures/obsidian/contracts/edit-operation/invalid/absolute-recovery-ref.v1.json +21 -0
  142. package/fixtures/obsidian/contracts/edit-operation/invalid/inline-observed-bytes.v1.json +22 -0
  143. package/fixtures/obsidian/contracts/edit-operation/invalid/missing-base-digest.v1.json +20 -0
  144. package/fixtures/obsidian/contracts/edit-operation/invalid/short-idempotency-key.v1.json +21 -0
  145. package/fixtures/obsidian/contracts/edit-operation/invalid/unknown-kind.v1.json +21 -0
  146. package/fixtures/obsidian/contracts/edit-operation/valid/pending-body-replacement.v1.json +21 -0
  147. package/fixtures/obsidian/contracts/ext-settings/invalid/absolute-path-prefix.v1.json +28 -0
  148. package/fixtures/obsidian/contracts/ext-settings/invalid/apply-policy-in-portable-settings.v1.json +30 -0
  149. package/fixtures/obsidian/contracts/ext-settings/invalid/duplicate-scope-id.v1.json +27 -0
  150. package/fixtures/obsidian/contracts/ext-settings/invalid/machine-local-vault-path.v1.json +28 -0
  151. package/fixtures/obsidian/contracts/ext-settings/invalid/service-port-in-portable-settings.v1.json +28 -0
  152. package/fixtures/obsidian/contracts/ext-settings/invalid/unknown-default-scope.v1.json +27 -0
  153. package/fixtures/obsidian/contracts/ext-settings/valid/disabled.v1.json +5 -0
  154. package/fixtures/obsidian/contracts/ext-settings/valid/enabled-with-scopes.v1.json +27 -0
  155. package/fixtures/obsidian/contracts/generation-manifest/invalid/absolute-note-path.v1.json +114 -0
  156. package/fixtures/obsidian/contracts/generation-manifest/invalid/complete-with-unwritten-notes.v1.json +114 -0
  157. package/fixtures/obsidian/contracts/generation-manifest/invalid/derived-link-claimed-declared.v1.json +114 -0
  158. package/fixtures/obsidian/contracts/generation-manifest/invalid/duplicate-note-identity.v1.json +114 -0
  159. package/fixtures/obsidian/contracts/generation-manifest/invalid/in-scope-endpoint-missing.v1.json +114 -0
  160. package/fixtures/obsidian/contracts/generation-manifest/invalid/title-only-note-path.v1.json +114 -0
  161. package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-note-field.v1.json +115 -0
  162. package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-relation-type.v1.json +114 -0
  163. package/fixtures/obsidian/contracts/generation-manifest/invalid/withheld-endpoint.v1.json +114 -0
  164. package/fixtures/obsidian/contracts/generation-manifest/valid/two-notes.v1.json +114 -0
  165. package/fixtures/obsidian/contracts/oracles/scope-cases.json +412 -0
  166. package/fixtures/obsidian/contracts/proposal-receipt/invalid/accepted-without-proposal-id.v1.json +12 -0
  167. package/fixtures/obsidian/contracts/proposal-receipt/invalid/missing-adapter-operation-id.v1.json +11 -0
  168. package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-backpressure-outcome.v1.json +12 -0
  169. package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-top-level-field.v1.json +13 -0
  170. package/fixtures/obsidian/contracts/proposal-receipt/valid/accepted-new.v1.json +12 -0
  171. package/fixtures/obsidian/contracts/proposal-receipt/valid/deferred.v1.json +12 -0
  172. package/fixtures/obsidian/contracts/publication-journal/invalid/absolute-recovery-ref.v1.json +47 -0
  173. package/fixtures/obsidian/contracts/publication-journal/invalid/duplicate-sequence.v1.json +47 -0
  174. package/fixtures/obsidian/contracts/publication-journal/invalid/empty-protocol-id.v1.json +47 -0
  175. package/fixtures/obsidian/contracts/publication-journal/invalid/missing-protocol-id.v1.json +46 -0
  176. package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-entry-field.v1.json +48 -0
  177. package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-state.v1.json +47 -0
  178. package/fixtures/obsidian/contracts/publication-journal/valid/committed.v1.json +47 -0
  179. package/fixtures/obsidian/contracts/publication-journal/valid/updating-after-restart.v1.json +22 -0
  180. package/fixtures/obsidian/contracts/scope/invalid/absolute-path-prefix.v1.json +15 -0
  181. package/fixtures/obsidian/contracts/scope/invalid/duplicate-ids.v1.json +18 -0
  182. package/fixtures/obsidian/contracts/scope/invalid/expansion-without-node-budget.v1.json +40 -0
  183. package/fixtures/obsidian/contracts/scope/invalid/full-mode-with-subset.v1.json +8 -0
  184. package/fixtures/obsidian/contracts/scope/invalid/unknown-mode.v1.json +41 -0
  185. package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-key.v1.json +15 -0
  186. package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-operator.v1.json +14 -0
  187. package/fixtures/obsidian/contracts/scope/invalid/unknown-top-level-field.v1.json +42 -0
  188. package/fixtures/obsidian/contracts/scope/valid/explicit-empty.v1.json +8 -0
  189. package/fixtures/obsidian/contracts/scope/valid/focus.v1.json +11 -0
  190. package/fixtures/obsidian/contracts/scope/valid/full.v1.json +8 -0
  191. package/fixtures/obsidian/contracts/scope/valid/scoped-set-expression.v1.json +41 -0
  192. package/fixtures/obsidian/contracts/service-state/invalid/hostname-instead-of-literal-loopback.v1.json +23 -0
  193. package/fixtures/obsidian/contracts/service-state/invalid/missing-consent.v1.json +18 -0
  194. package/fixtures/obsidian/contracts/service-state/invalid/privileged-port.v1.json +23 -0
  195. package/fixtures/obsidian/contracts/service-state/invalid/unknown-top-level-field.v1.json +24 -0
  196. package/fixtures/obsidian/contracts/service-state/invalid/wildcard-bind.v1.json +23 -0
  197. package/fixtures/obsidian/contracts/service-state/valid/healthy.v1.json +23 -0
  198. package/fixtures/obsidian/contracts/source-snapshot/invalid/absolute-file-path.v1.json +48 -0
  199. package/fixtures/obsidian/contracts/source-snapshot/invalid/duplicate-repository-id.v1.json +48 -0
  200. package/fixtures/obsidian/contracts/source-snapshot/invalid/mixed-read.v1.json +48 -0
  201. package/fixtures/obsidian/contracts/source-snapshot/invalid/parent-traversal-path.v1.json +48 -0
  202. package/fixtures/obsidian/contracts/source-snapshot/invalid/unknown-file-field.v1.json +49 -0
  203. package/fixtures/obsidian/contracts/source-snapshot/valid/two-repositories.v1.json +48 -0
  204. package/fixtures/obsidian/edits/workspace.json +65 -0
  205. package/fixtures/obsidian/materialization/expected-full.json +99 -0
  206. package/fixtures/obsidian/materialization/expected-scoped.json +39 -0
  207. package/fixtures/obsidian/materialization/workspace.json +54 -0
  208. package/fixtures/obsidian/proposals/expected-shapes.json +11 -0
  209. package/fixtures/projects/sample-workspace/content/source.html.kg.json +4 -1
  210. package/fixtures/projects/source-formats-workspace/content/data.json.kg.json +4 -1
  211. package/fixtures/projects/source-formats-workspace/content/logo.png.kg.json +4 -1
  212. package/fixtures/projects/source-formats-workspace/content/metrics.csv.kg.json +4 -1
  213. package/fixtures/projects/source-formats-workspace/content/pipeline.yaml.kg.json +4 -1
  214. package/fixtures/ui/presentation/pane.v1.json +7 -0
  215. package/fixtures/ui/presentation/reference.v1.json +293 -0
  216. package/package.json +86 -10
  217. package/skills/claude/atelier-guided-coauthor/SKILL.md +49 -0
  218. package/skills/claude/atelier-guided-upgrade/SKILL.md +97 -0
  219. package/skills/claude/atelier-local-service/SKILL.md +47 -0
  220. package/skills/claude/atelier-open/SKILL.md +11 -5
  221. package/skills/claude/atelier-public-boundary/SKILL.md +31 -0
  222. package/skills/claude/atelier-skill-steward/SKILL.md +62 -0
  223. package/skills/claude/mnstry-readiness/SKILL.md +5 -0
  224. package/skills/codex/atelier-guided-coauthor/SKILL.md +49 -0
  225. package/skills/codex/atelier-guided-upgrade/SKILL.md +97 -0
  226. package/skills/codex/atelier-local-service/SKILL.md +47 -0
  227. package/skills/codex/atelier-open/SKILL.md +11 -5
  228. package/skills/codex/atelier-public-boundary/SKILL.md +31 -0
  229. package/skills/codex/atelier-skill-steward/SKILL.md +62 -0
  230. package/skills/codex/mnstry-readiness/SKILL.md +5 -0
  231. package/src/access/preview-enforcer.mjs +84 -0
  232. package/src/boundary/content-rules.mjs +283 -20
  233. package/src/boundary/policy.mjs +213 -91
  234. package/src/cli/execute-command.mjs +36 -0
  235. package/src/cli/project-options.mjs +58 -0
  236. package/src/cli/run.mjs +78 -10
  237. package/src/coauthor/session.mjs +161 -0
  238. package/src/coauthor/store.mjs +173 -0
  239. package/src/collaboration/event-ledger.mjs +348 -0
  240. package/src/collaboration/index.mjs +17 -0
  241. package/src/collaboration/inspection-bundle.mjs +223 -0
  242. package/src/collaboration/proposals.mjs +265 -65
  243. package/src/collaboration/review-contracts.mjs +25 -0
  244. package/src/collaboration/review-store.mjs +305 -0
  245. package/src/commands/attestation.mjs +20 -6
  246. package/src/commands/coauthor.mjs +26 -0
  247. package/src/commands/config.mjs +6 -1
  248. package/src/commands/disclosure.mjs +133 -0
  249. package/src/commands/distribution.mjs +2 -1
  250. package/src/commands/extension-pack.mjs +17 -9
  251. package/src/commands/init.mjs +46 -6
  252. package/src/commands/obsidian.mjs +371 -0
  253. package/src/commands/review.mjs +147 -0
  254. package/src/commands/server.mjs +1 -4
  255. package/src/commands/setup.mjs +51 -14
  256. package/src/commands/skills.mjs +199 -0
  257. package/src/commands/sync.mjs +107 -0
  258. package/src/composition/registry.mjs +27 -0
  259. package/src/composition/wire.mjs +122 -0
  260. package/src/contracts/corpus.mjs +35 -0
  261. package/src/decisions/contracts.d.mts +96 -0
  262. package/src/decisions/contracts.mjs +321 -0
  263. package/src/disclosure/content-scan.mjs +206 -0
  264. package/src/egress/check.mjs +7 -38
  265. package/src/egress/forbidden-egress.mjs +32 -18
  266. package/src/export/atelier-export-contract.mjs +16 -1
  267. package/src/extension-packs/lifecycle.mjs +154 -0
  268. package/src/graph/graph.mjs +176 -316
  269. package/src/graph/knowledge-graph.mjs +643 -43
  270. package/src/guides/contracts.mjs +54 -0
  271. package/src/harness/context-client.mjs +9 -1
  272. package/src/index.mjs +41 -0
  273. package/src/intake/store.mjs +119 -0
  274. package/src/preview/channel.mjs +89 -0
  275. package/src/project/config.mjs +112 -52
  276. package/src/project/durable-state.mjs +161 -0
  277. package/src/project/file-class.mjs +180 -0
  278. package/src/project/package-root.mjs +10 -0
  279. package/src/project/path-match.mjs +38 -15
  280. package/src/project/private-state.mjs +120 -0
  281. package/src/projection/obsidian/contracts.mjs +571 -0
  282. package/src/projection/obsidian/edits/align.mjs +169 -0
  283. package/src/projection/obsidian/edits/apply.mjs +889 -0
  284. package/src/projection/obsidian/edits/arbitrate.mjs +414 -0
  285. package/src/projection/obsidian/edits/contribution.mjs +108 -0
  286. package/src/projection/obsidian/edits/index.mjs +19 -0
  287. package/src/projection/obsidian/edits/object-identity.mjs +61 -0
  288. package/src/projection/obsidian/edits/object-store.mjs +517 -0
  289. package/src/projection/obsidian/edits/observe.mjs +123 -0
  290. package/src/projection/obsidian/edits/policy.mjs +123 -0
  291. package/src/projection/obsidian/edits/regions.mjs +648 -0
  292. package/src/projection/obsidian/materialize/byte-lens.mjs +98 -0
  293. package/src/projection/obsidian/materialize/index.mjs +7 -0
  294. package/src/projection/obsidian/materialize/path-registry.mjs +105 -0
  295. package/src/projection/obsidian/materialize/prepare-view.mjs +746 -0
  296. package/src/projection/obsidian/materialize/settings.mjs +57 -0
  297. package/src/projection/obsidian/materialize/stage.mjs +27 -0
  298. package/src/projection/obsidian/proposals/adapter.mjs +572 -0
  299. package/src/projection/obsidian/proposals/backpressure.mjs +113 -0
  300. package/src/projection/obsidian/proposals/contribution.mjs +55 -0
  301. package/src/projection/obsidian/proposals/index.mjs +23 -0
  302. package/src/projection/obsidian/proposals/observation.mjs +216 -0
  303. package/src/projection/obsidian/proposals/queue.mjs +277 -0
  304. package/src/projection/obsidian/proposals/router.mjs +168 -0
  305. package/src/projection/obsidian/publication/bridge-script.mjs +266 -0
  306. package/src/projection/obsidian/publication/exchange.mjs +117 -0
  307. package/src/projection/obsidian/publication/index.mjs +5 -0
  308. package/src/projection/obsidian/publication/publisher.mjs +588 -0
  309. package/src/projection/obsidian/publication/test-seam.mjs +16 -0
  310. package/src/projection/obsidian/publication/transport.mjs +212 -0
  311. package/src/projection/obsidian/recovery/index.mjs +6 -0
  312. package/src/projection/obsidian/recovery/journal.mjs +134 -0
  313. package/src/projection/obsidian/recovery/late-writer.mjs +46 -0
  314. package/src/projection/obsidian/recovery/restart.mjs +316 -0
  315. package/src/projection/obsidian/recovery/store.mjs +234 -0
  316. package/src/projection/obsidian/selection-ui/conflict-view.mjs +91 -0
  317. package/src/projection/obsidian/selection-ui/contribution.mjs +141 -0
  318. package/src/projection/obsidian/selection-ui/focus.mjs +82 -0
  319. package/src/projection/obsidian/selection-ui/index.mjs +12 -0
  320. package/src/projection/obsidian/selection-ui/policy-setup.mjs +107 -0
  321. package/src/projection/obsidian/selection-ui/receipt.mjs +173 -0
  322. package/src/projection/obsidian/selection-ui/selection-state.mjs +122 -0
  323. package/src/projection/obsidian/selection-ui/selection.mjs +109 -0
  324. package/src/projection/project.mjs +28 -19
  325. package/src/readiness-protocols/evidence.mjs +282 -0
  326. package/src/readiness-protocols/runtime.mjs +4 -3
  327. package/src/readiness-protocols/source-read.mjs +39 -0
  328. package/src/runtime/git-adapter.mjs +189 -0
  329. package/src/runtime/local-state.mjs +439 -0
  330. package/src/runtime/obsidian/app-capability.mjs +138 -0
  331. package/src/runtime/obsidian/app-production-seams.mjs +107 -0
  332. package/src/runtime/obsidian/contributions/proposal-adapter.mjs +4 -0
  333. package/src/runtime/obsidian/contributions/selection-ui.mjs +4 -0
  334. package/src/runtime/obsidian/contributions/source-apply.mjs +4 -0
  335. package/src/runtime/obsidian/contributions.mjs +27 -0
  336. package/src/runtime/obsidian/documents.mjs +37 -0
  337. package/src/runtime/obsidian/enablement.mjs +53 -0
  338. package/src/runtime/obsidian/engine.mjs +532 -0
  339. package/src/runtime/obsidian/errors.mjs +14 -0
  340. package/src/runtime/obsidian/extension-points.mjs +103 -0
  341. package/src/runtime/obsidian/index.mjs +34 -0
  342. package/src/runtime/obsidian/lifecycle.mjs +258 -0
  343. package/src/runtime/obsidian/machine-settings.mjs +260 -0
  344. package/src/runtime/obsidian/observation.mjs +158 -0
  345. package/src/runtime/obsidian/opening.mjs +280 -0
  346. package/src/runtime/obsidian/pending-edits.mjs +141 -0
  347. package/src/runtime/obsidian/pipeline.mjs +122 -0
  348. package/src/runtime/obsidian/private-lock.mjs +156 -0
  349. package/src/runtime/obsidian/process-identity.mjs +58 -0
  350. package/src/runtime/obsidian/service-client.mjs +66 -0
  351. package/src/runtime/obsidian/service-entry-path.mjs +9 -0
  352. package/src/runtime/obsidian/service-main.mjs +93 -0
  353. package/src/runtime/obsidian/service-record.mjs +169 -0
  354. package/src/runtime/obsidian/service-server.mjs +121 -0
  355. package/src/runtime/obsidian/service.mjs +216 -0
  356. package/src/runtime/obsidian/startup-adapters.mjs +87 -0
  357. package/src/runtime/obsidian/state-store.mjs +180 -0
  358. package/src/runtime/obsidian/sync-notice.mjs +33 -0
  359. package/src/runtime/obsidian/tick-loop.mjs +84 -0
  360. package/src/runtime/obsidian/watchers.mjs +36 -0
  361. package/src/runtime/repository-observation.mjs +491 -0
  362. package/src/runtime/supervisor.mjs +803 -0
  363. package/src/server/local-sidecar.mjs +125 -59
  364. package/src/server/security.mjs +89 -4
  365. package/src/server/server.mjs +4 -2
  366. package/src/skills/steward.mjs +780 -0
  367. package/src/support/feedback-report.mjs +4 -3
  368. package/src/ui/presentation/browser.mjs +278 -0
  369. package/src/ui/presentation/contract.mjs +116 -0
  370. package/src/ui/presentation/index.mjs +7 -0
  371. package/src/ui/presentation/native.mjs +153 -0
  372. package/src/ui/presentation/proof.mjs +57 -0
  373. package/src/ui/presentation/schema-check.mjs +30 -0
  374. package/src/ui/presentation/schema.generated.mjs +1371 -0
  375. package/src/ui/presentation/state.mjs +43 -0
  376. package/src/ui/presentation/styles.mjs +67 -0
  377. package/src/ui/presentation/tokens.mjs +74 -0
  378. package/src/ui/presentation/web.mjs +85 -0
  379. package/src/ui/review-page.mjs +32 -0
  380. package/src/upgrade/explanation.mjs +33 -0
  381. package/src/upgrade/provenance.mjs +338 -0
  382. package/src/upgrade/transaction-files.mjs +103 -0
  383. package/src/upgrade/transaction.mjs +475 -0
  384. package/src/upgrade/upgrade.mjs +65 -29
  385. package/src/vault/hosts.mjs +21 -0
  386. package/src/vault/identity.mjs +19 -0
  387. package/src/vault/index.mjs +7 -0
  388. package/src/vault/interface.mjs +11 -0
  389. package/src/vault/metadata.mjs +31 -0
  390. package/src/vault/privacy.mjs +148 -0
  391. package/src/vault/probe.mjs +89 -0
  392. package/src/vault/service.mjs +131 -0
  393. package/src/vault/source.mjs +45 -0
  394. package/src/vault/storage.mjs +23 -0
  395. package/templates/distribution-workspace/.claude/launch.json +6 -2
  396. package/templates/distribution-workspace/gitignore +1 -0
  397. package/templates/distribution-workspace/project/README.md +2 -2
  398. package/templates/external-project-workspace/README.md +45 -0
  399. package/templates/external-project-workspace/answers.example.json +4 -0
  400. package/templates/external-project-workspace/atelier.project.json +40 -0
  401. package/templates/external-project-workspace/boundary-policy.v1.json +60 -0
  402. package/templates/external-project-workspace/gitignore +8 -0
  403. package/templates/external-project-workspace/pack-lifecycle.json +14 -0
  404. package/templates/external-project-workspace/packs/protocols/contract-gate.v1.json +109 -0
  405. package/templates/external-project-workspace/packs/sample-pack.v1.json +48 -0
  406. package/templates/external-project-workspace/repo-access.v1.json +9 -0
  407. package/templates/external-project-workspace/source/README.md +11 -0
  408. package/templates/launch.json +6 -2
  409. package/templates/private-domain-workspace/.claude/launch.json +6 -2
  410. package/templates/private-domain-workspace/domain/README.md +2 -2
  411. package/templates/private-domain-workspace/gitignore +1 -0
  412. package/templates/shared-project-workspace/.claude/launch.json +6 -2
  413. package/templates/shared-project-workspace/gitignore +1 -0
  414. package/templates/shared-project-workspace/project/README.md +2 -2
@@ -0,0 +1,34 @@
1
+ // Continuous maintenance of Obsidian views: typed enablement, private machine
2
+ // settings, observation by digest, pending edits and per-view freshness.
3
+ // The engine starts no process, timer or listener; `tick()` is explicit. The
4
+ // owned lifecycle (start / status / stop of one loopback service per
5
+ // workspace) is separate, below, and is the only thing here that does.
6
+ export { ObsidianMaintenanceRefusal } from './errors.mjs'
7
+ export { DISABLED_REASONS, ENABLEMENT_STATES, readObsidianEnablement } from './enablement.mjs'
8
+ export {
9
+ LOCAL_POINTER_SCHEMA, MACHINE_SETTINGS_SCHEMA, MAINTENANCE_MODES, authorizeAutomaticApply, defaultDataRoot, defaultMachineSettings, ensureWorkspaceIdentity,
10
+ installApplyPolicy, localPointerPath, protectedRoots, readInstalledApplyPolicy, readLocalPointer, readMachineSettings, resolveDataRoot, revokeApplyPolicy, workspaceStateRoot, writeLocalPointer, writeMachineSettings,
11
+ } from './machine-settings.mjs'
12
+ export { APPLY_RESULT_STATUSES, BUILT_IN_OPERATIONS, EXTENSION_KINDS, REPLACEABLE_OPERATIONS, UNAVAILABLE_APPLY_OPERATION, createCommandOperations, createMaintenanceExtensions, createObsidianRegistry } from './extension-points.mjs'
13
+ export { CONTRIBUTIONS_DIRECTORY, loadContributions } from './contributions.mjs'
14
+ export { APP_OUTCOMES, MINIMUM_APP_VERSION, compareAppVersions, createQualifiedAdapterFactory, meetsMinimumAppVersion, parseAppVersion, qualifyApp } from './app-capability.mjs'
15
+ export { OPENING_OUTCOMES, describeOutcome, openScope, scopeReport } from './opening.mjs'
16
+ export {
17
+ CHANGE_CLASSES, CLOSED_EDIT_STATES, EDIT_STATES, FRESHNESS_SCHEMA, FRESHNESS_STATES, LATE_WRITERS_SCHEMA, OPEN_EDIT_STATES, PENDING_EDITS_SCHEMA,
18
+ createMaintenanceStateStore, validateFreshness, validateLateWriters, validatePendingEdits,
19
+ } from './state-store.mjs'
20
+ export { listConfigFiles, listSourceFiles, listVaultNotes, reconcile } from './observation.mjs'
21
+ export { createFsWatcherFactory, createNullWatcherFactory } from './watchers.mjs'
22
+ export { DEFAULT_ELIGIBILITY, assetEligibilityFor, createProductionSeams } from './pipeline.mjs'
23
+ export { DEFAULT_FULL_RECONCILIATION_INTERVAL_MS, DEFAULT_LATE_WRITER_WINDOW_MS, DEFAULT_RETRY_INTERVAL_MS, createMaintenanceEngine } from './engine.mjs'
24
+ export { ENGINE_LOCK_DIRECTORY, LOCK_TICKET_SCHEMA, acquirePrivateGenerationLock, createAbandonmentProof, inspectPrivateGenerationLock } from './private-lock.mjs'
25
+ export { DEFAULT_MAX_BACKOFF_MS, DEFAULT_TICK_INTERVAL_MS, createTickLoop } from './tick-loop.mjs'
26
+ export { HEALTH_SCHEMA, LOOPBACK_HOSTS, probeHealth, requestLoopback } from './service-client.mjs'
27
+ export {
28
+ CONSENT_COVERAGES, SERVICE_ERROR_SCHEMA, SERVICE_SETTINGS_SCHEMA, executableIdentity, publicRecord, readLastServiceError, readServiceRecord, readServiceSettings,
29
+ removeServiceRecord, serviceNameFor, servicePaths, validateServiceRecord, writeServiceRecord, writeServiceSettings,
30
+ } from './service-record.mjs'
31
+ export { MAX_REQUEST_BYTES, SERVICE_OPERATIONS, createServiceServer } from './service-server.mjs'
32
+ export { DEFAULT_SHUTDOWN_GRACE_MS, SERVICE_STATUS_SCHEMA, resolveServiceWorkspace, runMaintenanceService } from './service.mjs'
33
+ export { DEFAULT_START_TIMEOUT_MS, DEFAULT_STOP_TIMEOUT_MS, SERVICE_STATES, readServiceStatusDocument, requestServiceTick, serviceStatus, startService, stopService } from './lifecycle.mjs'
34
+ export { STARTUP_PLATFORMS, buildStartupAdapter } from './startup-adapters.mjs'
@@ -0,0 +1,258 @@
1
+ import childProcess from 'node:child_process'
2
+ import { randomBytes as cryptoRandomBytes } from 'node:crypto'
3
+ import fs from 'node:fs'
4
+ import net from 'node:net'
5
+ import { isoTime } from './documents.mjs'
6
+ import { refuse } from './errors.mjs'
7
+ import { ensureWorkspaceIdentity } from './machine-settings.mjs'
8
+ import { acquirePrivateGenerationLock, createAbandonmentProof, isProcessAlive } from './private-lock.mjs'
9
+ import { processRunsRecordedExecutable } from './process-identity.mjs'
10
+ import { DEFAULT_PROBE_TIMEOUT_MS, LOOPBACK_HOSTS, probeHealth, requestLoopback } from './service-client.mjs'
11
+ import { SERVICE_ENTRY_PATH } from './service-entry-path.mjs'
12
+ import {
13
+ CONSENT_COVERAGES, SERVICE_SETTINGS_SCHEMA, executableIdentity, openServiceLog, publicRecord, readServiceRecord, readServiceSettings, removeServiceRecord,
14
+ serviceNameFor, servicePaths, writeServiceSettings,
15
+ } from './service-record.mjs'
16
+ import { resolveServiceWorkspace } from './service.mjs'
17
+
18
+ // start / status / stop of the maintenance service of one workspace, as
19
+ // docs/local-services.md describes them.
20
+ //
21
+ // Ownership is proven, never assumed. A running service is ours only when the
22
+ // owner-only record and the health answer agree on service name, workspace,
23
+ // runtime identifier, PID, executable digest and literal loopback address,
24
+ // and that PID is alive. Anything else that answers on the port is
25
+ // `occupied`: it is not adopted, not stopped and not replaced. One case is
26
+ // told apart from it: a listener that accepts and does not answer in time
27
+ // while the recorded PID is alive and provably runs the recorded executable is
28
+ // our own service in a long tick. That is `busy`: never adopted, never
29
+ // stopped and never started over; it is simply asked again later. A record whose
30
+ // address refuses connections is `stale-record` when its PID is gone and
31
+ // `pid-not-ours` when that PID is alive (the number was reused, or a process
32
+ // outlived its listener): such a PID is never signalled.
33
+ //
34
+ // Nothing here looks a process up by port, name or pattern, and nothing here
35
+ // removes anything but the one generated record.
36
+
37
+ export const SERVICE_STATES = Object.freeze(['stopped', 'healthy', 'busy', 'occupied', 'stale-record', 'pid-not-ours'])
38
+ export const DEFAULT_START_TIMEOUT_MS = 20 * 1000
39
+ export const DEFAULT_STOP_TIMEOUT_MS = 45 * 1000
40
+
41
+ const AGREEMENT = ['serviceName', 'workspaceId', 'runtimeId', 'pid', 'host', 'port']
42
+ const sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms) })
43
+
44
+ // The decisions the lifecycle oracles are sensitive to; tests substitute broken ones to prove the oracles can fail.
45
+ export const LIFECYCLE_PRIMITIVES = Object.freeze({
46
+ // Every field the record and the health answer must agree on; the names of those that differ.
47
+ disagreements(record, health) {
48
+ const differing = AGREEMENT.filter((field) => record[field] !== health[field])
49
+ if (record.executable.digest !== health.executableDigest) differing.push('executableDigest')
50
+ return differing
51
+ },
52
+ // Whether start may report a running service as this workspace's own instead of creating one.
53
+ isOurs: (status) => status.state === 'healthy',
54
+ // Whether start gives up because something unproven answers on the port.
55
+ refusesOccupied: (status) => status.state === 'occupied',
56
+ // Whether a stop may be sent at all.
57
+ mayStop: (status) => status.state === 'healthy',
58
+ // Whether the live recorded PID provably runs the recorded executable, when health did not answer in time.
59
+ provesOurProcess: (record) => processRunsRecordedExecutable(record),
60
+ })
61
+
62
+ function context({ loadProject, dataRoot, env, platform, create = false, randomBytes }) {
63
+ if (typeof loadProject !== 'function') throw new TypeError('the service lifecycle needs loadProject')
64
+ const project = loadProject()
65
+ if (create) ensureWorkspaceIdentity({ project, ...(randomBytes ? { randomBytes } : {}) })
66
+ const workspace = resolveServiceWorkspace({ project, dataRoot, env, platform, create })
67
+ return { project, workspace }
68
+ }
69
+
70
+ async function evaluate({ workspaceRoot, workspaceId }, { probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS, alive = isProcessAlive, rules = LIFECYCLE_PRIMITIVES } = {}) {
71
+ // A malformed or foreign record refuses right here, typed; it is never repaired and never adopted.
72
+ const record = readServiceRecord({ workspaceRoot, workspaceId })
73
+ const settings = readServiceSettings({ workspaceRoot, workspaceId })
74
+ const address = record ? { host: record.host, port: record.port } : settings ? { host: settings.host, port: settings.port } : null
75
+ const base = { workspaceId, serviceName: serviceNameFor(workspaceId), address, record, logPath: servicePaths(workspaceRoot).log }
76
+ if (address === null) return { ...base, state: 'stopped', reason: 'never-started' }
77
+ const answer = await probeHealth({ ...address, timeoutMs: probeTimeoutMs })
78
+ if (record === null) return answer.kind === 'refused' ? { ...base, state: 'stopped', reason: 'no-record-and-nothing-listens' } : { ...base, state: 'occupied', reason: 'a-listener-without-a-record', answer: answer.kind }
79
+ if (answer.kind === 'refused') return alive(record.pid) ? { ...base, state: 'pid-not-ours', reason: 'recorded-pid-is-alive-but-nothing-listens' } : { ...base, state: 'stale-record', reason: 'recorded-pid-is-gone' }
80
+ // Only a timeout can be a busy service: a refusal, a foreign answer or a failed connection never is.
81
+ if (answer.kind === 'timeout' && alive(record.pid) && rules.provesOurProcess(record) === true) return { ...base, state: 'busy', reason: 'our-service-did-not-answer-health-in-time', answer: answer.kind }
82
+ if (answer.kind !== 'health') return { ...base, state: 'occupied', reason: answer.kind === 'timeout' ? 'listener-did-not-answer-in-time' : 'listener-is-not-this-service', answer: answer.kind }
83
+ const differing = rules.disagreements(record, answer.body)
84
+ if (differing.length > 0) return { ...base, state: 'occupied', reason: 'health-identity-differs', disagreements: differing }
85
+ if (!alive(record.pid)) return { ...base, state: 'occupied', reason: 'recorded-pid-is-gone-but-something-answers' }
86
+ return { ...base, state: 'healthy', reason: 'record-and-health-agree', health: answer.body }
87
+ }
88
+
89
+ const shown = ({ record, ...status }) => ({ ...status, record: publicRecord(record) })
90
+
91
+ export async function serviceStatus({ loadProject, dataRoot, env = process.env, platform = process.platform, probeTimeoutMs, alive } = {}, rules = LIFECYCLE_PRIMITIVES) {
92
+ const { workspace } = context({ loadProject, dataRoot, env, platform })
93
+ if (!workspace?.workspaceRoot) return { state: 'stopped', reason: 'workspace-not-prepared', workspaceId: workspace?.workspaceId ?? null, record: null, address: null }
94
+ return shown(await evaluate(workspace, { probeTimeoutMs, alive, rules }))
95
+ }
96
+
97
+ // A port nobody listens on right now, chosen once and then recorded, so later starts, status and an installed unit all name the same one.
98
+ function freeLoopbackPort(host) {
99
+ return new Promise((resolve, reject) => {
100
+ const server = net.createServer()
101
+ server.once('error', reject)
102
+ server.listen({ host, port: 0, exclusive: true }, () => { const { port } = server.address(); server.close(() => resolve(port)) })
103
+ })
104
+ }
105
+
106
+ async function resolveSettings({ workspaceRoot, workspaceId }, { host, port, consent, now }) {
107
+ const current = readServiceSettings({ workspaceRoot, workspaceId })
108
+ if (host !== undefined && !LOOPBACK_HOSTS.includes(host)) refuse('service-address-not-loopback', 'the service host must be the literal 127.0.0.1 or ::1')
109
+ if (port !== undefined && (!Number.isInteger(port) || port < 1024 || port > 65535)) refuse('service-address-not-loopback', 'the service port must be between 1024 and 65535')
110
+ if (consent !== undefined && (typeof consent?.actor !== 'string' || !CONSENT_COVERAGES.includes(consent.coverage ?? 'service'))) refuse('startup-consent-required', 'a consent names its actor and what it covers')
111
+ if (current === null && consent === undefined) refuse('startup-consent-required', 'the first start of this workspace needs an explicit consent naming its actor')
112
+ const nextHost = host ?? current?.host ?? '127.0.0.1'
113
+ const next = {
114
+ schema: SERVICE_SETTINGS_SCHEMA, workspaceId, host: nextHost, port: port ?? (current && current.host === nextHost ? current.port : await freeLoopbackPort(nextHost)),
115
+ consent: consent === undefined || (current && current.consent.actor === consent.actor && current.consent.coverage === (consent.coverage ?? 'service')) ? current.consent : { grantedAt: now, actor: consent.actor, coverage: consent.coverage ?? 'service' },
116
+ updatedAt: now,
117
+ }
118
+ const changed = current === null || ['host', 'port'].some((key) => current[key] !== next[key]) || current.consent !== next.consent
119
+ return changed ? writeServiceSettings({ workspaceRoot, workspaceId, settings: next }) : current
120
+ }
121
+
122
+ export async function startService(options = {}, rules = LIFECYCLE_PRIMITIVES) {
123
+ const {
124
+ loadProject, dataRoot, host, port, consent, detached = false, entryPath = SERVICE_ENTRY_PATH, entryArgs = [], intervalMs,
125
+ startTimeoutMs = DEFAULT_START_TIMEOUT_MS, probeTimeoutMs, clock = () => new Date(), env = process.env, platform = process.platform,
126
+ spawn = childProcess.spawn, execPath = process.execPath, alive = isProcessAlive, randomBytes = cryptoRandomBytes,
127
+ } = options
128
+ const { project, workspace } = context({ loadProject, dataRoot, env, platform, create: true })
129
+ const { workspaceRoot, workspaceId } = workspace
130
+ const deadline = Date.now() + startTimeoutMs
131
+
132
+ // One start at a time per workspace. A start is bounded by its own deadline, so a holder far older than that is gone.
133
+ const proveAbandoned = createAbandonmentProof({ alive, maxAgeMs: startTimeoutMs * 4 })
134
+ let lock
135
+ for (;;) {
136
+ lock = await acquirePrivateGenerationLock({ workspaceRoot, directory: servicePaths(workspaceRoot).startLock, workspaceId, purpose: 'service-start', clock, proveAbandoned })
137
+ if (lock.acquired) break
138
+ const meanwhile = await evaluate(workspace, { probeTimeoutMs, alive, rules })
139
+ if (rules.isOurs(meanwhile)) return { ...shown(meanwhile), started: false, alreadyRunning: true }
140
+ if (meanwhile.state === 'busy') return { ...shown(meanwhile), started: false, alreadyRunning: true, busy: true }
141
+ if (Date.now() >= deadline) refuse('service-start-in-progress', 'another start of this workspace holds the start lock', { reason: lock.reason })
142
+ await sleep(100)
143
+ }
144
+
145
+ let child = null
146
+ try {
147
+ const before = await evaluate(workspace, { probeTimeoutMs, alive, rules })
148
+ if (rules.isOurs(before)) return { ...shown(before), started: false, alreadyRunning: true }
149
+ // Running, and in a long tick: nothing is started beside it and nothing replaces it.
150
+ if (before.state === 'busy') return { ...shown(before), started: false, alreadyRunning: true, busy: true }
151
+ if (rules.refusesOccupied(before)) refuse('service-port-occupied', 'something that is not this service answers on the loopback port; it is never taken over', { reason: before.reason, address: before.address })
152
+ // `stale-record` and `pid-not-ours`: nothing listens, so the recorded runtime is not serving. Its PID is never signalled;
153
+ // the new service replaces the record once it listens. Nothing under recovery or staging is touched on the way.
154
+ const settings = await resolveSettings(workspace, { host, port, consent, now: isoTime(clock) })
155
+ if (before.address && (before.address.host !== settings.host || before.address.port !== settings.port)) {
156
+ const moved = await probeHealth({ host: settings.host, port: settings.port, timeoutMs: probeTimeoutMs })
157
+ if (moved.kind !== 'refused') refuse('service-port-occupied', 'something already answers on the selected loopback port; it is never taken over', { address: { host: settings.host, port: settings.port } })
158
+ }
159
+
160
+ const runtimeId = `rt-${randomBytes(16).toString('hex')}`
161
+ const executable = executableIdentity(entryPath)
162
+ const log = openServiceLog(workspaceRoot)
163
+ try {
164
+ const args = [executable.path, `--project=${project.configPath}`, ...(dataRoot === undefined ? [] : [`--data-root=${dataRoot}`]), `--runtime-id=${runtimeId}`, ...(intervalMs === undefined ? [] : [`--interval-ms=${intervalMs}`]), ...entryArgs]
165
+ // No shell. Detached only when the service is meant to outlive the command that starts it.
166
+ child = spawn(execPath, args, { detached, shell: false, windowsHide: true, stdio: ['ignore', log.descriptor, log.descriptor], env })
167
+ } finally {
168
+ fs.closeSync(log.descriptor)
169
+ }
170
+ let exited = null
171
+ child.once('exit', (code, signal) => { exited = { code, signal } })
172
+ child.once('error', (error) => { exited = { code: null, signal: null, error: error.code ?? 'spawn-failed' } })
173
+
174
+ // Health must echo the runtime identifier generated here and the PID of the child created here.
175
+ let last = null
176
+ while (Date.now() < deadline && exited === null) {
177
+ last = await evaluate(workspace, { probeTimeoutMs, alive, rules }).catch((error) => ({ state: 'refused', reason: error.code ?? 'untyped-error' }))
178
+ if (last.state === 'healthy' && last.record.runtimeId === runtimeId && last.record.pid === child.pid) {
179
+ if (detached) child.unref()
180
+ return { ...shown(last), started: true, alreadyRunning: false, ...(detached ? {} : { child }) }
181
+ }
182
+ await sleep(50)
183
+ }
184
+ // The child created here wrote the record and went straight into a long first tick: it runs, and is not stopped.
185
+ if (exited === null && last?.state === 'busy' && last.record?.runtimeId === runtimeId && last.record?.pid === child.pid) {
186
+ if (detached) child.unref()
187
+ return { ...shown(last), started: true, alreadyRunning: false, busy: true, ...(detached ? {} : { child }) }
188
+ }
189
+ // Ownership was never proven: only the child created here is stopped, by its handle.
190
+ if (exited === null) { child.kill(); const until = Date.now() + 5000; while (exited === null && Date.now() < until) await sleep(25) }
191
+ removeServiceRecord({ workspaceRoot, workspaceId, runtimeId, pid: child.pid })
192
+ return { state: 'start-failed', started: false, alreadyRunning: false, workspaceId, reason: exited?.error ?? (exited?.code === null || exited === null ? 'health-never-proved-ownership' : `service-exited-${exited.code}`), observed: last?.state ?? null, logPath: log.file }
193
+ } finally {
194
+ lock.release()
195
+ }
196
+ }
197
+
198
+ export async function stopService(options = {}, rules = LIFECYCLE_PRIMITIVES) {
199
+ const { loadProject, dataRoot, stopTimeoutMs = DEFAULT_STOP_TIMEOUT_MS, probeTimeoutMs, env = process.env, platform = process.platform, alive = isProcessAlive, force = false, kill = process.kill.bind(process) } = options
200
+ const { workspace } = context({ loadProject, dataRoot, env, platform })
201
+ if (!workspace?.workspaceRoot) return { state: 'stopped', stopped: false, refused: false, reason: 'workspace-not-prepared' }
202
+ const { workspaceRoot, workspaceId } = workspace
203
+ const status = await evaluate(workspace, { probeTimeoutMs, alive, rules })
204
+ if (status.state === 'stopped') return { ...shown(status), stopped: false, refused: false }
205
+ // Any disagreement refuses. Nothing is signalled, and the record stays for a person to look at.
206
+ if (status.state === 'busy') return { ...shown(status), stopped: false, refused: true, retry: true, reason: 'service-is-busy-ask-again-later' }
207
+ if (!rules.mayStop(status)) return { ...shown(status), stopped: false, refused: true }
208
+
209
+ const { record } = status
210
+ const answer = await requestLoopback({ host: record.host, port: record.port, method: 'POST', path: '/stop', bearer: record.ext.bearer, payload: { runtimeId: record.runtimeId }, timeoutMs: Math.max(probeTimeoutMs ?? 0, 2 * DEFAULT_PROBE_TIMEOUT_MS) })
211
+ const accepted = answer.kind === 'response' && answer.statusCode === 202 && answer.body?.runtimeId === record.runtimeId && answer.body?.pid === record.pid
212
+ if (!accepted) return { ...shown(status), stopped: false, refused: true, reason: 'stop-was-not-accepted-by-the-proven-runtime' }
213
+ const until = Date.now() + stopTimeoutMs
214
+ while (alive(record.pid) && Date.now() < until) await sleep(50)
215
+ if (alive(record.pid)) {
216
+ // Escalation is opt-in and proven again first: the same runtime and PID must still answer.
217
+ const again = force ? await evaluate(workspace, { probeTimeoutMs, alive, rules }).catch(() => null) : null
218
+ if (!(again?.state === 'healthy' && again.record.runtimeId === record.runtimeId && again.record.pid === record.pid)) return { ...shown(status), stopped: false, refused: true, reason: 'stop-timed-out' }
219
+ kill(record.pid, 'SIGKILL')
220
+ while (alive(record.pid) && Date.now() < until + 5000) await sleep(50)
221
+ }
222
+ // The service removes its own record on the way out; after a hard end it is removed here, and only if it still names that runtime.
223
+ removeServiceRecord({ workspaceRoot, workspaceId, runtimeId: record.runtimeId, pid: record.pid })
224
+ return { state: 'stopped', stopped: true, refused: false, reason: 'stopped-the-proven-runtime', workspaceId, runtimeId: record.runtimeId, pid: record.pid }
225
+ }
226
+
227
+ // One authenticated request to the proven runtime, and to nothing else: anything but a healthy status is answered
228
+ // with that status and no request is made.
229
+ async function askProvenRuntime(options, rules, { method, operation, timeoutMs }) {
230
+ const { loadProject, dataRoot, probeTimeoutMs, env = process.env, platform = process.platform, alive = isProcessAlive } = options
231
+ const { workspace } = context({ loadProject, dataRoot, env, platform })
232
+ if (!workspace?.workspaceRoot) return { requested: false, state: 'stopped', reason: 'workspace-not-prepared' }
233
+ const status = await evaluate(workspace, { probeTimeoutMs, alive, rules })
234
+ if (status.state !== 'healthy') return { requested: false, state: status.state, reason: status.reason }
235
+ const { record } = status
236
+ const answer = await requestLoopback({ host: record.host, port: record.port, method, path: operation, bearer: record.ext.bearer, payload: method === 'POST' ? { runtimeId: record.runtimeId } : null, timeoutMs })
237
+ return { requested: true, state: 'healthy', answer }
238
+ }
239
+
240
+ // Asks the proven runtime for one tick now and returns what that tick reported. A tick that outlasts the wait is
241
+ // `pending`, not an error.
242
+ export async function requestServiceTick(options = {}, rules = LIFECYCLE_PRIMITIVES) {
243
+ const asked = await askProvenRuntime(options, rules, { method: 'POST', operation: '/tick', timeoutMs: options.tickTimeoutMs ?? 60 * 1000 })
244
+ if (!asked.requested) return asked
245
+ const { answer } = asked
246
+ if (answer.kind === 'timeout') return { requested: true, state: 'healthy', pending: true, tick: null, reason: 'tick-still-running' }
247
+ if (answer.kind !== 'response' || answer.statusCode !== 200 || answer.body === null) return { requested: true, state: 'healthy', pending: false, tick: null, reason: 'tick-was-not-answered' }
248
+ return { requested: true, state: 'healthy', pending: false, tick: answer.body, reason: 'tick-ran' }
249
+ }
250
+
251
+ // The status document of the proven runtime: loop, last tick, last error code, freshness summary and, where the
252
+ // service qualifies an app, what it last learned about it. Null when it did not answer.
253
+ export async function readServiceStatusDocument(options = {}, rules = LIFECYCLE_PRIMITIVES) {
254
+ const asked = await askProvenRuntime(options, rules, { method: 'GET', operation: '/status', timeoutMs: options.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS })
255
+ if (!asked.requested) return { ...asked, document: null }
256
+ const { answer } = asked
257
+ return { requested: true, state: 'healthy', document: answer.kind === 'response' && answer.statusCode === 200 ? answer.body : null }
258
+ }
@@ -0,0 +1,260 @@
1
+ import { randomBytes as cryptoRandomBytes } from 'node:crypto'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { LOCAL_STATE_DIR, localStateRoot } from '../../project/config.mjs'
6
+ import { checkManagedRoots } from '../../project/file-class.mjs'
7
+ import { atomicReplacePrivateText, ensureContainedPrivateDirectory, readRegularTextNoFollow } from '../../project/private-state.mjs'
8
+ import { OBSIDIAN_EXT_KEY, ObsidianContractRefusal, assertObsidianContract } from '../../projection/obsidian/contracts.mjs'
9
+ import { refuse } from './errors.mjs'
10
+ import { canonicalJson, closedObject, isPlainObject } from './documents.mjs'
11
+
12
+ // Machine-private settings of the Obsidian maintenance runtime.
13
+ //
14
+ // <data>/obsidian/<workspace-id>/state/settings/machine.json mode, audiences, policy reference
15
+ // <data>/obsidian/<workspace-id>/state/settings/apply-policy.json the installed apply policy
16
+ // <project>/.atelier-local/obsidian.json pointer: workspace id, optional data root
17
+ //
18
+ // Nothing here is project configuration and nothing here is ever serialized
19
+ // into a note. The data directory sits outside every enrolled repository. The
20
+ // pointer is the one ignored file a project gains: it holds the persisted
21
+ // random workspace identity (never a hash of a checkout path, so moving a
22
+ // checkout keeps its vaults) and, optionally, where this machine keeps the
23
+ // data directory.
24
+
25
+ export const MACHINE_SETTINGS_SCHEMA = 'atelier-obsidian-machine-settings/v1'
26
+ export const LOCAL_POINTER_SCHEMA = 'atelier-obsidian-local-pointer/v1'
27
+ export const LOCAL_POINTER_FILE = 'obsidian.json'
28
+ export const MAINTENANCE_MODES = Object.freeze(['manual', 'automatic'])
29
+
30
+ const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
31
+ const DIGEST = /^sha256:[0-9a-f]{64}$/
32
+ const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/
33
+
34
+ const segment = (identifier) => identifier.replaceAll(':', '_')
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Where the data directory is
38
+ // ---------------------------------------------------------------------------
39
+
40
+ // Pure: computes a path from its arguments and touches nothing.
41
+ export function defaultDataRoot({ platform = process.platform, env = process.env, homedir = os.homedir() } = {}) {
42
+ const flavor = platform === 'win32' ? path.win32 : path.posix
43
+ const absolute = (value) => typeof value === 'string' && value !== '' && flavor.isAbsolute(value)
44
+ if (platform === 'win32') {
45
+ if (!absolute(env.LOCALAPPDATA)) refuse('data-root-unresolvable', 'LOCALAPPDATA does not name an absolute directory')
46
+ return flavor.join(env.LOCALAPPDATA, 'Atelier')
47
+ }
48
+ if (!absolute(homedir)) refuse('data-root-unresolvable', 'the home directory is not an absolute path')
49
+ if (platform === 'darwin') return flavor.join(homedir, 'Library', 'Application Support', 'Atelier')
50
+ // A relative XDG_DATA_HOME is invalid by its specification and is ignored.
51
+ return flavor.join(absolute(env.XDG_DATA_HOME) ? env.XDG_DATA_HOME : flavor.join(homedir, '.local', 'share'), 'atelier')
52
+ }
53
+
54
+ // The data root for this run: an injected root, else the project's pointer,
55
+ // else the local overlay preference, else the platform default. Pure. Under
56
+ // the Node test runner the platform default refuses, so a test that forgot to
57
+ // inject a root can never reach a person's real data directory.
58
+ export function resolveDataRoot({ dataRoot, pointer = null, project = null, platform = process.platform, env = process.env, homedir } = {}) {
59
+ const preference = project?.localOverlay?.overlay?.preferences?.[OBSIDIAN_EXT_KEY]
60
+ const configured = dataRoot ?? pointer?.dataRoot ?? (isPlainObject(preference) ? preference.dataRoot : undefined)
61
+ if (configured !== undefined) {
62
+ if (typeof configured !== 'string' || !path.isAbsolute(configured)) refuse('data-root-not-absolute', 'a configured data root must be an absolute path')
63
+ return path.resolve(configured)
64
+ }
65
+ if (env.NODE_TEST_CONTEXT !== undefined) refuse('real-data-root-under-test', 'the platform data directory is never used under the test runner; inject a data root')
66
+ return defaultDataRoot({ platform, env, ...(homedir === undefined ? {} : { homedir }) })
67
+ }
68
+
69
+ export function workspaceStateRoot(dataRoot, workspaceId) {
70
+ if (typeof workspaceId !== 'string' || !IDENTIFIER.test(workspaceId)) refuse('invalid-workspace-identity', 'a workspace identity must be a contract identifier')
71
+ return path.join(dataRoot, 'obsidian', segment(workspaceId))
72
+ }
73
+
74
+ // Every enrolled repository root plus the directories the project itself
75
+ // occupies. Private state may overlap none of them, in either direction.
76
+ export function protectedRoots(project) {
77
+ const roots = new Set()
78
+ for (const repo of project?.repos ?? []) if (!repo.external && typeof repo.path === 'string') roots.add(path.resolve(repo.path))
79
+ if (typeof project?.configDir === 'string') roots.add(path.resolve(project.configDir))
80
+ return [...roots].sort()
81
+ }
82
+
83
+ export function assertOutsideRepositories({ managedRoot, repositoryRoots }) {
84
+ const guard = checkManagedRoots({ managedRoots: [managedRoot], repositoryRoots })
85
+ if (!guard.ok) refuse(guard.refusals[0].code, guard.refusals[0].message, { refusals: guard.refusals.map(({ code }) => code) })
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // The pointer a project keeps in its ignored local state
90
+ // ---------------------------------------------------------------------------
91
+
92
+ function validatePointer(document) {
93
+ closedObject(document, { required: ['schema', 'workspaceId'], optional: ['dataRoot'] }, 'invalid-local-pointer', 'the Obsidian pointer')
94
+ if (document.schema !== LOCAL_POINTER_SCHEMA) refuse('invalid-local-pointer', 'the Obsidian pointer names an unknown schema')
95
+ if (typeof document.workspaceId !== 'string' || !IDENTIFIER.test(document.workspaceId)) refuse('invalid-local-pointer', 'the Obsidian pointer carries no usable workspace identity')
96
+ if (document.dataRoot !== undefined && (typeof document.dataRoot !== 'string' || !path.isAbsolute(document.dataRoot))) {
97
+ refuse('invalid-local-pointer', 'the Obsidian pointer names a data root that is not an absolute path')
98
+ }
99
+ return document
100
+ }
101
+
102
+ export const localPointerPath = (project) => path.join(localStateRoot(project), LOCAL_POINTER_FILE)
103
+
104
+ function readJsonFile(file, code, label) {
105
+ let text
106
+ try { text = readRegularTextNoFollow(file) } catch (error) {
107
+ if (error.code === 'ENOENT') return null
108
+ refuse(code, `${label} cannot be read`, { cause: error.code ?? String(error.message) })
109
+ }
110
+ try { return JSON.parse(text) } catch { return refuse(code, `${label} is not JSON`) }
111
+ }
112
+
113
+ export function readLocalPointer(project) {
114
+ const document = readJsonFile(localPointerPath(project), 'invalid-local-pointer', 'the Obsidian pointer')
115
+ return document === null ? null : validatePointer(document)
116
+ }
117
+
118
+ // The pointer is written only where the project's local state is proven
119
+ // ignored, so a machine-local value can never be committed by accident.
120
+ export function writeLocalPointer(project, pointer) {
121
+ validatePointer(pointer)
122
+ if (project?.localState?.ignored !== true) refuse('local-state-not-ignored', `${LOCAL_STATE_DIR}/ is not ignored in this project; the Obsidian pointer is not written`)
123
+ const directory = ensureContainedPrivateDirectory({ workspaceRoot: project.configDir, directory: localStateRoot(project), label: 'project local state' })
124
+ atomicReplacePrivateText(path.join(directory, LOCAL_POINTER_FILE), canonicalJson(pointer))
125
+ return pointer
126
+ }
127
+
128
+ // Reads the persisted workspace identity, or issues a random one and persists
129
+ // it. `randomBytes` is injectable so tests are deterministic.
130
+ export function ensureWorkspaceIdentity({ project, dataRoot, randomBytes = cryptoRandomBytes } = {}) {
131
+ const existing = readLocalPointer(project)
132
+ if (existing) return existing
133
+ const pointer = { schema: LOCAL_POINTER_SCHEMA, workspaceId: `ws-${randomBytes(12).toString('hex')}`, ...(dataRoot === undefined ? {} : { dataRoot: path.resolve(dataRoot) }) }
134
+ return writeLocalPointer(project, pointer)
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Machine settings
139
+ // ---------------------------------------------------------------------------
140
+
141
+ function validateMachineSettings(document, workspaceId) {
142
+ closedObject(document, { required: ['schema', 'workspaceId', 'maintenanceMode', 'audienceAllow', 'applyPolicy', 'updatedAt'] }, 'invalid-machine-settings', 'machine settings')
143
+ if (document.schema !== MACHINE_SETTINGS_SCHEMA) refuse('invalid-machine-settings', 'machine settings name an unknown schema')
144
+ if (document.workspaceId !== workspaceId) refuse('invalid-machine-settings', 'machine settings belong to another workspace')
145
+ if (!MAINTENANCE_MODES.includes(document.maintenanceMode)) refuse('invalid-machine-settings', 'maintenanceMode must be manual or automatic')
146
+ if (!Array.isArray(document.audienceAllow) || document.audienceAllow.length > 64 || document.audienceAllow.some((item) => typeof item !== 'string' || !IDENTIFIER.test(item))
147
+ || new Set(document.audienceAllow).size !== document.audienceAllow.length) {
148
+ refuse('invalid-machine-settings', 'audienceAllow must be a list of distinct audience identifiers')
149
+ }
150
+ if (document.applyPolicy !== null) {
151
+ closedObject(document.applyPolicy, { required: ['policyId', 'version', 'digest'] }, 'invalid-machine-settings', 'the apply policy reference')
152
+ const { policyId, version, digest } = document.applyPolicy
153
+ if (typeof policyId !== 'string' || !IDENTIFIER.test(policyId) || !Number.isInteger(version) || version < 1 || typeof digest !== 'string' || !DIGEST.test(digest)) {
154
+ refuse('invalid-machine-settings', 'the apply policy reference is malformed')
155
+ }
156
+ }
157
+ if (typeof document.updatedAt !== 'string' || !TIMESTAMP.test(document.updatedAt)) refuse('invalid-machine-settings', 'updatedAt must be a UTC timestamp')
158
+ return document
159
+ }
160
+
161
+ // The defaults fail closed: edits are only queued, no audience is visible and
162
+ // no apply policy is installed until the person says otherwise.
163
+ export function defaultMachineSettings({ workspaceId, updatedAt }) {
164
+ return { schema: MACHINE_SETTINGS_SCHEMA, workspaceId, maintenanceMode: 'manual', audienceAllow: [], applyPolicy: null, updatedAt }
165
+ }
166
+
167
+ const settingsDirectory = (workspaceRoot) => ensureContainedPrivateDirectory({ workspaceRoot, directory: path.join(workspaceRoot, 'state', 'settings'), label: 'Obsidian machine settings' })
168
+ const settingsFile = (workspaceRoot, name) => path.join(workspaceRoot, 'state', 'settings', name)
169
+
170
+ export function readMachineSettings({ workspaceRoot, workspaceId }) {
171
+ const document = readJsonFile(settingsFile(workspaceRoot, 'machine.json'), 'invalid-machine-settings', 'machine settings')
172
+ return document === null ? null : validateMachineSettings(document, workspaceId)
173
+ }
174
+
175
+ export function writeMachineSettings({ workspaceRoot, workspaceId, settings, repositoryRoots }) {
176
+ validateMachineSettings(settings, workspaceId)
177
+ assertOutsideRepositories({ managedRoot: workspaceRoot, repositoryRoots })
178
+ fs.mkdirSync(workspaceRoot, { recursive: true, mode: 0o700 })
179
+ atomicReplacePrivateText(path.join(settingsDirectory(workspaceRoot), 'machine.json'), canonicalJson(settings))
180
+ return settings
181
+ }
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // Installed apply policy
185
+ // ---------------------------------------------------------------------------
186
+
187
+ // `digestOf` is the canonical digest of a policy, handed in by the caller that installs for a person (the command):
188
+ // the decision that reads the policy recomputes that digest on every apply, so a policy that carries another one
189
+ // could be installed and would then refuse every automatic apply. With it, such a policy is refused here, naming the
190
+ // digest it has to carry. A digest is not a secret. The file of the person is never rewritten.
191
+ export function installApplyPolicy({ workspaceRoot, workspaceId, policy, repositoryRoots, updatedAt, digestOf = null }) {
192
+ try { assertObsidianContract('apply-policy', policy) } catch (error) {
193
+ if (error instanceof ObsidianContractRefusal) refuse('invalid-apply-policy', 'the apply policy does not satisfy its contract', { errors: error.detail?.errors ?? [] })
194
+ throw error
195
+ }
196
+ if (policy.workspaceId !== workspaceId) refuse('invalid-apply-policy', 'the apply policy belongs to another workspace')
197
+ if (digestOf !== null) {
198
+ const expected = digestOf(policy)
199
+ if (policy.digest !== expected) refuse('policy-digest-mismatch', `the policy does not carry the digest of its content; it has to carry ${expected}`, { expected, carried: policy.digest })
200
+ }
201
+ const current = readMachineSettings({ workspaceRoot, workspaceId }) ?? defaultMachineSettings({ workspaceId, updatedAt })
202
+ assertOutsideRepositories({ managedRoot: workspaceRoot, repositoryRoots })
203
+ fs.mkdirSync(workspaceRoot, { recursive: true, mode: 0o700 })
204
+ atomicReplacePrivateText(path.join(settingsDirectory(workspaceRoot), 'apply-policy.json'), canonicalJson(policy))
205
+ return writeMachineSettings({ workspaceRoot, workspaceId, repositoryRoots, settings: { ...current, applyPolicy: { policyId: policy.policyId, version: policy.version, digest: policy.digest }, updatedAt } })
206
+ }
207
+
208
+ // Whether an automatic dispatch is authorized right now. Reads both files
209
+ // from disk on every call; the engine calls this immediately before each
210
+ // dispatch, so a revocation or a pause takes effect for the very next edit.
211
+ // Anything missing, malformed or mismatched is "not authorized", never an
212
+ // error that could be mistaken for permission.
213
+ // `assumeAutomatic` answers "would automatic mode be authorized": the check `mode set automatic` makes before it writes.
214
+ export function authorizeAutomaticApply({ workspaceRoot, workspaceId, assumeAutomatic = false }) {
215
+ const denied = (reason) => ({ authorized: false, reason, policy: null })
216
+ let settings
217
+ try { settings = readMachineSettings({ workspaceRoot, workspaceId }) } catch { return denied('machine-settings-invalid') }
218
+ if (!settings) return denied('machine-settings-absent')
219
+ if (settings.maintenanceMode !== 'automatic' && assumeAutomatic !== true) return denied('maintenance-mode-manual')
220
+ if (settings.applyPolicy === null) return denied('no-apply-policy-installed')
221
+ let policy
222
+ try {
223
+ policy = readJsonFile(settingsFile(workspaceRoot, 'apply-policy.json'), 'invalid-apply-policy', 'the apply policy')
224
+ if (policy !== null) assertObsidianContract('apply-policy', policy)
225
+ } catch { return denied('apply-policy-invalid') }
226
+ if (policy === null) return denied('apply-policy-absent')
227
+ const reference = settings.applyPolicy
228
+ if (policy.workspaceId !== workspaceId || policy.policyId !== reference.policyId || policy.version !== reference.version || policy.digest !== reference.digest) {
229
+ return denied('apply-policy-reference-mismatch')
230
+ }
231
+ if (policy.mode !== 'automatic') return denied('apply-policy-manual')
232
+ if (policy.status !== 'active') return denied(`apply-policy-${policy.status}`)
233
+ return { authorized: true, reason: 'apply-policy-active', policy }
234
+ }
235
+
236
+ // The installed policy as stored, validated, or null. A stored policy that does not validate refuses.
237
+ export function readInstalledApplyPolicy({ workspaceRoot, workspaceId }) {
238
+ const policy = readJsonFile(settingsFile(workspaceRoot, 'apply-policy.json'), 'invalid-apply-policy', 'the apply policy')
239
+ if (policy === null) return null
240
+ try { assertObsidianContract('apply-policy', policy) } catch (error) {
241
+ if (error instanceof ObsidianContractRefusal) refuse('invalid-apply-policy', 'the stored apply policy does not satisfy its contract', { errors: error.detail?.errors ?? [] })
242
+ throw error
243
+ }
244
+ if (policy.workspaceId !== workspaceId) refuse('invalid-apply-policy', 'the stored apply policy belongs to another workspace')
245
+ return policy
246
+ }
247
+
248
+ // Revocation. The stored policy is marked revoked first: from that write on, every authorization read denies, so an
249
+ // apply that is queued behind it is never dispatched. Then maintenance goes back to manual, so installing a policy
250
+ // later does not resume automatic apply by itself.
251
+ export function revokeApplyPolicy({ workspaceRoot, workspaceId, repositoryRoots, updatedAt }) {
252
+ const policy = readInstalledApplyPolicy({ workspaceRoot, workspaceId })
253
+ const current = readMachineSettings({ workspaceRoot, workspaceId })
254
+ if (policy === null && (current === null || current.applyPolicy === null)) return { revoked: false, reason: 'no-apply-policy-installed', policy: null }
255
+ assertOutsideRepositories({ managedRoot: workspaceRoot, repositoryRoots })
256
+ const revoked = policy === null ? null : { ...policy, status: 'revoked' }
257
+ if (revoked !== null) atomicReplacePrivateText(path.join(settingsDirectory(workspaceRoot), 'apply-policy.json'), canonicalJson(revoked))
258
+ if (current !== null) writeMachineSettings({ workspaceRoot, workspaceId, repositoryRoots, settings: { ...current, maintenanceMode: 'manual', updatedAt } })
259
+ return { revoked: true, reason: policy?.status === 'revoked' ? 'already-revoked' : 'revoked', policy: revoked }
260
+ }