@mnstry/atelier 0.2.0-alpha.5 → 0.2.0-alpha.8

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 (383) hide show
  1. package/CHANGELOG.md +191 -0
  2. package/README.md +86 -25
  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/docs/assurance-controls.md +12 -2
  31. package/docs/atelier-runtime.md +13 -2
  32. package/docs/atelier-sync.md +172 -0
  33. package/docs/blocks/claims.md +28 -18
  34. package/docs/blocks/will-not-do.md +12 -3
  35. package/docs/coauthor-session.md +57 -0
  36. package/docs/connected-composition.md +75 -0
  37. package/docs/decisions.md +167 -0
  38. package/docs/exact-upgrades.md +159 -0
  39. package/docs/extension-pack-lifecycle.md +33 -0
  40. package/docs/guided-upgrades.md +99 -0
  41. package/docs/install.md +65 -9
  42. package/docs/intake-and-guides.md +65 -0
  43. package/docs/integration-and-review-plan.md +409 -0
  44. package/docs/integration-contract-decisions.md +22 -0
  45. package/docs/local-review.md +84 -0
  46. package/docs/local-services.md +227 -0
  47. package/docs/local-state.md +107 -0
  48. package/docs/obsidian-contract.md +687 -0
  49. package/docs/obsidian.md +387 -0
  50. package/docs/portable-presentation.md +328 -0
  51. package/docs/presentation-consumer-boundaries.md +75 -0
  52. package/docs/presentation-review-disposition.md +47 -0
  53. package/docs/project-command-inventory.md +38 -0
  54. package/docs/project-options.md +53 -0
  55. package/docs/release-engineering.md +99 -2
  56. package/docs/review-portability.md +55 -0
  57. package/docs/skill-steward.md +156 -0
  58. package/docs/upgrade.md +70 -2
  59. package/docs/vault-service.md +292 -0
  60. package/fixtures/atelier-coauthor/invalid/authority.v1.json +5 -0
  61. package/fixtures/atelier-coauthor/valid/config.v1.json +12 -0
  62. package/fixtures/atelier-guide/invalid/authority.v1.json +11 -0
  63. package/fixtures/atelier-guide/valid/capability.v1.json +10 -0
  64. package/fixtures/atelier-guide/valid/consent.v1.json +11 -0
  65. package/fixtures/atelier-guide/valid/engagement.v1.json +7 -0
  66. package/fixtures/atelier-guide/valid/offer.v1.json +10 -0
  67. package/fixtures/atelier-intake/invalid/authority.v1.json +9 -0
  68. package/fixtures/atelier-intake/valid/attempt.v1.json +8 -0
  69. package/fixtures/atelier-intake/valid/completion.v1.json +9 -0
  70. package/fixtures/atelier-intake/valid/source.v1.json +7 -0
  71. package/fixtures/atelier-repository-observation/invalid/complete-with-blocker.v1.json +18 -0
  72. package/fixtures/atelier-repository-observation/valid/complete-local.v1.json +48 -0
  73. package/fixtures/atelier-review/invalid/authenticated-identity.v1.json +22 -0
  74. package/fixtures/atelier-review/valid/bound.v1.json +229 -0
  75. package/fixtures/atelier-review/valid/bundle.v1.json +267 -0
  76. package/fixtures/atelier-review/valid/contribution.v1.json +22 -0
  77. package/fixtures/atelier-review/valid/lifecycle.v1.json +14 -0
  78. package/fixtures/atelier-skill-steward/audit/invalid/source-mutation.v1.json +26 -0
  79. package/fixtures/atelier-skill-steward/audit/valid/clean.v1.json +26 -0
  80. package/fixtures/atelier-skill-steward/candidates/invalid/workflow-key.v1.json +37 -0
  81. package/fixtures/atelier-skill-steward/candidates/valid/create.v1.json +37 -0
  82. package/fixtures/atelier-skill-steward/lock/invalid/bad-digest.v1.json +18 -0
  83. package/fixtures/atelier-skill-steward/lock/valid/installed.v1.json +23 -0
  84. package/fixtures/atelier-skill-steward/plan/invalid/delete-action.v1.json +33 -0
  85. package/fixtures/atelier-skill-steward/plan/valid/add.v1.json +33 -0
  86. package/fixtures/atelier-upgrade-transaction/adoption-policy/invalid/example.json +12 -0
  87. package/fixtures/atelier-upgrade-transaction/adoption-policy/valid/example.json +11 -0
  88. package/fixtures/atelier-upgrade-transaction/migration/invalid/example.json +20 -0
  89. package/fixtures/atelier-upgrade-transaction/migration/valid/example.json +19 -0
  90. package/fixtures/atelier-upgrade-transaction/upgrade-plan/invalid/example.json +58 -0
  91. package/fixtures/atelier-upgrade-transaction/upgrade-plan/valid/example.json +57 -0
  92. package/fixtures/atelier-upgrade-transaction/upgrade-receipt/invalid/example.json +15 -0
  93. package/fixtures/atelier-upgrade-transaction/upgrade-receipt/valid/example.json +14 -0
  94. package/fixtures/atelier-vault-publication/invalid/publication.json +5 -0
  95. package/fixtures/atelier-vault-publication/valid/publication.json +11 -0
  96. package/fixtures/decisions/request/invalid/unknown-authority.v1.json +61 -0
  97. package/fixtures/decisions/request/valid/triage.v1.json +60 -0
  98. package/fixtures/decisions/result/invalid/execution-authority.v1.json +50 -0
  99. package/fixtures/decisions/result/valid/abstained.v1.json +23 -0
  100. package/fixtures/decisions/result/valid/assessed.v1.json +50 -0
  101. package/fixtures/obsidian/acceptance/ap05-extra-notes.json +14 -0
  102. package/fixtures/obsidian/acceptance/receipts/G07.valid.v1.json +63 -0
  103. package/fixtures/obsidian/acceptance/receipts/G13.valid.v1.json +69 -0
  104. package/fixtures/obsidian/acceptance/receipts/G14.valid.v1.json +62 -0
  105. package/fixtures/obsidian/acceptance/receipts/G15.valid.v1.json +56 -0
  106. package/fixtures/obsidian/acceptance/receipts/G16.valid.v1.json +73 -0
  107. package/fixtures/obsidian/acceptance/receipts/G17.valid.v1.json +75 -0
  108. package/fixtures/obsidian/acceptance/receipts/G18.valid.v1.json +64 -0
  109. package/fixtures/obsidian/acceptance/service-sentinels.json +10 -0
  110. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/duplicate-evidence-name.v1.json +39 -0
  111. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/evidence-by-absolute-path.v1.json +34 -0
  112. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/missing-candidate-tree.v1.json +33 -0
  113. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/no-evidence.v1.json +28 -0
  114. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-outcome.v1.json +34 -0
  115. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-top-level-field.v1.json +35 -0
  116. package/fixtures/obsidian/contracts/acceptance-receipt/valid/node-gate.v1.json +34 -0
  117. package/fixtures/obsidian/contracts/apply-policy/invalid/automatic-without-edit-class.v1.json +27 -0
  118. package/fixtures/obsidian/contracts/apply-policy/invalid/conflict-overwrite.v1.json +29 -0
  119. package/fixtures/obsidian/contracts/apply-policy/invalid/missing-policy-digest.v1.json +28 -0
  120. package/fixtures/obsidian/contracts/apply-policy/invalid/unbounded-batch.v1.json +29 -0
  121. package/fixtures/obsidian/contracts/apply-policy/invalid/unimplemented-edit-class.v1.json +30 -0
  122. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-mode.v1.json +29 -0
  123. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-selector-operator.v1.json +22 -0
  124. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-top-level-field.v1.json +30 -0
  125. package/fixtures/obsidian/contracts/apply-policy/valid/automatic-scoped.v1.json +29 -0
  126. package/fixtures/obsidian/contracts/apply-policy/valid/manual-default.v1.json +20 -0
  127. package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-path-in-ext.v1.json +30 -0
  128. package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-repository-root.v1.json +27 -0
  129. package/fixtures/obsidian/contracts/corpus-profile/invalid/duplicate-repository-id.v1.json +27 -0
  130. package/fixtures/obsidian/contracts/corpus-profile/invalid/overlapping-managed-roots.v1.json +27 -0
  131. package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-enrollment.v1.json +27 -0
  132. package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-top-level-field.v1.json +28 -0
  133. package/fixtures/obsidian/contracts/corpus-profile/valid/three-repositories.v1.json +27 -0
  134. package/fixtures/obsidian/contracts/edit-operation/invalid/absolute-recovery-ref.v1.json +21 -0
  135. package/fixtures/obsidian/contracts/edit-operation/invalid/inline-observed-bytes.v1.json +22 -0
  136. package/fixtures/obsidian/contracts/edit-operation/invalid/missing-base-digest.v1.json +20 -0
  137. package/fixtures/obsidian/contracts/edit-operation/invalid/short-idempotency-key.v1.json +21 -0
  138. package/fixtures/obsidian/contracts/edit-operation/invalid/unknown-kind.v1.json +21 -0
  139. package/fixtures/obsidian/contracts/edit-operation/valid/pending-body-replacement.v1.json +21 -0
  140. package/fixtures/obsidian/contracts/ext-settings/invalid/absolute-path-prefix.v1.json +28 -0
  141. package/fixtures/obsidian/contracts/ext-settings/invalid/apply-policy-in-portable-settings.v1.json +30 -0
  142. package/fixtures/obsidian/contracts/ext-settings/invalid/duplicate-scope-id.v1.json +27 -0
  143. package/fixtures/obsidian/contracts/ext-settings/invalid/machine-local-vault-path.v1.json +28 -0
  144. package/fixtures/obsidian/contracts/ext-settings/invalid/service-port-in-portable-settings.v1.json +28 -0
  145. package/fixtures/obsidian/contracts/ext-settings/invalid/unknown-default-scope.v1.json +27 -0
  146. package/fixtures/obsidian/contracts/ext-settings/valid/disabled.v1.json +5 -0
  147. package/fixtures/obsidian/contracts/ext-settings/valid/enabled-with-scopes.v1.json +27 -0
  148. package/fixtures/obsidian/contracts/generation-manifest/invalid/absolute-note-path.v1.json +114 -0
  149. package/fixtures/obsidian/contracts/generation-manifest/invalid/complete-with-unwritten-notes.v1.json +114 -0
  150. package/fixtures/obsidian/contracts/generation-manifest/invalid/derived-link-claimed-declared.v1.json +114 -0
  151. package/fixtures/obsidian/contracts/generation-manifest/invalid/duplicate-note-identity.v1.json +114 -0
  152. package/fixtures/obsidian/contracts/generation-manifest/invalid/in-scope-endpoint-missing.v1.json +114 -0
  153. package/fixtures/obsidian/contracts/generation-manifest/invalid/title-only-note-path.v1.json +114 -0
  154. package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-note-field.v1.json +115 -0
  155. package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-relation-type.v1.json +114 -0
  156. package/fixtures/obsidian/contracts/generation-manifest/invalid/withheld-endpoint.v1.json +114 -0
  157. package/fixtures/obsidian/contracts/generation-manifest/valid/two-notes.v1.json +114 -0
  158. package/fixtures/obsidian/contracts/oracles/scope-cases.json +412 -0
  159. package/fixtures/obsidian/contracts/proposal-receipt/invalid/accepted-without-proposal-id.v1.json +12 -0
  160. package/fixtures/obsidian/contracts/proposal-receipt/invalid/missing-adapter-operation-id.v1.json +11 -0
  161. package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-backpressure-outcome.v1.json +12 -0
  162. package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-top-level-field.v1.json +13 -0
  163. package/fixtures/obsidian/contracts/proposal-receipt/valid/accepted-new.v1.json +12 -0
  164. package/fixtures/obsidian/contracts/proposal-receipt/valid/deferred.v1.json +12 -0
  165. package/fixtures/obsidian/contracts/publication-journal/invalid/absolute-recovery-ref.v1.json +47 -0
  166. package/fixtures/obsidian/contracts/publication-journal/invalid/duplicate-sequence.v1.json +47 -0
  167. package/fixtures/obsidian/contracts/publication-journal/invalid/empty-protocol-id.v1.json +47 -0
  168. package/fixtures/obsidian/contracts/publication-journal/invalid/missing-protocol-id.v1.json +46 -0
  169. package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-entry-field.v1.json +48 -0
  170. package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-state.v1.json +47 -0
  171. package/fixtures/obsidian/contracts/publication-journal/valid/committed.v1.json +47 -0
  172. package/fixtures/obsidian/contracts/publication-journal/valid/updating-after-restart.v1.json +22 -0
  173. package/fixtures/obsidian/contracts/scope/invalid/absolute-path-prefix.v1.json +15 -0
  174. package/fixtures/obsidian/contracts/scope/invalid/duplicate-ids.v1.json +18 -0
  175. package/fixtures/obsidian/contracts/scope/invalid/expansion-without-node-budget.v1.json +40 -0
  176. package/fixtures/obsidian/contracts/scope/invalid/full-mode-with-subset.v1.json +8 -0
  177. package/fixtures/obsidian/contracts/scope/invalid/unknown-mode.v1.json +41 -0
  178. package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-key.v1.json +15 -0
  179. package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-operator.v1.json +14 -0
  180. package/fixtures/obsidian/contracts/scope/invalid/unknown-top-level-field.v1.json +42 -0
  181. package/fixtures/obsidian/contracts/scope/valid/explicit-empty.v1.json +8 -0
  182. package/fixtures/obsidian/contracts/scope/valid/focus.v1.json +11 -0
  183. package/fixtures/obsidian/contracts/scope/valid/full.v1.json +8 -0
  184. package/fixtures/obsidian/contracts/scope/valid/scoped-set-expression.v1.json +41 -0
  185. package/fixtures/obsidian/contracts/service-state/invalid/hostname-instead-of-literal-loopback.v1.json +23 -0
  186. package/fixtures/obsidian/contracts/service-state/invalid/missing-consent.v1.json +18 -0
  187. package/fixtures/obsidian/contracts/service-state/invalid/privileged-port.v1.json +23 -0
  188. package/fixtures/obsidian/contracts/service-state/invalid/unknown-top-level-field.v1.json +24 -0
  189. package/fixtures/obsidian/contracts/service-state/invalid/wildcard-bind.v1.json +23 -0
  190. package/fixtures/obsidian/contracts/service-state/valid/healthy.v1.json +23 -0
  191. package/fixtures/obsidian/contracts/source-snapshot/invalid/absolute-file-path.v1.json +48 -0
  192. package/fixtures/obsidian/contracts/source-snapshot/invalid/duplicate-repository-id.v1.json +48 -0
  193. package/fixtures/obsidian/contracts/source-snapshot/invalid/mixed-read.v1.json +48 -0
  194. package/fixtures/obsidian/contracts/source-snapshot/invalid/parent-traversal-path.v1.json +48 -0
  195. package/fixtures/obsidian/contracts/source-snapshot/invalid/unknown-file-field.v1.json +49 -0
  196. package/fixtures/obsidian/contracts/source-snapshot/valid/two-repositories.v1.json +48 -0
  197. package/fixtures/obsidian/edits/workspace.json +65 -0
  198. package/fixtures/obsidian/materialization/expected-full.json +99 -0
  199. package/fixtures/obsidian/materialization/expected-scoped.json +39 -0
  200. package/fixtures/obsidian/materialization/workspace.json +54 -0
  201. package/fixtures/obsidian/proposals/expected-shapes.json +11 -0
  202. package/fixtures/ui/presentation/pane.v1.json +7 -0
  203. package/fixtures/ui/presentation/reference.v1.json +293 -0
  204. package/package.json +61 -6
  205. package/skills/claude/atelier-guided-coauthor/SKILL.md +49 -0
  206. package/skills/claude/atelier-guided-upgrade/SKILL.md +97 -0
  207. package/skills/claude/atelier-open/SKILL.md +5 -0
  208. package/skills/claude/atelier-skill-steward/SKILL.md +62 -0
  209. package/skills/claude/mnstry-readiness/SKILL.md +5 -0
  210. package/skills/codex/atelier-guided-coauthor/SKILL.md +49 -0
  211. package/skills/codex/atelier-guided-upgrade/SKILL.md +97 -0
  212. package/skills/codex/atelier-open/SKILL.md +5 -0
  213. package/skills/codex/atelier-skill-steward/SKILL.md +62 -0
  214. package/skills/codex/mnstry-readiness/SKILL.md +5 -0
  215. package/src/access/preview-enforcer.mjs +84 -0
  216. package/src/boundary/content-rules.mjs +9 -4
  217. package/src/boundary/policy.mjs +74 -42
  218. package/src/cli/project-options.mjs +58 -0
  219. package/src/cli/run.mjs +61 -3
  220. package/src/coauthor/session.mjs +161 -0
  221. package/src/coauthor/store.mjs +173 -0
  222. package/src/collaboration/event-ledger.mjs +16 -33
  223. package/src/collaboration/inspection-bundle.mjs +223 -0
  224. package/src/collaboration/review-contracts.mjs +25 -0
  225. package/src/collaboration/review-store.mjs +305 -0
  226. package/src/commands/coauthor.mjs +26 -0
  227. package/src/commands/config.mjs +6 -1
  228. package/src/commands/extension-pack.mjs +15 -8
  229. package/src/commands/init.mjs +44 -5
  230. package/src/commands/obsidian.mjs +368 -0
  231. package/src/commands/review.mjs +147 -0
  232. package/src/commands/setup.mjs +51 -14
  233. package/src/commands/skills.mjs +199 -0
  234. package/src/commands/sync.mjs +107 -0
  235. package/src/composition/registry.mjs +27 -0
  236. package/src/composition/wire.mjs +122 -0
  237. package/src/contracts/corpus.mjs +35 -0
  238. package/src/decisions/contracts.d.mts +96 -0
  239. package/src/decisions/contracts.mjs +321 -0
  240. package/src/disclosure/content-scan.mjs +13 -0
  241. package/src/export/atelier-export-contract.mjs +16 -1
  242. package/src/extension-packs/lifecycle.mjs +154 -0
  243. package/src/graph/graph.mjs +64 -2
  244. package/src/graph/knowledge-graph.mjs +548 -24
  245. package/src/guides/contracts.mjs +54 -0
  246. package/src/index.mjs +29 -0
  247. package/src/intake/store.mjs +119 -0
  248. package/src/preview/channel.mjs +89 -0
  249. package/src/project/config.mjs +50 -49
  250. package/src/project/durable-state.mjs +161 -0
  251. package/src/project/file-class.mjs +166 -0
  252. package/src/project/private-state.mjs +11 -1
  253. package/src/projection/obsidian/contracts.mjs +571 -0
  254. package/src/projection/obsidian/edits/align.mjs +169 -0
  255. package/src/projection/obsidian/edits/apply.mjs +889 -0
  256. package/src/projection/obsidian/edits/arbitrate.mjs +414 -0
  257. package/src/projection/obsidian/edits/contribution.mjs +108 -0
  258. package/src/projection/obsidian/edits/index.mjs +19 -0
  259. package/src/projection/obsidian/edits/object-identity.mjs +61 -0
  260. package/src/projection/obsidian/edits/object-store.mjs +517 -0
  261. package/src/projection/obsidian/edits/observe.mjs +123 -0
  262. package/src/projection/obsidian/edits/policy.mjs +123 -0
  263. package/src/projection/obsidian/edits/regions.mjs +648 -0
  264. package/src/projection/obsidian/materialize/byte-lens.mjs +98 -0
  265. package/src/projection/obsidian/materialize/index.mjs +7 -0
  266. package/src/projection/obsidian/materialize/path-registry.mjs +105 -0
  267. package/src/projection/obsidian/materialize/prepare-view.mjs +746 -0
  268. package/src/projection/obsidian/materialize/settings.mjs +57 -0
  269. package/src/projection/obsidian/materialize/stage.mjs +27 -0
  270. package/src/projection/obsidian/proposals/adapter.mjs +572 -0
  271. package/src/projection/obsidian/proposals/backpressure.mjs +113 -0
  272. package/src/projection/obsidian/proposals/contribution.mjs +55 -0
  273. package/src/projection/obsidian/proposals/index.mjs +23 -0
  274. package/src/projection/obsidian/proposals/observation.mjs +216 -0
  275. package/src/projection/obsidian/proposals/queue.mjs +277 -0
  276. package/src/projection/obsidian/proposals/router.mjs +168 -0
  277. package/src/projection/obsidian/publication/bridge-script.mjs +266 -0
  278. package/src/projection/obsidian/publication/exchange.mjs +117 -0
  279. package/src/projection/obsidian/publication/index.mjs +5 -0
  280. package/src/projection/obsidian/publication/publisher.mjs +588 -0
  281. package/src/projection/obsidian/publication/test-seam.mjs +16 -0
  282. package/src/projection/obsidian/publication/transport.mjs +139 -0
  283. package/src/projection/obsidian/recovery/index.mjs +6 -0
  284. package/src/projection/obsidian/recovery/journal.mjs +134 -0
  285. package/src/projection/obsidian/recovery/late-writer.mjs +46 -0
  286. package/src/projection/obsidian/recovery/restart.mjs +316 -0
  287. package/src/projection/obsidian/recovery/store.mjs +234 -0
  288. package/src/projection/obsidian/selection-ui/conflict-view.mjs +91 -0
  289. package/src/projection/obsidian/selection-ui/contribution.mjs +141 -0
  290. package/src/projection/obsidian/selection-ui/focus.mjs +82 -0
  291. package/src/projection/obsidian/selection-ui/index.mjs +12 -0
  292. package/src/projection/obsidian/selection-ui/policy-setup.mjs +107 -0
  293. package/src/projection/obsidian/selection-ui/receipt.mjs +173 -0
  294. package/src/projection/obsidian/selection-ui/selection-state.mjs +122 -0
  295. package/src/projection/obsidian/selection-ui/selection.mjs +109 -0
  296. package/src/projection/project.mjs +28 -19
  297. package/src/readiness-protocols/evidence.mjs +282 -0
  298. package/src/readiness-protocols/runtime.mjs +4 -3
  299. package/src/readiness-protocols/source-read.mjs +39 -0
  300. package/src/runtime/git-adapter.mjs +189 -0
  301. package/src/runtime/local-state.mjs +439 -0
  302. package/src/runtime/obsidian/app-capability.mjs +110 -0
  303. package/src/runtime/obsidian/app-production-seams.mjs +96 -0
  304. package/src/runtime/obsidian/contributions/proposal-adapter.mjs +4 -0
  305. package/src/runtime/obsidian/contributions/selection-ui.mjs +4 -0
  306. package/src/runtime/obsidian/contributions/source-apply.mjs +4 -0
  307. package/src/runtime/obsidian/contributions.mjs +27 -0
  308. package/src/runtime/obsidian/documents.mjs +37 -0
  309. package/src/runtime/obsidian/enablement.mjs +53 -0
  310. package/src/runtime/obsidian/engine.mjs +532 -0
  311. package/src/runtime/obsidian/errors.mjs +14 -0
  312. package/src/runtime/obsidian/extension-points.mjs +103 -0
  313. package/src/runtime/obsidian/index.mjs +34 -0
  314. package/src/runtime/obsidian/lifecycle.mjs +258 -0
  315. package/src/runtime/obsidian/machine-settings.mjs +260 -0
  316. package/src/runtime/obsidian/observation.mjs +158 -0
  317. package/src/runtime/obsidian/opening.mjs +243 -0
  318. package/src/runtime/obsidian/pending-edits.mjs +141 -0
  319. package/src/runtime/obsidian/pipeline.mjs +122 -0
  320. package/src/runtime/obsidian/private-lock.mjs +156 -0
  321. package/src/runtime/obsidian/process-identity.mjs +58 -0
  322. package/src/runtime/obsidian/service-client.mjs +66 -0
  323. package/src/runtime/obsidian/service-main.mjs +89 -0
  324. package/src/runtime/obsidian/service-record.mjs +169 -0
  325. package/src/runtime/obsidian/service-server.mjs +121 -0
  326. package/src/runtime/obsidian/service.mjs +216 -0
  327. package/src/runtime/obsidian/startup-adapters.mjs +87 -0
  328. package/src/runtime/obsidian/state-store.mjs +180 -0
  329. package/src/runtime/obsidian/sync-notice.mjs +33 -0
  330. package/src/runtime/obsidian/tick-loop.mjs +84 -0
  331. package/src/runtime/obsidian/watchers.mjs +36 -0
  332. package/src/runtime/repository-observation.mjs +491 -0
  333. package/src/runtime/supervisor.mjs +803 -0
  334. package/src/server/local-sidecar.mjs +44 -0
  335. package/src/server/server.mjs +1 -0
  336. package/src/skills/steward.mjs +780 -0
  337. package/src/ui/presentation/browser.mjs +278 -0
  338. package/src/ui/presentation/contract.mjs +116 -0
  339. package/src/ui/presentation/index.mjs +7 -0
  340. package/src/ui/presentation/native.mjs +153 -0
  341. package/src/ui/presentation/proof.mjs +57 -0
  342. package/src/ui/presentation/schema-check.mjs +30 -0
  343. package/src/ui/presentation/schema.generated.mjs +1371 -0
  344. package/src/ui/presentation/state.mjs +43 -0
  345. package/src/ui/presentation/styles.mjs +67 -0
  346. package/src/ui/presentation/tokens.mjs +74 -0
  347. package/src/ui/presentation/web.mjs +85 -0
  348. package/src/ui/review-page.mjs +32 -0
  349. package/src/upgrade/explanation.mjs +33 -0
  350. package/src/upgrade/provenance.mjs +338 -0
  351. package/src/upgrade/transaction-files.mjs +103 -0
  352. package/src/upgrade/transaction.mjs +475 -0
  353. package/src/upgrade/upgrade.mjs +63 -28
  354. package/src/vault/hosts.mjs +21 -0
  355. package/src/vault/identity.mjs +19 -0
  356. package/src/vault/index.mjs +7 -0
  357. package/src/vault/interface.mjs +11 -0
  358. package/src/vault/metadata.mjs +31 -0
  359. package/src/vault/privacy.mjs +148 -0
  360. package/src/vault/probe.mjs +89 -0
  361. package/src/vault/service.mjs +131 -0
  362. package/src/vault/source.mjs +45 -0
  363. package/src/vault/storage.mjs +23 -0
  364. package/templates/distribution-workspace/.claude/launch.json +6 -2
  365. package/templates/distribution-workspace/gitignore +1 -0
  366. package/templates/distribution-workspace/project/README.md +2 -2
  367. package/templates/external-project-workspace/README.md +45 -0
  368. package/templates/external-project-workspace/answers.example.json +4 -0
  369. package/templates/external-project-workspace/atelier.project.json +40 -0
  370. package/templates/external-project-workspace/boundary-policy.v1.json +60 -0
  371. package/templates/external-project-workspace/gitignore +8 -0
  372. package/templates/external-project-workspace/pack-lifecycle.json +14 -0
  373. package/templates/external-project-workspace/packs/protocols/contract-gate.v1.json +109 -0
  374. package/templates/external-project-workspace/packs/sample-pack.v1.json +48 -0
  375. package/templates/external-project-workspace/repo-access.v1.json +9 -0
  376. package/templates/external-project-workspace/source/README.md +11 -0
  377. package/templates/launch.json +6 -2
  378. package/templates/private-domain-workspace/.claude/launch.json +6 -2
  379. package/templates/private-domain-workspace/domain/README.md +2 -2
  380. package/templates/private-domain-workspace/gitignore +1 -0
  381. package/templates/shared-project-workspace/.claude/launch.json +6 -2
  382. package/templates/shared-project-workspace/gitignore +1 -0
  383. package/templates/shared-project-workspace/project/README.md +2 -2
