@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,698 @@
1
+ # Obsidian projection contract
2
+
3
+ This document freezes what later work receives from the contract and
4
+ feasibility track. It states what is proven, on what, and what is not. Nothing
5
+ here enables a feature. The publisher under
6
+ `src/projection/obsidian/publication/` is called by the maintenance service
7
+ (`atelier obsidian service start`) and so by `atelier obsidian open`; see
8
+ [obsidian.md](obsidian.md).
9
+
10
+ ## Registered shapes
11
+
12
+ Eleven closed v1 schemas, `contracts/atelier-obsidian-<shape>.v1.schema.json`,
13
+ are registered in `src/contracts/corpus.mjs` with valid and invalid fixtures
14
+ under `fixtures/obsidian/contracts/<shape>/`. `src/projection/obsidian/contracts.mjs`
15
+ validates them and adds the refusals a schema cannot express.
16
+
17
+ | Shape | Portable | Purpose |
18
+ | --- | --- | --- |
19
+ | `corpus-profile` | yes | Workspace and repository identities, enrollment, audience |
20
+ | `scope` | yes | Mode and selector set-AST; an empty selection is valid and empty |
21
+ | `source-snapshot` | yes | Raw byte digests, graph pin, dirty state, single consistent read |
22
+ | `generation-manifest` | yes | Path map, byte regions, link inversion map, completeness |
23
+ | `publication-journal` | no | Conditional operations, partial transition, restart; protocol ID required |
24
+ | `service-state` | no | Literal loopback, port, runtime ID, PID, executable, consent |
25
+ | `edit-operation` | yes | Object identity, origin generation, base digest, idempotency key |
26
+ | `apply-policy` | no | `manual` or `automatic`; only `body-replacement` is an accepted edit class |
27
+ | `proposal-receipt` | yes | Repository-scoped store, adapter operation identity, dedupe outcome |
28
+ | `acceptance-receipt` | yes | Candidate identity, environment versions, evidence hashes, outcome |
29
+ | `ext-settings` | yes | The object under `ext["mnstry.atelier.obsidian"]` |
30
+
31
+ Portable shapes refuse absolute paths. `atelier-project-config.v1` is
32
+ unchanged: extension settings are validated by their own schema, and an
33
+ unknown extension key refuses in the adapter, not in the project validator.
34
+
35
+ ## Selection
36
+
37
+ `selectScope({ canonicalSnapshot, profile, selector, expansion })` is the only
38
+ runtime entry point. Visibility fails closed: a node is selectable only when it
39
+ is explicitly eligible, its repository is enrolled and its audience is allowed.
40
+ Edges to a withheld endpoint are dropped. Absent and withheld identities are
41
+ reported together. Expansion requires an explicit depth and node budget,
42
+ proceeds in canonical-identity order and reports truncation. Relation types are
43
+ `related`, `supports`, `supersedes`, `implements`, `depends_on`, `evidences`,
44
+ `contradicts`, `belongs_to` and the derived `links_to`. Literal oracles and
45
+ refusals live in `fixtures/obsidian/contracts/oracles/scope-cases.json`.
46
+
47
+ ### What redaction covers
48
+
49
+ Emission is byte-faithful: a visible author's bytes are emitted unchanged. A
50
+ visible author's own references to withheld or out-of-selection documents
51
+ (canonical identities in front matter, repository-relative paths, link text)
52
+ therefore appear in the vault exactly as written. Only generated bytes are
53
+ redacted: generated regions, note paths, manifest entries and link inversions
54
+ never carry a withheld node's identity, title, summary, tags or path. A scoped
55
+ vault is not a confidentiality boundary against what visible authors wrote. A
56
+ redaction boundary over authored bytes cannot coexist with byte-faithful
57
+ emission; it would be a separate mode with its own evidence.
58
+
59
+ Each note may carry the generated line `Relationships leading outside this
60
+ view: N`. N counts that note's relationships to notes that are visible but not
61
+ selected, so it reveals that count, and two views of one workspace can be
62
+ compared. It never counts a withheld node: edges to a withheld endpoint are
63
+ dropped before the count is taken.
64
+
65
+ ## Note paths
66
+
67
+ `notes/<readable title>--<identity suffix>.md`. The suffix derives from the
68
+ stable repository identity plus node identity, starts at 12 hexadecimal
69
+ characters and lengthens on collision. Titles are Unicode-normalized, platform
70
+ reserved names are avoided, and case-folding collisions are detected. Duplicate
71
+ titles are allowed; duplicate canonical identities refuse; a title-only link
72
+ that matches more than one visible note refuses. Paths are allocated once per
73
+ workspace and reused by every view.
74
+
75
+ ## Graph behaviour changes since the shared link resolver
76
+
77
+ The resolver that produces `links_to` edges changed in these ways, each pinned by a
78
+ test in `test/graph-knowledge-graph.test.mjs`. Repository artifacts committed by a
79
+ consumer may differ after upgrading in exactly these classes and no others:
80
+
81
+ 1. Links inside fenced code (backtick or tilde, any info string, up to three
82
+ spaces of indent, CRLF, CommonMark fence-length rules), inside inline code
83
+ (including two stray backticks that happen to pair across a link) and inside
84
+ front matter no longer produce edges. Links after an unbalanced fence that
85
+ runs to the end of the file are inside code.
86
+ 2. A link to a directory resolves to that directory's `README.md`, then
87
+ `index.md`, testing eligibility per candidate; a link to a parent directory
88
+ now resolves where the earlier reader missed it.
89
+ 3. A link that climbs above its own repository root and re-enters through the
90
+ checkout's directory name is reported as leaving the enrolled roots, as it
91
+ always was; it is never turned into a repository-local edge.
92
+ 4. Malformed percent-encoding in a link is a `link-href-malformed` finding;
93
+ it no longer throws out of the graph build.
94
+ 5. The workspace graph (not repository artifacts) additionally carries
95
+ wikilink edges and cross-repository Markdown-link edges, de-duplicated.
96
+
97
+ ## Embedded assets
98
+
99
+ An embed (`![](file)` or `![[file]]`) whose target is not a document of the
100
+ census resolves in the canonical graph, never in the emitter. The resolver
101
+ reports `embeds` beside `links`: one `embeds_asset` record per occurrence, with
102
+ the same UTF-16 and byte offsets, naming an asset
103
+ `{ id, repo, path, extension }` whose id is `<repository>:asset:<path>`.
104
+ `buildCanonicalGraph` returns `embeds` and the de-duplicated `assets`. Neither
105
+ is a node or an edge: committed graph artifacts and `markdownLinkEdges` are
106
+ byte-identical with and without assets.
107
+
108
+ An asset is a regular file inside an enrolled repository root. A link on disk,
109
+ a file reached through one, a git-ignored path, anything inside `.git`, a
110
+ Markdown file and a census node are never assets. Markdown embeds resolve by
111
+ relative path like links. A wikilink embed whose target contains `/` resolves
112
+ by repository-relative path, in the source's repository first and then as
113
+ `<repository>/<path>`; a bare file name resolves by basename across enrolled
114
+ repositories and refuses as `link-target-ambiguous` when more than one file
115
+ matches. `isAssetEligible({ repo, path })` fails closed and is asked per
116
+ candidate before choosing: a refused asset is reported exactly as an absent
117
+ one (`link-target-unresolved`), so a finding never confirms that a withheld
118
+ file exists.
119
+
120
+ Behaviour change: an embed that resolves to an asset no longer produces a
121
+ `link-target-unresolved` finding. No other finding changes.
122
+
123
+ In a view, an asset is copied only when the embedding note is in the vault
124
+ set, the asset record carries `eligible: true` (`withEligibility` takes a
125
+ second predicate; without it every asset is withheld) and the profile enrols
126
+ its repository under the same audience rule as a node without an audience of
127
+ its own. It is copied once, byte for byte, to
128
+ `attachments/<readable stem>--<identity suffix>.<ext>`, read through the pinned
129
+ snapshot (`source-not-in-snapshot`, `mixed-read`). The embed target is
130
+ rewritten through the link inversion machinery: a Markdown embed to the
131
+ percent-encoded vault-root path, a wikilink embed to the plain path with its
132
+ size or fragment left as authored and no alias added. The manifest lists the
133
+ copy in `attachments[]` with
134
+ `ext["mnstry.atelier.obsidian"] = { kind: "embedded-asset", repoId, assetPath }`
135
+ and records the inversions on the embedding note at
136
+ `notes[].ext["mnstry.atelier.obsidian"].assetEmbeds = [{ attachment, inversions }]`,
137
+ in the shape of link inversions. A withheld or out-of-selection asset leaves
138
+ the authored embed untouched and appears in no output, manifest entry or
139
+ diagnostic.
140
+
141
+ ## Unclosed code fence at the end of a source
142
+
143
+ An authored Markdown body that ends inside a fenced code block would turn any
144
+ generated section after it into code. When, and only when, a generated section
145
+ follows such a body, the emitter writes a closing fence as the first bytes of
146
+ the first generated region. The fence is generated, not authored: it lies
147
+ inside that region's range, so authored ranges and inversion stay exact. It
148
+ repeats the opener's indentation (0 to 3 spaces), character and length, starts
149
+ on its own line (a line break is added first when the body has none) and uses
150
+ the source's line ending, CRLF or LF. The region records it as
151
+ `ext["mnstry.atelier.obsidian"].fenceClosure = { fence, byteLength }`, where
152
+ `fence` is the emitted fence line without its line ending and `byteLength`
153
+ counts every closure byte, line breaks included. The view is not refused; its
154
+ diagnostics carry `unclosed-code-fence-closed-in-generated-region`. With no
155
+ generated section, nothing is emitted and nothing is reported.
156
+
157
+ Fence detection is the canonical graph scanner's: `unclosedFenceAtEnd` in
158
+ `src/graph/knowledge-graph.mjs` shares the rules that decide which text is
159
+ scanned for links. Front matter is never read for fences.
160
+
161
+ ## Incremental preparation
162
+
163
+ A maintenance tick rebuilds the canonical graph and prepares the whole view.
164
+ Both stages accept a cache that makes repeated runs proportional to what
165
+ changed, without changing a single emitted byte. Each cache is derived,
166
+ in-memory, droppable state: it is never written anywhere, a dropped cache
167
+ costs a full build or preparation, and the result is by construction the one
168
+ a run with no cache produces. `test/obsidian-incremental.test.mjs` proves the
169
+ equality over the materialization fixture under both scopes and over random
170
+ change sequences (edits, retitles, relations, links, added and removed notes,
171
+ eligibility and scope changes), and shows with mutation controls that the
172
+ oracle sees a wrong cached byte, entry, inversion, node or scan.
173
+
174
+ Graph stage: `buildKnowledgeGraph` / `buildCanonicalGraph` take `fileCache`
175
+ (`createGraphFileCache`) and, optionally, `observedDigest`. A census node and
176
+ link scan are reused only under an equal sha256 of the bytes and equal
177
+ per-file inputs outside the bytes (coverage and the repository's read
178
+ boundary), and the cache is rebuilt to hold exactly the current census.
179
+ Without `observedDigest` every Markdown source is read and hashed on every
180
+ build. The maintenance engine passes `observedDigest` from its observation
181
+ index, and a source whose observed digest equals the cached one is then not
182
+ opened: the bound is observation's own, a stat hint between full
183
+ reconciliations, so bytes that change under an unchanged stat hint are not
184
+ seen by the graph, the snapshot or the view until observation hashes the file
185
+ again. The result reports `fileCensus: { reused, derived, read }`.
186
+
187
+ Preparation stage: `prepareView` takes `cache` (`createPreparationCache`).
188
+ Each note's bytes and manifest entry are a function of its pinned source
189
+ digest, node record, allocated path, generated rows, outside-selection count
190
+ and rewritten occurrences with their emitted targets; those inputs form a
191
+ dependency key, and a note whose key equals the cached one reuses the cached
192
+ bytes, manifest entry, attachment record and inversions. Everything else
193
+ (asset copies, links, settings, collision checks, the manifest and the
194
+ redaction guard over the whole result) runs as before. The result reports
195
+ `preparation: { emitted, reused }`, and a preparation that emits exactly the
196
+ notes whose output changed is what the tests pin.
197
+
198
+ One bound is stated rather than hidden: a reused note's source is not read
199
+ again, so bytes that drift under an unchanged pinned digest are not seen by
200
+ `mixed-read` on that call. The emitted note is exactly the one the pin
201
+ describes, and the next observation by digest sees the drift. A note whose pin
202
+ changed is read and verified against the pin as always.
203
+
204
+ The engine holds one graph file cache and one preparation cache per scope for
205
+ its lifetime and hands them through the `createGraphCache` and
206
+ `createPreparationCache` production seams; a test replaces either with
207
+ `() => null` to build or prepare in full.
208
+
209
+ ## Publication protocol `obsidian-cli-critical-section/v1`
210
+
211
+ The journal's `protocolId` names this protocol. A publisher may use it only
212
+ within the proven boundary below.
213
+
214
+ 1. Stage the candidate on the vault's volume and bind it by SHA-256. A
215
+ candidate that replaces an existing file is staged in that file's unit
216
+ recovery directory (see "Where candidates are staged").
217
+ 2. Inside the app, in one synchronous step: refuse if any editor of the note in
218
+ any window is unsaved or differs from the expected base; refuse if the bytes
219
+ on disk differ from the expected base; refuse a staged file whose digest
220
+ differs; atomically exchange the staged candidate with the note, so
221
+ whatever occupied the path becomes the recovery file; update every open
222
+ editor in one transaction and record the view as saved with that content.
223
+ The app must not write the note as a result of publication.
224
+ 3. Reply immediately. Record the outcome in the app. A caller whose reply is
225
+ lost re-reads the outcome and never resends.
226
+ 4. Re-check the recovery file after a quiet period: a program that held the
227
+ note open before the exchange writes into it.
228
+ 5. Commit the trusted manifest last. Until then the transition is reported as
229
+ updating. A view converges to one verified generation; atomic visibility
230
+ across notes is not claimed.
231
+
232
+ A refusal is always an acceptable outcome. A note being edited stays one
233
+ generation behind until its editor is clean.
234
+
235
+ ### Where candidates are staged
236
+
237
+ The exchange in step 2 leaves the displaced bytes at the candidate's path until
238
+ they are moved to their recovery name. Those bytes may be a person's only copy,
239
+ and `staging/` is classed discardable, so that path is never in `staging/`.
240
+ This is a layout inside the private store; the protocol is unchanged.
241
+
242
+ | Bytes | Path |
243
+ | --- | --- |
244
+ | Any candidate while it is written and fsynced (generated bytes only) | `staging/<journalId>/NNNNNN.candidate`, or `NNNNNN.late.candidate` for one that could not be known when the run began |
245
+ | Candidate of a file that is created (exclusive link, no exchange) | stays at that staging path until it is linked |
246
+ | Candidate of a file that is replaced (note, attachment, policy settings) | moved, complete, to `recovery/<journalId>/<unit>/exchange.candidate`, or `exchange.late.candidate` |
247
+ | Displaced bytes | the vault path, then that same candidate path, then `recovery/<journalId>/<unit>/displaced.bin` |
248
+
249
+ The journal names every exchange candidate, by path and SHA-256, before the
250
+ file is at that path: in the header for candidates known when the run began, in
251
+ a write-ahead entry for a late one. A file at a candidate path whose digest is
252
+ the recorded one is a generated candidate and may be deleted; any other file
253
+ there is displaced bytes and is moved to its recovery name with a receipt,
254
+ never deleted. Restart recovery decides only this way. The candidate names end
255
+ in `.candidate`; every other name in a unit directory ends in `.bin` or
256
+ `.json`. The same-volume check covers staging and recovery, and the exchange
257
+ self-test runs inside the recovery area, in `recovery/.exchange-probe/`.
258
+
259
+ ### One publisher per vault
260
+
261
+ A publication holds two locks: the view's, in its private state, and the
262
+ vault's. Two views, or two sets of workspace state, that point at one vault
263
+ share nothing but the vault, so the vault lock lives under the vault's real
264
+ path in `.atelier-publication/`, a dot-directory that no note path can name
265
+ and that the app does not show. The second publisher refuses with
266
+ `publication-in-progress` and writes nothing. It is the only thing the
267
+ publisher writes in a vault besides notes, attachments and the policy
268
+ settings file.
269
+
270
+ Release and recovery follow the view lock exactly. A finished publication
271
+ writes a release marker. A publisher that dies leaves a ticket naming its
272
+ process; the next publisher on the same host sees that the process is gone and
273
+ takes the lock over, so a crash does not wedge the vault. Superseded tickets
274
+ are removed while the lock is held. Two cases refuse until a person acts: a
275
+ ticket written on another host (a vault reached through a shared or
276
+ synchronized folder), and an unrelated live process that reuses the dead
277
+ publisher's process ID, which clears when that process exits. When no
278
+ publisher is running anywhere, deleting `.atelier-publication/` is safe.
279
+
280
+ ### The path with no app
281
+
282
+ When the process table shows, positively, that no Obsidian runs, the same
283
+ critical section runs in the publisher's own process with no editor to
284
+ coordinate with. The table is read at path selection, again immediately before
285
+ the first note whatever time has passed, and again whenever two seconds have
286
+ passed since the last reading. Once a reading is anything other than absent,
287
+ every remaining note that would be written refuses.
288
+
289
+ Residual window, stated plainly: an app that starts after a reading and before
290
+ the next is not seen, for at most two seconds plus one note's publication. In
291
+ that window a note is exchanged with no editor check. Bytes saved to disk are
292
+ still protected by the on-disk comparison and the exchange. An unsaved buffer
293
+ in the newly started app is not: the app then takes its own
294
+ external-modification merge, which can drop overlapping edits. The probe also
295
+ cannot see an app on another machine that reaches the vault through a shared
296
+ or synchronized folder, or an app packaged under another executable name. It
297
+ recognises the app by the executable a process runs (`ps -A -o comm=`, or
298
+ `pid=,comm=` on Linux), never
299
+ by its arguments; on Linux a system Electron process, which does not say which
300
+ app it hosts, makes the reading unknown rather than absent, and a process whose
301
+ name is not recognised is identified by the executable `/proc/<pid>/exe`
302
+ resolves to. A `ps` that does not answer within five seconds is unknown.
303
+ Before 0.2.0-alpha.10 no production reading was ever absent, because the probe
304
+ found Atelier's own service; from that release this path, and this window, are
305
+ live.
306
+
307
+ ### Proven boundary
308
+
309
+ macOS (Darwin 25, arm64) with Obsidian 1.13.7 (installer 1.12.7), CLI enabled,
310
+ no community plugin. Sixteen interleavings, 25 rounds for each racing case, in
311
+ four complete clean runs of the prototype in `experiments/obsidian-publication/`
312
+ at commits `34f5fab` and `c5142e7`. The receipts record those same trees under their
313
+ pre-sign-off identities `5930f85` and `93303ff`; the branch was rewritten only to add
314
+ sign-off trailers, with identical content. Receipt SHA-256 digests:
315
+
316
+ - `379992a6cc0e6c95581459412e8cab68d9b260a631fa891089613f120b40a67a`
317
+ - `63a5b69b203125b6099f2398d1ebddd0e4f3265a0401becbddff5e6d72fee16e`
318
+ - `95995051859e02042d648d1a0b513d86fa9f1d72d82359030e21858cdba5d959`
319
+ - `0984c35087ff91c0cc0d0f37f533eea1fc6c6380705491362eec98368f64fe01`
320
+
321
+ A fifth run aborted because the harness could not open a note after the
322
+ second-window case; no bytes were involved. The receipts are maintainer-held
323
+ and are not part of this repository.
324
+
325
+ ### Receiving obligations for the production publisher
326
+
327
+ The publisher may not claim the protocol outside the proven boundary until
328
+ each open item is discharged with its own evidence.
329
+
330
+ | Obligation | State |
331
+ | --- | --- |
332
+ | Atomic exchange without the system Python | Done. The system Python is gone. The exchange is a raw syscall (`renameatx_np` with `RENAME_SWAP` on macOS, `renameat2` with `RENAME_EXCHANGE` on Linux) reached through the system perl, with no perl module, compiler or package dependency. |
333
+ | Interpreter trust | Done. The perl binary is used only when uid 0 owns it and neither group nor others can write it, in the publisher and in the script the app runs; otherwise `exchange-interpreter-untrusted`. An interpreter is still a dependency, and the exchange still costs a process start inside the critical section. |
334
+ | Exit status of the exchange helper | Done. The critical section reads the staged path after the call and decides from the bytes whether the exchange took place; a helper killed after the call returned no longer reads as a failure. |
335
+ | Linux | Partly open. The exchange primitive is proven in a container on aarch64. The app suite (the interleavings above) has not been run on Linux. |
336
+ | x86_64 | Open. Never run, on macOS or Linux; the syscall numbers are present and untested. |
337
+ | Windows | Refuses. No direct equivalent is known; the publisher refuses with `exchange-unsupported-platform` and publishes nothing. |
338
+ | App capability floor | Partly open. The step that prevents an app write sets the view's undocumented `lastSavedData`; open notes refuse when it is absent. The floor is pinned: `MINIMUM_APP_VERSION` in `src/runtime/obsidian/app-capability.mjs` is 1.13.7, the only version the protocol was proven on, and an older, unreadable or unknown version is refused as `app-version-unsupported`. There is no ceiling: a newer app is admitted, and the cases must be re-run on each app release. |
339
+ | Late-writer re-check | Partly open. The publisher re-checks displaced files twice per publication (on the blocking path, and after the quiet period). A holder can write later still, so the check must be repeated by the future maintenance service; carried to the maintenance track. |
340
+ | Displaced bytes between exchange and recovery move | Done, by the maintenance track's staging layout change. The exchange still leaves the displaced bytes at the candidate's path until the next step moves them to their recovery name, and that path is now in the unit recovery directory: candidates for replaced files are staged there, and staging holds only generated bytes. A crash in between is settled by restart recovery from the journal, which names that path and the candidate's digest. A staging sweeper is still not provided; any future one may discard `staging/` only and must never touch `recovery/`. |
341
+ | Transport | CLI replies are occasionally lost while the app stays responsive. Calls are serialized; every call is idempotent or outcome-recorded, and a publish is never resent. |
342
+ | Timer throttling | A hidden app window delays the app's own autosave. Do not read that as a fault. |
343
+ | Unreproduced anomaly | One early run ended with typed text on disk but absent from the editor buffer. It did not recur in any later run. Keep the typing-race case in every qualification run and treat a recurrence as a failed gate. |
344
+ | Link scanner cost | Open, carried. The Markdown link scanner is roughly quadratic in skipped regions times link occurrences on pathological inputs; fine for ordinary documents, minutes for several megabytes of adversarial Markdown. |
345
+ | Link-then-rename | Rejected. It leaves a window in which a concurrent replacement is destroyed. |
346
+ | App-driven save after replacement | Rejected. Observed losing an outside writer's bytes; kept as a negative control. |
347
+
348
+ Source application back into canonical files carries the same conditional-write
349
+ obligation against other source writers; its protocol follows.
350
+
351
+ ## Source apply protocol `source-apply-exchange/v1`
352
+
353
+ Source apply writes one edit made in a vault back to the one source file the
354
+ note was generated from. It is the only operation of this integration that
355
+ writes a source file. A person's explicit Apply (`atelier obsidian apply run
356
+ EDIT [--actor ID]`, or the same call through the API by an agent acting for them)
357
+ and an automatic policy reach the same function; they differ only in who
358
+ authorises. It never stages, commits or otherwise asks git to change anything:
359
+ its two git calls ask whether the path is ignored and where the git directory
360
+ is.
361
+
362
+ ### Who authorises
363
+
364
+ | Mode | Authority | Actor recorded |
365
+ | --- | --- | --- |
366
+ | `manual` | an explicit request that names the edit; the integration must be enabled | the request's actor, else `manual-request` |
367
+ | `automatic` | the installed policy, read from private state at the decision and again immediately before the write: machine mode `automatic`, status `active`, the policy's recomputed digest equal to the digest it carries, the object inside the policy's `selector`, `body-replacement` in `allowedEditClasses`, retry budget not spent | the policy's actor, with the policy identifier and digest |
368
+
369
+ In both modes the object must be visible in the canonical graph as it is now
370
+ (eligible, enrolled, audience allowed). An absent object and a withheld one get
371
+ the same answer, `object-not-visible`, and they get it first: before the
372
+ manifest, the record of the object or the source path is looked at, with
373
+ nothing recorded, so a moved or deleted source, an earlier apply or a missing
374
+ manifest never tells the two apart. A source deleted from the corpus is
375
+ therefore answered `object-not-visible` as well. There is no ambient agent mode: an automatic request with no
376
+ matching active policy is refused.
377
+
378
+ The digest of a policy is `sha256:` and the hex SHA-256 of its canonical form:
379
+ the policy document without its `digest` member, keys sorted at every depth,
380
+ two-space indentation, one final newline, UTF-8. `atelier obsidian policy
381
+ digest FILE` prints it and writes nothing; `policy install FILE` recomputes it
382
+ and refuses `policy-digest-mismatch`, naming the digest the file has to carry.
383
+ The file is never rewritten for the person. `maxBatchSize` bounds an
384
+ automatic dispatch (the engine's, and `applyBatch` in automatic mode); a batch
385
+ a person names through `applyBatch` is bounded too, by 1000, the largest bound
386
+ a policy can carry, and the rest of either batch refuses `batch-bound-reached`. `retryBudget` allows one attempt and
387
+ that many retries per operation under one revision of the policy; it is counted
388
+ over the refusals recorded in the events of the object, so a restart does not
389
+ refill it, and a spent budget refuses before anything is recorded.
390
+ `conflictDisposition` is `hold`: a conflict stays queued with its bytes, and
391
+ nothing a policy says overrides a stale source.
392
+
393
+ ### Order of one apply
394
+
395
+ 1. Resolve the workspace and the pending edit. Build the canonical graph now
396
+ and ask whether this machine may see the object. Then resolve the view and
397
+ the immutable manifest of the generation the edit was observed under.
398
+ 2. The identity must still name the path the
399
+ manifest recorded, in an enrolled repository. That path must be a regular
400
+ file with one name, reached through no symbolic link, inside the repository,
401
+ outside every managed root and every git directory, and not git-ignored.
402
+ A git directory is any path with a segment spelled `.git` in any case (a
403
+ nested repository, a submodule), anything under `<root>/.git`, and anything
404
+ under the directory git itself names for the repository, which a `gitdir:`
405
+ file can place elsewhere; a git that cannot name it refuses
406
+ `source-ignore-state-unknown`. The only git calls ask whether the path is
407
+ ignored and where the git directory is.
408
+ 3. Read the source. Run the lens from the preserved edit bytes, never from the
409
+ note as it is now. Record the observation of this edit and of every other
410
+ open edit of the same object, so a divergent edit in another view makes the
411
+ object conflicted before anything is written.
412
+ 4. Take the object lease. An earlier apply whose outcome is unknown is settled
413
+ first, from digests on disk.
414
+ 5. Refuse, writing nothing: a repeated request (answered from the record), a
415
+ stale source, a conflicted object, a lens refusal, a change outside the
416
+ authored body, a result equal to the source, the policy, a volume that is
417
+ not the source's, a missing exchange.
418
+ 6. Write the apply record, then the candidate (the new source bytes, with the
419
+ source's mode, fsynced) in `recovery/<applyId>/000000/` of the private
420
+ workspace state.
421
+ 7. Record `apply-intent` in the object store.
422
+ 8. Read the policy again. Exchange the candidate with the source atomically.
423
+ 9. Read what the exchange displaced. Equal to the base: it stays as the
424
+ retained backup under its recovery name, with a receipt binding its digest;
425
+ the source is verified; `applied` is recorded with old and new digests, the
426
+ actor and the policy. Not equal: another program saved the source between
427
+ the read and the exchange. Its bytes are retained as an immutable object and
428
+ the files are exchanged back, so its bytes return to the source path; what
429
+ that displaces must be the candidate, and anything else is kept with a
430
+ receipt. `apply-refused`, `concurrent-source-writer`, with every reference.
431
+ Nothing is retried inside one call. An exchange that reports a failure is
432
+ not believed either way: only the candidate still at its path beside the
433
+ source as it was read means that nothing was exchanged
434
+ (`exchange-unavailable`); anything else is decided as restart recovery
435
+ decides it, so an exchange that did take place is recorded as applied
436
+ (`applied-after-restart`) with its backup.
437
+ 10. After a quiet period the backup is read again. A program that opened the
438
+ source before the exchange still holds the old file and can write into it
439
+ at any later time: `source-changed-after-apply`, both byte sets retained.
440
+ An applied source leaves a closed journal of this protocol beside the
441
+ publication journals of the view, so the maintenance engine's late-writer
442
+ re-check covers the backup on the tick of the apply and on later ticks,
443
+ for as long as it covers a publication.
444
+
445
+ The candidate is never written inside a repository working tree, where a stray
446
+ file could be committed by somebody. An exchange cannot cross a volume, so the
447
+ private workspace state and the source must share one; otherwise the apply
448
+ refuses `apply-volume-mismatch`. Where no atomic exchange exists (Windows
449
+ today) it refuses `exchange-unavailable`. Both write nothing.
450
+
451
+ ### Refusals
452
+
453
+ | Code | When |
454
+ | --- | --- |
455
+ | `integration-disabled`, `workspace-not-prepared`, `unknown-edit`, `foreign-workspace`, `unknown-scope`, `edit-not-open` | the request cannot be resolved |
456
+ | `corpus-unreadable` | the canonical graph cannot be built on this machine: an enrolled file may not be read; no file is named |
457
+ | `manifest-unavailable`, `published-note-unavailable` | the generation's manifest, or the note as it was published, cannot be established |
458
+ | `repository-not-enrolled`, `source-not-in-graph`, `source-moved` | the identity of a visible object no longer names that path: a renamed or moved source (a deleted one is `object-not-visible`) |
459
+ | `source-missing`, `source-symlink`, `source-not-regular-file`, `source-unreadable`, `source-hard-linked`, `source-outside-repository`, `source-inside-managed-root`, `source-inside-git-directory`, `source-git-ignored`, `source-ignore-state-unknown` | the path is not one this operation writes |
460
+ | `invalid-apply-request`, `object-not-visible`, `edit-class-not-allowed`, `conflict-disposition-unsupported`, `maintenance-mode-manual`, `no-apply-policy-installed`, `apply-policy-revoked`, `apply-policy-paused`, `apply-policy-invalid`, `apply-policy-reference-mismatch`, `policy-digest-mismatch`, `policy-changed-since-dispatch`, `policy-selector-invalid`, `outside-policy-selection`, `retry-budget-exhausted`, `batch-bound-reached` | the decision |
461
+ | `stale-source`, `object-conflicted`, `sibling-edit-unobservable`, `lease-held` | arbitration; the operation stays conflicted or pending with its bytes |
462
+ | `edit-not-applicable`, `change-outside-authored-body`, `no-source-change` | the lens result is not an applicable body replacement |
463
+ | `exchange-unavailable`, `apply-volume-mismatch` | this machine cannot write conditionally here |
464
+ | `concurrent-source-writer`, `source-changed-during-apply` | another program wrote the source during the apply; every byte is retained |
465
+ | `interrupted-before-exchange`, `apply-interrupted-needs-person` | what restart recovery decided for an interrupted apply |
466
+ | `recovery-state-unreadable` | a candidate, backup or source path of an interrupted apply may not be looked at or read; nothing is settled from a read this process was denied, that record is reported with its intent still open, and every other one is still settled |
467
+ | `apply-outcome-unknown` | the source was exchanged and the settlement from digests could not be carried out; the source may have been changed, the intent stays open and `apply recover` decides |
468
+
469
+ A path that another program removes or replaces between two steps, before the
470
+ intent is recorded, answers one of these refusals (`source-missing`,
471
+ `source-not-regular-file`, `workspace-not-prepared`), never an exception; one
472
+ that this process may not look at or read answers `corpus-unreadable` while the
473
+ graph is built and `source-unreadable` from then on. The
474
+ mode of the source is read from the descriptor its bytes were read from.
475
+
476
+ ### Restart recovery
477
+
478
+ An `apply-intent` with no outcome is never guessed. `atelier obsidian apply
479
+ recover`, and the next apply of the same object, decide from digests on disk.
480
+ Only bytes with the recorded candidate digest are ours to delete. Every
481
+ interrupted apply is settled on its own: an object whose record cannot be read
482
+ is reported with its code, left exactly as it is, and delays no other. `apply
483
+ list`, `show`, `run` and `recover` answer such a record as a typed refusal.
484
+
485
+ | At the candidate path | Source | Decision |
486
+ | --- | --- | --- |
487
+ | the candidate | anything | nothing was exchanged, or it was exchanged back: the candidate is retired, `interrupted-before-exchange` |
488
+ | the base | the candidate | applied: the base becomes the backup, `applied` is recorded |
489
+ | anything else | anything | everything is kept with receipts, `apply-interrupted-needs-person` |
490
+ | nothing, backup recorded as the base | the candidate | applied |
491
+ | nothing, no backup | the base | `interrupted-before-exchange` |
492
+
493
+ ### Limits
494
+
495
+ - The applied source is a new file: a hard link is refused up front, extended
496
+ attributes and ownership are those of the candidate, and only the permission
497
+ bits are carried over.
498
+ - A hard link is refused when the path is checked. One that another program
499
+ creates on the source after that check and before the exchange keeps the old
500
+ bytes under its other name; the source path itself ends as the applied file,
501
+ and nothing detects the second name.
502
+ - Every component of the source path is checked for a symbolic link, and the
503
+ file itself is opened without following one. A directory component that
504
+ another program replaces with a symbolic link after that check and before the
505
+ exchange is not detected; what contains it is the commit rule: unless the
506
+ file the exchange displaced holds exactly the bytes that were read, the files
507
+ are exchanged back and everything is retained.
508
+ - When the exchange back reports a failure, or the displaced file cannot be
509
+ retained first, nothing is guessed and the report is not believed either way:
510
+ the apply is settled from the digests on disk by the restart table above.
511
+ If the exchange back did not take place, the source path keeps the candidate,
512
+ whole, the other program's bytes stay where recovery references name them,
513
+ and the answer is `apply-interrupted-needs-person`. If it did take place
514
+ before it reported the failure, the source path holds the other program's
515
+ bytes, the candidate is retired, and the answer is
516
+ `interrupted-before-exchange`. If the digests show that the first exchange
517
+ displaced the base after all, the apply happened and is answered as applied.
518
+ - A refusal says that nothing was written. From the moment the first exchange
519
+ may have taken place (a clean exchange, or one that reported a failure while
520
+ the digests no longer show the untouched state), a failure is therefore never
521
+ returned as a plain refusal with the intent open: it is settled from the
522
+ digests on disk, and only when that settlement itself cannot be carried out
523
+ is the answer `apply-outcome-unknown`, which says that the source may have
524
+ been changed and that `apply recover` decides. Once the outcome is durable
525
+ that answer is no longer given.
526
+ - A read that this process is denied (a permission or I/O error) proves
527
+ nothing about a file, unlike a path that is no longer a regular file. It is
528
+ never taken as evidence that the source changed or that another program
529
+ wrote: the apply is settled again from digests when the path can be read.
530
+ A typed failure of the late-writer check after a durable apply leaves the
531
+ answer applied; the engine repeats that check.
532
+ - An enrolled file this process may not read while the canonical graph is built
533
+ refuses `corpus-unreadable`, naming no file. A source that cannot be read at
534
+ the moment of the apply refuses `source-unreadable`. Both carry the system's
535
+ error code as the cause. Only bytes with the recorded candidate digest are
536
+ ours to delete, so a file that cannot be read is never retired as a generated
537
+ candidate.
538
+ - Where no atomic exchange exists the whole apply half of the test suite is
539
+ skipped: on such a platform the only executed evidence is that apply refuses
540
+ `exchange-unavailable` and writes nothing.
541
+ - Lifting the hold after an apply relies on the publisher: it reads the note
542
+ again under its own lock and settles it as already current only while the
543
+ note still holds the prepared bytes. The engine's own read of the held note
544
+ narrows the window; the publisher's expected-bytes check closes it.
545
+ - Between the exchange and the exchange back, readers of the source path see
546
+ the candidate for a moment. A third write in that moment is kept: the source
547
+ ends as one whole version and the other is in recovery with a receipt.
548
+ - A program that holds the old file open and writes later has its bytes
549
+ retained and surfaced, not merged: deciding what the source should be is a
550
+ person's work.
551
+ - Generated regions are never applied. The edited note must still end with the
552
+ exact generated bytes, or have lost them whole while the end of its authored
553
+ text is provably the published end: the last authored line and its line
554
+ endings byte for byte, followed by nothing but what is left of the separator
555
+ or fence closure. A region cut in the middle of a line from either side, a
556
+ fragment of one left in the body, and text typed at the very end of the body
557
+ together with a removed region all refuse `generated-region-edited`. Some of
558
+ those refusals a person could have been spared; a generated byte in a source
559
+ is never accepted in exchange.
560
+ - After an apply the next tick prepares the view again. When the prepared note
561
+ is byte for byte the note the person edited (read from the vault at the
562
+ moment of that decision, never taken from the engine's observation index; a
563
+ note that cannot be read stays held), it is published over nothing,
564
+ the hold lifts and the pending record closes as `withdrawn`; the `applied`
565
+ record of the object store is the authority. When it is not (the person
566
+ removed a generated region, for example) the view stays held.
567
+ - `--actor` is an option of `apply run`. The command has one option table for
568
+ every operation; every other operation, of `apply` or not, refuses it as a
569
+ usage error and does nothing.
570
+ - Proven on macOS arm64 on APFS with a real second process. Linux, x86_64 and
571
+ other filesystems carry the open obligations of the exchange listed above.
572
+
573
+ ## Proposal adapter for structural edits
574
+
575
+ An edit the byte lens cannot turn into source bytes (a new or changed link to
576
+ another note of the vault, an edited front matter) is recorded as an edit
577
+ operation of kind `semantic-proposal`, state `proposed`. It is never applied.
578
+ The proposal adapter (`src/projection/obsidian/proposals/`) turns each such
579
+ operation into exactly one copy-only proposal in the existing proposal store of
580
+ the repository that owns the source, so that a reviewer of that repository sees
581
+ one durable request. It writes no source file and no vault, it never accepts or
582
+ applies a proposal, and no status of a proposal is an instruction to it.
583
+
584
+ The adapter is a contribution (`src/runtime/obsidian/contributions/`). On a
585
+ tick the engine first lets it observe the open pending edits (below), then,
586
+ after the automatic dispatch, hands it a copy of the pending edits once.
587
+
588
+ ### Observation on a tick
589
+
590
+ An operation exists once the edit has been observed: the source is read now,
591
+ the lens runs over the PRESERVED edit bytes (never the live note) against it,
592
+ and the resulting edit operation is recorded in the object store. Source apply
593
+ does this before it takes its lease. The adapter does the same on every tick
594
+ (`src/projection/obsidian/proposals/observation.mjs`), through the same
595
+ observer and the same store, for every open pending edit the object store does
596
+ not know yet, in manual and in automatic mode alike, so that a structural edit
597
+ becomes `proposed`, and is routed in the same tick, without anybody running
598
+ `atelier obsidian apply run EDIT`. What it records is what an apply would have
599
+ recorded: a body replacement `pending`, which stays queued and is written only
600
+ by apply; a structural edit `proposed`; a lens refusal `refused`; a base source
601
+ that moved on `conflicted`. It writes no source file and no vault, and takes no
602
+ lease.
603
+
604
+ Ticks stay quiet. The object store answers the same for an origin it already
605
+ holds and appends nothing, and an edit whose operation is recorded is not
606
+ offered again: the record of the object is the authority, and an adapter that
607
+ has just started finds it there. A refusal that comes before anything can be
608
+ recorded (`object-not-visible`, `source-not-in-graph`, `source-moved`, the
609
+ `source-*` codes of locating the file, `manifest-unavailable`,
610
+ `published-note-unavailable`, `stale-source`) is remembered in memory with its
611
+ code and offered again only at the full reconciliation cadence of the engine,
612
+ never on every tick. At most 16 edits are observed per tick, in the order they
613
+ were observed, starting where the last tick stopped. The tick reports
614
+ `observed`: for each edit looked at, its identifiers, `observed` or `refused`,
615
+ a code, and for a recorded one the kind and state of the operation.
616
+
617
+ ### Routing
618
+
619
+ The routing key is the whole identity `(workspaceId, repoId, nodeId)`. The store
620
+ is the one every other writer of proposals uses for a root:
621
+ `<repository root>/.atelier-proposals`. Two repositories that hold the same
622
+ relative path and the same local node id have different stores and different
623
+ operation identities. A route that cannot be resolved refuses, writes nothing,
624
+ and leaves the preserved bytes and the record of the object as they were:
625
+
626
+ | Code | Meaning |
627
+ | --- | --- |
628
+ | `invalid-operation`, `foreign-workspace` | the identity is malformed or belongs to another workspace |
629
+ | `repository-not-enrolled`, `repository-external`, `repository-root-unreadable` | the project does not enrol the repository on this machine |
630
+ | `route-withheld` | this machine may not see the object now (the rule source apply asks); `route-visibility-unknown` waits instead |
631
+ | `source-path-invalid`, `source-path-not-preservable` | the repository-relative path is unusable, or the store would trim or cut it (500 characters) |
632
+ | `proposal-store-unsafe`, `proposal-store-inside-managed-root` | the store directory is a link or a file, or overlaps private state or a vault |
633
+ | `proposal-store-not-ignored`, `proposal-store-ignore-unknown` | the store does not exist yet and git would report it; nothing is created that changes `git status` |
634
+
635
+ ### Operation identity and deduplication
636
+
637
+ The adapter operation identity is `pa-` and the SHA-256 of the identity and the
638
+ idempotency key of the edit operation, joined by a character no identifier can
639
+ hold: 67 lower-case characters. It is carried in `payload.adapter.operationId`
640
+ of the proposal, a member the store persists as JSON without normalising it.
641
+ The identifier the store gives a proposal is seeded with the time and decides
642
+ nothing here.
643
+
644
+ Per operation, under a private lock per repository (the generation lock of the
645
+ maintenance engine; taken over only with proof that its holder is gone, never
646
+ because time passed):
647
+
648
+ 1. the operation is recorded in the adapter queue,
649
+ `<state>/state/proposals/<repo>/operations/<operation>--NNNNNN.json`:
650
+ immutable, owner-only, canonical files, each naming the digest of the one
651
+ before it;
652
+ 2. the ledger of the store is read and its room judged;
653
+ 3. the persisted store is searched for a proposal that carries the operation
654
+ identity, and one is created only when there is none;
655
+ 4. `submitted` is recorded, the proposal is read back, and `acknowledged` is
656
+ recorded with an `atelier-obsidian-proposal-receipt/v1` that binds the edit
657
+ to the proposal (`dedupe`: `new`, `recovered` after a lost acknowledgement,
658
+ `duplicate` when an acknowledged operation is offered again).
659
+
660
+ A crash before the append leaves a queued record and no proposal; after it, a
661
+ proposal that step 3 finds. An unchanged tick reads the queue and nothing else:
662
+ no store is opened and nothing is written. An operation whose edit was withdrawn
663
+ or superseded while it waited is refused `edit-withdrawn` or `edit-superseded`.
664
+
665
+ ### What a proposal holds
666
+
667
+ Identifiers, the repository-relative source path, the lens code and reason,
668
+ byte offsets into the edited note, digests and recovery references of the
669
+ preserved bytes, and sentences made from those codes. It holds no text of any
670
+ note: what a person typed can carry the title of another document, and a
671
+ reviewer of one repository must not learn a title of another repository, or of
672
+ a withheld document, from a proposal. The preserved bytes stay in private state.
673
+
674
+ ### Ledger limits and backpressure
675
+
676
+ The limits are those of the existing ledger and are not widened: 256 KiB a
677
+ line, 16 MiB, 10,000 events, and an unreadable line refuses every append.
678
+
679
+ | Code | Outcome |
680
+ | --- | --- |
681
+ | `proposal-too-large` | refused before anything is appended; nothing is cut to fit |
682
+ | `ledger-full` | fewer than 1 + 32 events or less than the line + 512 KiB of room (the reserve is the reviewers'); the operation waits as `backpressure` with its edit retained |
683
+ | `ledger-corrupt` | refused, for that repository only; other repositories progress in the same tick |
684
+ | `store-unavailable` | the store cannot be read or is locked now; waits like a full one |
685
+
686
+ A waiting operation is tried at most 8 times, 60 seconds after the first attempt
687
+ and twice as long after each, up to an hour. At most 8 operations per repository
688
+ and 64 unexamined edits are looked at per tick. A repository holds at most 4096
689
+ operations, 256 of them open, 64 records of 16 KiB each; a full queue refuses
690
+ `queue-full` and records nothing. Nothing is compacted, rotated or deleted, in
691
+ the ledger or in the queue. A refused or exhausted operation is final until the
692
+ adapter's `requeue` is called for it, which is a person's decision after making
693
+ room or fixing the route; it is an exported function, and no command binds it
694
+ yet.
695
+
696
+ `atelier obsidian proposals list` and `show OPERATION` are read-only: per
697
+ repository the counts by state, the codes of the waiting and the refused, and
698
+ the events and bytes the ledger has left; never note or source text.