@@ -0,0 +1,517 @@
1
+ import { randomBytes as cryptoRandomBytes, randomUUID } from 'node:crypto'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { isPendingPrivateWrite } from '../../../project/durable-state.mjs'
5
+ import { checkManagedRoots } from '../../../project/file-class.mjs'
6
+ import { atomicReplacePrivateText, ensureContainedPrivateDirectory, openRegularFileNoFollow, syncPrivateDirectory } from '../../../project/private-state.mjs'
7
+ import { canonicalJson, isoTime } from '../../../runtime/obsidian/documents.mjs'
8
+ import { createAbandonmentProof, machineDigest } from '../../../runtime/obsidian/private-lock.mjs'
9
+ import { OBSIDIAN_EXT_KEY, validateObsidianContract } from '../contracts.mjs'
10
+ import { sha256Digest } from '../materialize/byte-lens.mjs'
11
+ import { ARBITRATION_RULES, EditArbitrationRefusal, OBJECT_EVENT_SCHEMA, REFUSED_DISPOSITIONS, arbitrateEvents, isObjectEvent, recordableOperation, refuseArbitration, sameOrigin } from './arbitrate.mjs'
12
+ import { decodeIdentitySegment, encodeIdentitySegment, isIdentifier, isIdentitySegment } from './object-identity.mjs'
13
+ import { editIdempotencyKey } from './observe.mjs'
14
+
15
+ // Durable arbitration of edits, shared by every view of one workspace.
16
+ //
17
+ // <stateRoot>/state/objects/<repo>/<node>/000000000001.json the events of one object
18
+ // <stateRoot>/state/object-index/<repo>/<node>.json a derived summary, a cache
19
+ //
20
+ // <repo> and <node> are encoded identifiers (object-identity.mjs); the
21
+ // repository is part of the key, so equal node ids of two repositories never
22
+ // meet. The state root is private workspace state, outside every repository
23
+ // and vault.
24
+ //
25
+ // The events of an object are its only truth. An event is one complete,
26
+ // canonical JSON file named by its sequence number, published with a
27
+ // non-overwriting hard link and never rewritten or removed. Every event names
28
+ // the digest of the one before it. An appender reads the log through N,
29
+ // decides, and publishes N+1; of two appenders exactly one gets the name, and
30
+ // the other reads again and decides again against what it lost to. So every
31
+ // event was decided against exactly the events before it, the log is a
32
+ // serialization of everything any process did, and there is no last writer to
33
+ // win: what an operation becomes is a function of the whole log
34
+ // (arbitrate.mjs), not of who wrote last.
35
+ //
36
+ // The compare/apply lease of an object is two of those events. Holding it
37
+ // means the last `lease-acquired` is not followed by its `lease-released`.
38
+ // It is taken from a holder only with proof that the holder is gone: its
39
+ // process does not exist on this machine (the proof rules of the engine lock,
40
+ // runtime/obsidian/private-lock.mjs). No amount of elapsed time is proof. A
41
+ // holder on another machine, or a live process that no longer knows it holds
42
+ // the lease, needs a person; so does an object whose log cannot be read,
43
+ // which refuses every operation and is never repaired by guessing.
44
+ //
45
+ // Only what the log cannot derive is asked of the caller; what it can derive
46
+ // is also written down, as `conflict-declared`, `superseded` and
47
+ // `siblings-stale` events that follow the event that caused them. A crash
48
+ // between the two loses nothing: the state is derived from the cause, and the
49
+ // next writer or `recoverObject` appends the declaration that is missing.
50
+ //
51
+ // Bounds. An event is at most 16 KiB (an observation is about 1.5 KiB). An
52
+ // object holds at most 4096 events, about 500 edits taken through a full
53
+ // apply; the last 64 are kept for settling what is in flight (intent,
54
+ // outcome, declarations, release), so an observation or a new lease refuses
55
+ // `object-log-full` first. Nothing is rotated, compacted or dropped: a full
56
+ // object refuses, and what becomes of it is a person's decision.
57
+ //
58
+ // Nothing here writes to a source file or to a vault, and no event, index or
59
+ // error holds note or source text: identifiers, digests, store references,
60
+ // codes, numbers and timestamps only.
61
+
62
+ export const OBJECTS_DIRECTORY = path.join('state', 'objects')
63
+ export const OBJECT_INDEX_DIRECTORY = path.join('state', 'object-index')
64
+ export const OBJECT_INDEX_SCHEMA = 'atelier-obsidian-object-index/v1'
65
+ export const EDIT_ACKNOWLEDGEMENT_SCHEMA = 'atelier-obsidian-edit-acknowledgement/v1'
66
+ export const OBJECT_STORE_LIMITS = Object.freeze({ maxEventBytes: 16 * 1024, maxEventsPerObject: 4096, reservedForSettlement: 64, maxAppendAttempts: 256 })
67
+ export const OBJECT_STORE_CRASH_STEPS = Object.freeze(['event-linked', 'event-published', 'index-updated'])
68
+
69
+ const EVENT_NAME = /^(\d{12})\.json$/
70
+ const eventName = (sequence) => `${String(sequence).padStart(12, '0')}.json`
71
+ const NEEDS_PERSON = new Set(['held-on-another-machine'])
72
+
73
+ export const OBJECT_STORE_PRIMITIVES = Object.freeze({
74
+ rules: ARBITRATION_RULES,
75
+ limits: OBJECT_STORE_LIMITS,
76
+ // Proof that the holder of a lease is gone: its process does not exist on
77
+ // this machine. There is no maximum age.
78
+ proveAbandoned: createAbandonmentProof(),
79
+ // An index is used only when it was derived from exactly the events present.
80
+ indexIsCurrent: ({ index, eventCount, headDigest }) => index.sequence === eventCount && index.headDigest === headDigest,
81
+ })
82
+
83
+ // The members of `values` that were given: an absent optional member is absent from the event, never null.
84
+ const optional = (values) => Object.fromEntries(Object.entries(values).filter(([, value]) => value !== undefined && value !== null))
85
+
86
+ function readBytesNoFollow(file) {
87
+ const descriptor = openRegularFileNoFollow(file)
88
+ try { return fs.readFileSync(descriptor) } finally { fs.closeSync(descriptor) }
89
+ }
90
+
91
+ export function createObjectStoreForOracleTests(primitives = OBJECT_STORE_PRIMITIVES) {
92
+ const { rules, limits, proveAbandoned, indexIsCurrent } = primitives
93
+
94
+ return function openObjectStore({ stateRoot, workspaceId, repositoryRoots, clock, crash = () => {}, randomBytes = cryptoRandomBytes } = {}) {
95
+ if (typeof stateRoot !== 'string' || !path.isAbsolute(stateRoot)) throw new TypeError('stateRoot must be an absolute path')
96
+ if (!isIdentifier(workspaceId)) throw new TypeError('workspaceId must be a contract identifier')
97
+ if (typeof clock !== 'function') throw new TypeError('a clock must be injected')
98
+ if (!Array.isArray(repositoryRoots) || repositoryRoots.some((item) => typeof item !== 'string' || !path.isAbsolute(item))) {
99
+ throw new TypeError('repositoryRoots must list the absolute root of every enrolled repository; pass an explicit empty list when there is none')
100
+ }
101
+ const guard = checkManagedRoots({ managedRoots: [stateRoot], repositoryRoots })
102
+ if (!guard.ok) refuseArbitration('state-root-overlaps-repository', 'the arbitration state may not overlap an enrolled repository', { codes: guard.refusals.map((item) => item.code) })
103
+ fs.mkdirSync(stateRoot, { recursive: true, mode: 0o700 })
104
+ const root = fs.realpathSync.native(stateRoot)
105
+ // Two processes may create the same directory at once; the one that loses
106
+ // the mkdir finds it made, and checks it like any other.
107
+ const privateDir = (...parts) => {
108
+ for (let attempt = 0; ; attempt += 1) {
109
+ try { return ensureContainedPrivateDirectory({ workspaceRoot: root, directory: path.join(root, ...parts), label: 'edit arbitration state' }) } catch (error) {
110
+ if (error.code !== 'EEXIST' || attempt >= 8) throw error
111
+ }
112
+ }
113
+ }
114
+ const objectsRoot = privateDir(OBJECTS_DIRECTORY)
115
+ const indexRoot = privateDir(OBJECT_INDEX_DIRECTORY)
116
+ // Verified prefixes of logs this store has read. Events are immutable and
117
+ // chained, so a verified prefix stays verified; recoverObject reads afresh.
118
+ const verified = new Map()
119
+
120
+ function identityOf(value) {
121
+ const identity = { workspaceId, repoId: value?.repoId, nodeId: value?.nodeId }
122
+ if (!isIdentifier(identity.repoId) || !isIdentifier(identity.nodeId)) refuseArbitration('invalid-identity', 'an object is named by a repository identifier and a node identifier')
123
+ if (value.workspaceId !== undefined && value.workspaceId !== workspaceId) refuseArbitration('foreign-workspace', 'the object belongs to another workspace')
124
+ return identity
125
+ }
126
+ const segmentsOf = (identity) => [encodeIdentitySegment(identity.repoId), encodeIdentitySegment(identity.nodeId)]
127
+ const pointerOf = (identity) => ({ repoId: identity.repoId, nodeId: identity.nodeId })
128
+
129
+ // ---- reading ---------------------------------------------------------
130
+
131
+ function listEventNames(directory, identity) {
132
+ let names
133
+ try { names = fs.readdirSync(directory) } catch (error) {
134
+ if (error.code === 'ENOENT') return 0
135
+ if (error.code === 'ENOTDIR') refuseArbitration('foreign-object-file', 'something that is not a directory stands where the object is kept', pointerOf(identity))
136
+ throw error
137
+ }
138
+ let highest = 0
139
+ for (const name of names) {
140
+ if (isPendingPrivateWrite(name)) continue
141
+ const match = EVENT_NAME.exec(name)
142
+ if (!match || Number(match[1]) < 1) refuseArbitration('foreign-object-file', 'the object directory holds a file that is not an event; nothing is decided until a person has looked', pointerOf(identity))
143
+ highest = Math.max(highest, Number(match[1]))
144
+ }
145
+ return highest
146
+ }
147
+
148
+ function readEvent(directory, identity, sequence, previous) {
149
+ const corrupt = (code, message) => refuseArbitration(code, message, { ...pointerOf(identity), sequence })
150
+ let bytes
151
+ try {
152
+ const file = path.join(directory, eventName(sequence))
153
+ if (fs.lstatSync(file).size > limits.maxEventBytes) corrupt('object-event-too-large', 'an event is larger than an event can be')
154
+ bytes = readBytesNoFollow(file)
155
+ } catch (error) {
156
+ if (error instanceof EditArbitrationRefusal) throw error
157
+ // Events are published in order and never removed, so a name that is
158
+ // listed has every lower name beside it.
159
+ return corrupt(error.code === 'ENOENT' ? 'object-log-gap' : 'object-event-unreadable', 'an event of the object cannot be read')
160
+ }
161
+ let document
162
+ try { document = JSON.parse(bytes.toString('utf8')) } catch { return corrupt('object-event-malformed', 'an event of the object is not a document') }
163
+ if (!isObjectEvent(document) || canonicalJson(document) !== bytes.toString('utf8')) corrupt('object-event-malformed', 'an event of the object is not a valid canonical event')
164
+ if (document.workspaceId !== identity.workspaceId || document.repoId !== identity.repoId || document.nodeId !== identity.nodeId) corrupt('object-event-foreign', 'an event names another object')
165
+ if (document.sequence !== sequence || document.previous !== previous) corrupt('object-log-chain-broken', 'an event does not follow the one before it')
166
+ return { document, digest: sha256Digest(bytes) }
167
+ }
168
+
169
+ // { directory, events, digests, derived }. Refuses a log that cannot be trusted.
170
+ function readLog(identity, { fresh = false } = {}) {
171
+ const directory = path.join(objectsRoot, ...segmentsOf(identity))
172
+ const highest = listEventNames(directory, identity)
173
+ const cached = fresh ? undefined : verified.get(directory)
174
+ const events = cached ? [...cached.events] : []
175
+ const digests = cached ? [...cached.digests] : []
176
+ if (highest < events.length) refuseArbitration('object-log-shortened', 'events of the object have disappeared', pointerOf(identity))
177
+ for (let sequence = events.length + 1; sequence <= highest; sequence += 1) {
178
+ const { document, digest } = readEvent(directory, identity, sequence, digests.at(-1) ?? null)
179
+ events.push(document)
180
+ digests.push(digest)
181
+ }
182
+ verified.set(directory, { events, digests })
183
+ const derived = arbitrateEvents(events, rules)
184
+ if (derived.violations.length > 0) {
185
+ refuseArbitration('object-log-inconsistent', 'the events of the object are not a history this store could have written', { ...pointerOf(identity), violations: derived.violations })
186
+ }
187
+ return { directory, events, digests, derived: { ...derived, identity } }
188
+ }
189
+
190
+ // ---- writing ---------------------------------------------------------
191
+
192
+ // 'published' or 'taken'. A crash leaves a complete event or none, and at
193
+ // most a private staging file that no reader takes for an event.
194
+ function publishEvent(directory, document) {
195
+ const text = canonicalJson(document)
196
+ const bytes = Buffer.from(text, 'utf8')
197
+ if (bytes.length > limits.maxEventBytes) refuseArbitration('object-event-too-large', 'the event is larger than an event can be', { sequence: document.sequence, bytes: bytes.length })
198
+ const file = path.join(directory, eventName(document.sequence))
199
+ const pending = path.join(directory, `.atelier-write-${randomUUID()}.tmp`)
200
+ const context = { type: document.type, sequence: document.sequence, repoId: document.repoId, nodeId: document.nodeId }
201
+ let descriptor
202
+ let crashed = false
203
+ const seam = (step) => { try { crash(step, context) } catch (error) { crashed = true; throw error } }
204
+ try {
205
+ descriptor = openRegularFileNoFollow(pending, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600)
206
+ fs.writeFileSync(descriptor, bytes); fs.fsyncSync(descriptor); fs.closeSync(descriptor); descriptor = undefined
207
+ try { fs.linkSync(pending, file) } catch (error) {
208
+ if (error.code !== 'EEXIST') throw error
209
+ // The same bytes under the name: the same decision, made twice.
210
+ if (!readBytesNoFollow(file).equals(bytes)) return 'taken'
211
+ }
212
+ seam('event-linked')
213
+ syncPrivateDirectory(directory)
214
+ if (!readBytesNoFollow(file).equals(bytes)) throw new Error('published event readback mismatch')
215
+ seam('event-published')
216
+ return 'published'
217
+ } finally {
218
+ if (descriptor !== undefined) fs.closeSync(descriptor)
219
+ // A crashed process removes nothing.
220
+ if (!crashed) try { fs.unlinkSync(pending) } catch (error) { if (error.code !== 'ENOENT') throw error }
221
+ }
222
+ }
223
+
224
+ function objectDirectory(identity) {
225
+ const [repo, node] = segmentsOf(identity)
226
+ const existed = fs.existsSync(path.join(objectsRoot, repo, node))
227
+ const directory = privateDir(OBJECTS_DIRECTORY, repo, node)
228
+ if (!existed) { syncPrivateDirectory(path.join(objectsRoot, repo)); syncPrivateDirectory(objectsRoot) }
229
+ return directory
230
+ }
231
+
232
+ // Appends the event `build(derived)` describes, decided against exactly
233
+ // the events before it. `build` returns { type, body }, or { done } when
234
+ // nothing is to be appended. The reducer is the judge: an event it would
235
+ // list as a violation is refused with that code and never written.
236
+ function append(identity, build, { settling = false } = {}) {
237
+ for (let attempt = 0; attempt < limits.maxAppendAttempts; attempt += 1) {
238
+ const log = readLog(identity)
239
+ const decided = build(log.derived, log)
240
+ if (Object.hasOwn(decided, 'done')) return { appended: false, log, value: decided.done }
241
+ const ceiling = limits.maxEventsPerObject - (settling ? 0 : limits.reservedForSettlement)
242
+ if (log.events.length >= ceiling) refuseArbitration('object-log-full', 'the object holds as many events as an object may; nothing is rotated or dropped', { ...pointerOf(identity), events: log.events.length, limit: limits.maxEventsPerObject })
243
+ const event = { schema: OBJECT_EVENT_SCHEMA, ...identity, sequence: log.events.length + 1, previous: log.digests.at(-1) ?? null, type: decided.type, recordedAt: isoTime(clock), body: decided.body }
244
+ if (!isObjectEvent(event)) refuseArbitration('invalid-event', 'the event is not one this store records', { type: decided.type })
245
+ const after = arbitrateEvents([...log.events, event], rules)
246
+ if (after.violations.length > 0) refuseArbitration(after.violations[0].code, 'the events of the object do not allow this', { ...pointerOf(identity), type: decided.type })
247
+ if (publishEvent(objectDirectory(identity), event) === 'taken') continue
248
+ return { appended: true, event, log: readLog(identity) }
249
+ }
250
+ return refuseArbitration('append-contention', 'the object was appended to by others on every attempt', pointerOf(identity))
251
+ }
252
+
253
+ // What the log can derive and has not yet written down, oldest cause first.
254
+ function declare(identity) {
255
+ for (;;) {
256
+ const { appended } = append(identity, ({ undeclared }) => {
257
+ if (undeclared.siblingsStale !== null) return { type: 'siblings-stale', body: undeclared.siblingsStale }
258
+ if (undeclared.superseded.length > 0) return { type: 'superseded', body: { operations: undeclared.superseded } }
259
+ if (undeclared.conflicted.length > 0) return { type: 'conflict-declared', body: { operations: undeclared.conflicted } }
260
+ return { done: true }
261
+ }, { settling: true })
262
+ if (!appended) return
263
+ }
264
+ }
265
+
266
+ // ---- the index, a cache ---------------------------------------------
267
+
268
+ const indexFile = (identity) => { const [repo, node] = segmentsOf(identity); return path.join(indexRoot, repo, `${node}.json`) }
269
+
270
+ function indexDocument({ derived, digests }) {
271
+ const scopes = new Set()
272
+ const operations = derived.operations.map((entry) => {
273
+ const own = [...new Set(entry.origins.map((origin) => origin.scopeId))].sort()
274
+ for (const scopeId of own) scopes.add(scopeId)
275
+ return { idempotencyKey: entry.idempotencyKey, kind: entry.kind, state: entry.state, reason: entry.reason, scopes: own }
276
+ })
277
+ return {
278
+ schema: OBJECT_INDEX_SCHEMA, ...derived.identity, sequence: derived.sequence, headDigest: digests.at(-1) ?? null,
279
+ state: derived.state, sourceDigest: derived.sourceDigest, operations, scopes: [...scopes].sort(),
280
+ leaseHeld: derived.lease !== null, intentOutcomeUnknown: derived.intent !== null,
281
+ }
282
+ }
283
+
284
+ function writeIndex(log) {
285
+ if (log.events.length === 0) return
286
+ const [repo] = segmentsOf(log.derived.identity)
287
+ privateDir(OBJECT_INDEX_DIRECTORY, repo)
288
+ const text = canonicalJson(indexDocument(log))
289
+ const file = indexFile(log.derived.identity)
290
+ try { if (readBytesNoFollow(file).toString('utf8') === text) return } catch { /* absent or unreadable: written below */ }
291
+ atomicReplacePrivateText(file, text)
292
+ }
293
+
294
+ function settle(identity) {
295
+ declare(identity)
296
+ const log = readLog(identity)
297
+ // The index is a cache: when it cannot be written, every reader derives
298
+ // what it needs from the events, and says so here.
299
+ let indexed = true
300
+ try { writeIndex(log) } catch { indexed = false }
301
+ crash('index-updated', { type: null, sequence: log.events.length, ...pointerOf(identity) })
302
+ return { log, indexed }
303
+ }
304
+
305
+ function readIndex(identity) {
306
+ try {
307
+ const document = JSON.parse(readBytesNoFollow(indexFile(identity)).toString('utf8'))
308
+ return document?.schema === OBJECT_INDEX_SCHEMA && document.workspaceId === workspaceId && document.repoId === identity.repoId && document.nodeId === identity.nodeId && Array.isArray(document.operations) && Array.isArray(document.scopes) ? document : null
309
+ } catch { return null }
310
+ }
311
+
312
+ // ---- objects on disk -------------------------------------------------
313
+
314
+ const directoriesOf = (directory) => {
315
+ let entries
316
+ try { entries = fs.readdirSync(directory, { withFileTypes: true }) } catch (error) { if (error.code === 'ENOENT') return { names: [], foreign: 0 }; throw error }
317
+ const names = entries.filter((entry) => entry.isDirectory() && isIdentitySegment(entry.name)).map((entry) => entry.name).sort()
318
+ return { names, foreign: entries.length - names.length }
319
+ }
320
+
321
+ // The identity of the object in <repo>/<node>: decoded from the names, or
322
+ // read from the first event when a name is hashed.
323
+ function identityAt(repo, node) {
324
+ let repoId = decodeIdentitySegment(repo)
325
+ let nodeId = decodeIdentitySegment(node)
326
+ if (repoId === null || nodeId === null) {
327
+ try {
328
+ const first = JSON.parse(readBytesNoFollow(path.join(objectsRoot, repo, node, eventName(1))).toString('utf8'))
329
+ if (isObjectEvent(first)) { repoId = first.repoId; nodeId = first.nodeId }
330
+ } catch { /* reported below */ }
331
+ }
332
+ if (!isIdentifier(repoId) || !isIdentifier(nodeId) || encodeIdentitySegment(repoId) !== repo || encodeIdentitySegment(nodeId) !== node) return null
333
+ return { workspaceId, repoId, nodeId }
334
+ }
335
+
336
+ function walkObjects(visit) {
337
+ let foreign = 0
338
+ const repos = directoriesOf(objectsRoot)
339
+ foreign += repos.foreign
340
+ for (const repo of repos.names) {
341
+ const nodes = directoriesOf(path.join(objectsRoot, repo))
342
+ foreign += nodes.foreign
343
+ for (const node of nodes.names) visit(identityAt(repo, node), path.join(objectsRoot, repo, node))
344
+ }
345
+ return foreign
346
+ }
347
+
348
+ // ---- the lease --------------------------------------------------------
349
+
350
+ function leaseOf(lease) {
351
+ if (lease === null || typeof lease !== 'object' || typeof lease.nonce !== 'string') refuseArbitration('lease-required', 'this is recorded only under the lease of the object')
352
+ return { identity: identityOf(lease), nonce: lease.nonce }
353
+ }
354
+
355
+ function underLease(lease, type, body) {
356
+ const { identity, nonce } = leaseOf(lease)
357
+ const { event } = append(identity, () => ({ type, body: { nonce, ...body } }), { settling: true })
358
+ const { log, indexed } = settle(identity)
359
+ return { sequence: event.sequence, state: log.derived, indexed }
360
+ }
361
+
362
+ return {
363
+ workspaceId,
364
+ stateRoot: root,
365
+
366
+ // Records one edit operation document. Returns { acknowledgement,
367
+ // appended, operation, object }. The acknowledgement depends only on the
368
+ // event that recorded this origin, so offering the same document again
369
+ // (a lost acknowledgement, a second tick, another process) appends
370
+ // nothing and answers the same.
371
+ observe(operation, { presentSourceDigest, resolves = [] } = {}) {
372
+ if (validateObsidianContract('edit-operation', operation).length > 0) refuseArbitration('invalid-operation', 'the document is not an edit operation')
373
+ const identity = identityOf(operation)
374
+ const recorded = recordableOperation(operation)
375
+ const expectedKey = editIdempotencyKey({ ...identity, baseSourceDigest: operation.baseSourceDigest, observedDigest: operation.observed.digest })
376
+ if (recorded === null || operation.idempotencyKey !== expectedKey) refuseArbitration('invalid-operation', 'the edit operation cannot be recorded as it is', { editId: operation.editId })
377
+ if (recorded.state === 'pending' && (recorded.kind !== 'body-replacement' || recorded.ext?.[OBSIDIAN_EXT_KEY]?.newSourceDigest === undefined)) {
378
+ refuseArbitration('invalid-operation', 'a pending operation is a body replacement that names its result', { editId: operation.editId })
379
+ }
380
+ const present = presentSourceDigest === undefined ? (recorded.ext?.[OBSIDIAN_EXT_KEY]?.currentSourceDigest ?? null) : presentSourceDigest
381
+ const body = { operation: recorded, presentSourceDigest: present, resolves: [...new Set(resolves)].sort() }
382
+ const origin = { scopeId: recorded.origin.scopeId, generationId: recorded.origin.generationId, editId: recorded.editId, resolves: body.resolves }
383
+ const recordedAt = (derived) => derived.operations.find((entry) => entry.idempotencyKey === recorded.idempotencyKey)?.origins.find((known) => sameOrigin(known, origin))?.sequence
384
+ const result = append(identity, (derived) => {
385
+ const sequence = recordedAt(derived)
386
+ if (sequence !== undefined) return { done: sequence }
387
+ return { type: derived.operations.some((entry) => entry.idempotencyKey === recorded.idempotencyKey) ? 'coalesced' : 'observed', body }
388
+ })
389
+ const { log, indexed } = settle(identity)
390
+ const sequence = result.appended ? result.event.sequence : result.value
391
+ return {
392
+ acknowledgement: { schema: EDIT_ACKNOWLEDGEMENT_SCHEMA, ...identity, idempotencyKey: recorded.idempotencyKey, editId: recorded.editId, sequence, eventDigest: log.digests[sequence - 1] },
393
+ appended: result.appended,
394
+ operation: log.derived.operations.find((entry) => entry.idempotencyKey === recorded.idempotencyKey) ?? null,
395
+ object: log.derived,
396
+ indexed,
397
+ }
398
+ },
399
+
400
+ stateOf(identity) {
401
+ return readLog(identityOf(identity)).derived
402
+ },
403
+
404
+ // Every object on disk, from the index where it is current and from the
405
+ // events where it is not. An object that cannot be read is listed as
406
+ // such and hides nothing else. { objects, foreign }
407
+ list({ state, scopeId } = {}) {
408
+ const objects = []
409
+ const foreign = walkObjects((identity, directory) => {
410
+ if (identity === null) { objects.push({ repoId: null, nodeId: null, state: 'unreadable', code: 'object-identity-unknown' }); return }
411
+ try {
412
+ const eventCount = listEventNames(directory, identity)
413
+ if (eventCount === 0) return
414
+ let index = readIndex(identity)
415
+ const headDigest = index === null ? null : sha256Digest(readBytesNoFollow(path.join(directory, eventName(eventCount))))
416
+ if (index === null || !indexIsCurrent({ index, eventCount, headDigest })) {
417
+ const log = readLog(identity)
418
+ index = indexDocument(log)
419
+ try { writeIndex(log) } catch { /* still a cache */ }
420
+ }
421
+ objects.push({ repoId: index.repoId, nodeId: index.nodeId, state: index.state, sequence: index.sequence, sourceDigest: index.sourceDigest, scopes: index.scopes, operations: index.operations, leaseHeld: index.leaseHeld, intentOutcomeUnknown: index.intentOutcomeUnknown })
422
+ } catch (error) {
423
+ if (!(error instanceof EditArbitrationRefusal)) throw error
424
+ objects.push({ ...pointerOf(identity), state: 'unreadable', code: error.code })
425
+ }
426
+ })
427
+ return {
428
+ objects: objects.filter((item) => (state === undefined || item.state === state) && (scopeId === undefined || item.scopes?.includes(scopeId))),
429
+ foreign,
430
+ }
431
+ },
432
+
433
+ // { acquired: true, lease } or { acquired: false, code: 'lease-held',
434
+ // reason, needsPerson, holder }. Never waits.
435
+ async acquireLease(identityInput, { pid = process.pid, runtimeId = null } = {}) {
436
+ const identity = identityOf(identityInput)
437
+ for (let attempt = 0; attempt < limits.maxAppendAttempts; attempt += 1) {
438
+ const before = readLog(identity).derived
439
+ let takeover = null
440
+ if (before.lease !== null) {
441
+ const held = before.lease
442
+ const proof = await proveAbandoned({ machine: held.machine, pid: held.pid, service: null, acquiredAt: held.acquiredAt }, { nowMs: Date.parse(isoTime(clock)) })
443
+ if (!proof.abandoned) {
444
+ return { acquired: false, code: 'lease-held', reason: proof.reason, needsPerson: NEEDS_PERSON.has(proof.reason), holder: { pid: held.pid, runtimeId: held.runtimeId, acquiredAt: held.acquiredAt, sequence: held.sequence } }
445
+ }
446
+ takeover = { nonce: held.nonce, reason: proof.reason }
447
+ }
448
+ const nonce = randomBytes(16).toString('hex')
449
+ let lost = false
450
+ const { event } = append(identity, (derived) => {
451
+ // The holder changed while it was being examined: examine again.
452
+ if ((derived.lease?.nonce ?? null) !== (takeover?.nonce ?? null)) { lost = true; return { done: null } }
453
+ return { type: 'lease-acquired', body: { lease: { nonce, pid, machine: machineDigest(), runtimeId, acquiredAt: isoTime(clock) }, takeover } }
454
+ })
455
+ if (lost) continue
456
+ const { log } = settle(identity)
457
+ return { acquired: true, lease: { ...identity, nonce, sequence: event.sequence }, takeover, object: log.derived }
458
+ }
459
+ return refuseArbitration('append-contention', 'the lease of the object changed hands on every attempt', pointerOf(identity))
460
+ },
461
+
462
+ // false when the lease is no longer this one (it was taken over after a
463
+ // proof that this holder was gone).
464
+ releaseLease(lease) {
465
+ const { identity, nonce } = leaseOf(lease)
466
+ const { appended } = append(identity, (derived) => (derived.lease?.nonce === nonce ? { type: 'lease-released', body: { nonce } } : { done: false }), { settling: true })
467
+ settle(identity)
468
+ return appended
469
+ },
470
+
471
+ // Write-ahead: recorded before the source is touched.
472
+ recordIntent(lease, { idempotencyKey, expectedSourceDigest, newSourceDigest, actor, policy, applyId }) {
473
+ return underLease(lease, 'apply-intent', { idempotencyKey, expectedSourceDigest, newSourceDigest, actor, policy, ...optional({ applyId }) })
474
+ },
475
+
476
+ // The source was written: old and new digests, who, and under what.
477
+ recordApplied(lease, { idempotencyKey, oldSourceDigest, newSourceDigest, actor, policy, applyId, backupRef }) {
478
+ return underLease(lease, 'applied', { idempotencyKey, oldSourceDigest, newSourceDigest, actor, policy, ...optional({ applyId, backupRef }) })
479
+ },
480
+
481
+ // The source was not written. `presentSourceDigest` is what the source
482
+ // was read to be, when it was read. `disposition` is what becomes of a
483
+ // pending operation: 'retained' (try again later), 'conflicted', 'refused'.
484
+ recordRefused(lease, { idempotencyKey, code, presentSourceDigest = null, disposition = 'retained', applyId, policy, recoveryRefs }) {
485
+ if (!REFUSED_DISPOSITIONS.includes(disposition)) refuseArbitration('invalid-event', 'the disposition is not one this store records', { type: 'apply-refused' })
486
+ return underLease(lease, 'apply-refused', { idempotencyKey, code, presentSourceDigest, disposition, ...optional({ applyId, policy, recoveryRefs }) })
487
+ },
488
+
489
+ // Reads every event of the object again, from the files, writes down
490
+ // whatever declaration a crash left unwritten, and rebuilds its index.
491
+ recoverObject(identityInput) {
492
+ const identity = identityOf(identityInput)
493
+ readLog(identity, { fresh: true })
494
+ const { log, indexed } = settle(identity)
495
+ return { ...log.derived, indexed }
496
+ },
497
+
498
+ // Deletes every index and derives it again from the events. Equal events
499
+ // give equal bytes.
500
+ rebuildIndexes() {
501
+ fs.rmSync(indexRoot, { recursive: true, force: true })
502
+ privateDir(OBJECT_INDEX_DIRECTORY)
503
+ const result = { rebuilt: 0, unreadable: [], foreign: 0 }
504
+ result.foreign = walkObjects((identity) => {
505
+ if (identity === null) { result.unreadable.push({ repoId: null, nodeId: null, code: 'object-identity-unknown' }); return }
506
+ try { writeIndex(readLog(identity, { fresh: true })); result.rebuilt += 1 } catch (error) {
507
+ if (!(error instanceof EditArbitrationRefusal)) throw error
508
+ result.unreadable.push({ ...pointerOf(identity), code: error.code })
509
+ }
510
+ })
511
+ return result
512
+ },
513
+ }
514
+ }
515
+ }
516
+
517
+ export const openObjectStore = createObjectStoreForOracleTests()
@@ -0,0 +1,123 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { OBSIDIAN_EXT_KEY, assertObsidianContract } from '../contracts.mjs'
3
+ import { sha256Digest } from '../materialize/byte-lens.mjs'
4
+ import { applyEditLens } from './regions.mjs'
5
+
6
+ // Observation: one pending edit record of the maintenance engine becomes one
7
+ // edit operation document.
8
+ //
9
+ // The order is fixed. First the immutable bytes: the edited note must already
10
+ // be retained in the recovery object store under its digest (the engine put it
11
+ // there before it queued the record), and the published note and the base
12
+ // source the edit is judged against are retained beside it. Only then is the
13
+ // edit classified. A crash between the two leaves every byte needed to
14
+ // classify again and no half-made operation. The live note in the vault is
15
+ // never read here: bytes that were not preserved cannot be replaced by
16
+ // whatever the note holds now.
17
+ //
18
+ // Nothing here writes to a source file or to a vault, and the document holds
19
+ // identities, digests, byte offsets, codes and store references only.
20
+
21
+ const EXT = OBSIDIAN_EXT_KEY
22
+ export const EDIT_LENS_VERSION = '1.0.0'
23
+
24
+ // The same edited bytes over the same base source of the same object are the
25
+ // same operation, from whichever view or generation they were observed.
26
+ export function editIdempotencyKey({ workspaceId, repoId, nodeId, baseSourceDigest, observedDigest }) {
27
+ return `op-${createHash('sha256').update([workspaceId, repoId, nodeId, baseSourceDigest, observedDigest].join('\u0000')).digest('hex')}`
28
+ }
29
+
30
+ // How a lens outcome is carried by the closed operation contract. A change to
31
+ // front matter or to the link structure is content for a proposal, which a
32
+ // later step builds from the preserved bytes; a base that moved is a conflict;
33
+ // everything else the lens refuses is refused.
34
+ function classify(outcome) {
35
+ if (outcome.kind === 'body-replacement') return { kind: 'body-replacement', state: 'pending' }
36
+ if (outcome.code === 'unsupported-structural-edit' || outcome.code === 'unsupported-frontmatter-edit') return { kind: 'semantic-proposal', state: 'proposed' }
37
+ if (outcome.code === 'stale-base') return { kind: 'body-replacement', state: 'conflicted' }
38
+ return { kind: 'body-replacement', state: 'refused' }
39
+ }
40
+
41
+ function refusal(code, detail = {}) {
42
+ return { kind: 'refusal', code, detail }
43
+ }
44
+
45
+ function readRetained(store, digest) {
46
+ try {
47
+ const bytes = store.readObject(digest)
48
+ return sha256Digest(bytes) === digest ? bytes : null
49
+ } catch (error) {
50
+ if (error?.code === 'ENOENT' || error?.code === 'recovery-object-corrupt') return null
51
+ throw error
52
+ }
53
+ }
54
+
55
+ export const EDIT_OBSERVATION_STEPS = Object.freeze({
56
+ // Returns { edited, publishedRef, baseRef }; throws nothing for missing bytes.
57
+ preserve({ store, edit, publishedNoteBytes, baseSourceBytes }) {
58
+ const edited = readRetained(store, edit.observedDigest)
59
+ if (edited === null) return { edited: null }
60
+ return { edited, publishedRef: store.retainObject(publishedNoteBytes).ref, baseRef: store.retainObject(baseSourceBytes).ref }
61
+ },
62
+ classify: (input) => applyEditLens(input),
63
+ })
64
+
65
+ // `edit` is a pending edit record; `store` is the recovery store of its view;
66
+ // `manifest` is the generation manifest the record names. `publishedNoteBytes`
67
+ // is the publisher's retained baseline of the note and `baseSourceBytes` the
68
+ // source as it is now. `afterPreserved` is a seam between the two halves.
69
+ //
70
+ // Returns { operation, outcome }: the validated document and the lens result
71
+ // (the new source bytes stay in memory; this phase applies nothing).
72
+ export function createEditObserverForOracleTests(steps = EDIT_OBSERVATION_STEPS) {
73
+ return function observeEdit({ edit, store, manifest, publishedNoteBytes, baseSourceBytes, afterPreserved = () => {} }) {
74
+ for (const bytes of [publishedNoteBytes, baseSourceBytes]) if (!Buffer.isBuffer(bytes)) throw new TypeError('observation reads Buffers only')
75
+ const { workspaceId, repoId, nodeId } = edit.identity
76
+ const noteEntry = manifest.generationId === edit.generationId && manifest.scopeId === edit.scopeId
77
+ ? manifest.notes.find((note) => note.repoId === repoId && note.nodeId === nodeId && note.path === edit.path)
78
+ : undefined
79
+
80
+ const preserved = steps.preserve({ store, edit, publishedNoteBytes, baseSourceBytes })
81
+ afterPreserved()
82
+ let outcome
83
+ if (!preserved.edited) outcome = refusal('edit-bytes-missing', { observedDigest: edit.observedDigest })
84
+ else if (!noteEntry) outcome = refusal('unknown-note')
85
+ else outcome = steps.classify({ manifest, repoId, nodeId, publishedNoteBytes, editedNoteBytes: preserved.edited, baseSourceBytes })
86
+
87
+ const baseSourceDigest = noteEntry?.ext?.[EXT]?.source?.rawDigest ?? sha256Digest(baseSourceBytes)
88
+ const { kind, state } = classify(outcome)
89
+ const operation = {
90
+ schema: 'atelier-obsidian-edit-operation/v1',
91
+ contractVersion: '1.0.0',
92
+ editId: edit.editId,
93
+ workspaceId,
94
+ repoId,
95
+ nodeId,
96
+ origin: {
97
+ scopeId: edit.scopeId,
98
+ generationId: edit.generationId,
99
+ ext: { [EXT]: { notePath: edit.path, baseNoteDigest: edit.baseNoteDigest, publishedNoteDigest: noteEntry?.noteDigest ?? null } },
100
+ },
101
+ kind,
102
+ baseSourceDigest,
103
+ observed: { digest: edit.observedDigest, byteLength: preserved.edited ? preserved.edited.length : 0, recoveryRef: edit.objectRef },
104
+ idempotencyKey: editIdempotencyKey({ workspaceId, repoId, nodeId, baseSourceDigest, observedDigest: edit.observedDigest }),
105
+ state,
106
+ observedAt: edit.observedAt,
107
+ ext: {
108
+ [EXT]: {
109
+ lensVersion: EDIT_LENS_VERSION,
110
+ ...(preserved.edited ? { publishedNoteRef: preserved.publishedRef, baseSourceRef: preserved.baseRef } : {}),
111
+ currentSourceDigest: sha256Digest(baseSourceBytes),
112
+ ...(outcome.kind === 'body-replacement'
113
+ ? { result: { newSourceDigest: outcome.newSourceDigest, newByteLength: outcome.newSourceBytes.length, changedRanges: outcome.changedRanges, unchanged: outcome.unchanged, generated: outcome.generated } }
114
+ : { refusal: { code: outcome.code, detail: outcome.detail } }),
115
+ },
116
+ },
117
+ }
118
+ assertObsidianContract('edit-operation', operation)
119
+ return { operation, outcome }
120
+ }
121
+ }
122
+
123
+ export const observeEdit = createEditObserverForOracleTests()