@mnstry/atelier 0.2.0-alpha.14 → 0.2.0-alpha.16

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 (236) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +80 -47
  3. package/SECURITY.md +1 -1
  4. package/TRADEMARKS.md +2 -2
  5. package/contracts/atelier-extension-manifest.v1.schema.json +412 -0
  6. package/contracts/atelier-graph-view.v1.schema.json +1085 -0
  7. package/contracts/public-api-baseline.json +298 -19
  8. package/docs/README.md +90 -0
  9. package/docs/blocks/claims.md +2 -2
  10. package/docs/blocks/conformance-admission.md +1 -1
  11. package/docs/{atelier-sync.md → guides/atelier-sync.md} +20 -14
  12. package/docs/guides/co-authoring.md +113 -0
  13. package/docs/{distributions.md → guides/distributions.md} +13 -8
  14. package/docs/guides/extensions.md +189 -0
  15. package/docs/guides/graph-refresh.md +152 -0
  16. package/docs/guides/graph-workspace.md +76 -0
  17. package/docs/{ingestion.md → guides/ingestion.md} +13 -12
  18. package/docs/{knowledge-workspace.md → guides/knowledge-workspace.md} +2 -2
  19. package/docs/{learning.md → guides/learning.md} +12 -5
  20. package/docs/{obsidian.md → guides/obsidian.md} +134 -47
  21. package/docs/guides/ontology-guide.md +111 -0
  22. package/docs/{personal-workspace.md → guides/personal-workspace.md} +7 -4
  23. package/docs/{research-starter.md → guides/research-starter.md} +1 -1
  24. package/docs/{tenant-readiness.md → guides/tenant-readiness.md} +39 -0
  25. package/docs/{upgrade.md → guides/upgrade.md} +40 -9
  26. package/docs/knowledge-health/assessment/capabilities.md +6 -15
  27. package/docs/knowledge-health/facilitator.md +15 -4
  28. package/docs/knowledge-health/{devday/participant-response.md → participant-response.md} +4 -2
  29. package/docs/knowledge-health/quickstart.md +97 -75
  30. package/docs/knowledge-health/readme.md +13 -5
  31. package/docs/maintainers/describing-the-atelier.md +173 -0
  32. package/docs/{release-engineering.md → maintainers/release-engineering.md} +5 -5
  33. package/docs/{announcements.md → reference/announcements.md} +1 -1
  34. package/docs/{architecture.md → reference/architecture.md} +31 -5
  35. package/docs/{atelier-runtime.md → reference/atelier-runtime.md} +1 -1
  36. package/docs/{connected-composition.md → reference/connected-composition.md} +7 -10
  37. package/docs/{coordination.md → reference/coordination.md} +1 -1
  38. package/docs/{decisions.md → reference/decisions.md} +3 -3
  39. package/docs/{design.md → reference/design.md} +1 -1
  40. package/docs/reference/edge-policy.md +151 -0
  41. package/docs/reference/graph-view-structured.md +119 -0
  42. package/docs/reference/graph-view.md +139 -0
  43. package/docs/reference/graph-workspace.md +37 -0
  44. package/docs/{intake-and-guides.md → reference/intake-and-guides.md} +3 -4
  45. package/docs/{knowledge-graph.md → reference/knowledge-graph.md} +120 -6
  46. package/docs/{local-services.md → reference/local-services.md} +1 -1
  47. package/docs/{obsidian-contract.md → reference/obsidian-contract.md} +6 -4
  48. package/docs/{obsidian-plugin.md → reference/obsidian-plugin.md} +5 -66
  49. package/docs/{practical-judgment.md → reference/phronesis.md} +12 -3
  50. package/docs/reference/portability.md +92 -0
  51. package/docs/{portable-presentation.md → reference/portable-presentation.md} +2 -2
  52. package/docs/{presentation-consumer-boundaries.md → reference/presentation-consumer-boundaries.md} +5 -0
  53. package/docs/{project-options.md → reference/project-options.md} +17 -0
  54. package/docs/{repo-boundary-guard.md → reference/repo-boundary-guard.md} +27 -4
  55. package/docs/{review-portability.md → reference/review-portability.md} +4 -0
  56. package/docs/{template-profile.md → reference/template-profile.md} +1 -1
  57. package/docs/{trackables.md → reference/trackables.md} +2 -2
  58. package/docs/start/independent-start.md +89 -0
  59. package/docs/{install.md → start/install.md} +6 -6
  60. package/docs/{knowledge-setup.md → start/knowledge-setup.md} +5 -5
  61. package/docs/{research-starter-quickstart.md → start/research-starter-quickstart.md} +6 -6
  62. package/fixtures/atelier-architecture/catalog/invalid/document.json +2 -2
  63. package/fixtures/atelier-architecture/catalog/valid/document.json +2 -2
  64. package/fixtures/graph-view/synthetic-authorized-input.v1.json +437 -0
  65. package/fixtures/graph-view/synthetic-view.v1.json +1 -0
  66. package/fixtures/knowledge-health/workshop/README.md +1 -1
  67. package/fixtures/templates/local-library.v1.json +3 -1
  68. package/package.json +21 -8
  69. package/skills/claude/atelier-guided-upgrade/SKILL.md +1 -1
  70. package/skills/claude/atelier-knowledge-setup/SKILL.md +2 -2
  71. package/skills/claude/atelier-local-service/SKILL.md +1 -1
  72. package/skills/claude/atelier-practical-judgment/SKILL.md +7 -2
  73. package/skills/claude/atelier-skill-steward/SKILL.md +1 -1
  74. package/skills/claude/mnstry-readiness/SKILL.md +2 -2
  75. package/skills/codex/atelier-guided-upgrade/SKILL.md +1 -1
  76. package/skills/codex/atelier-knowledge-setup/SKILL.md +2 -2
  77. package/skills/codex/atelier-local-service/SKILL.md +1 -1
  78. package/skills/codex/atelier-practical-judgment/SKILL.md +7 -2
  79. package/skills/codex/atelier-skill-steward/SKILL.md +1 -1
  80. package/skills/codex/mnstry-readiness/SKILL.md +2 -2
  81. package/src/architecture/catalog.mjs +2 -2
  82. package/src/attestation/sign.mjs +1 -1
  83. package/src/boundary/content-rules.mjs +5 -4
  84. package/src/boundary/policy.mjs +60 -12
  85. package/src/cli/run.mjs +24 -8
  86. package/src/commands/distribution.mjs +1 -1
  87. package/src/commands/extension.mjs +27 -0
  88. package/src/commands/ingest.mjs +1 -1
  89. package/src/commands/learn.mjs +1 -1
  90. package/src/commands/obsidian.mjs +28 -16
  91. package/src/commands/review.mjs +3 -0
  92. package/src/edge-policy/contract.mjs +69 -0
  93. package/src/edge-policy/policy.mjs +142 -0
  94. package/src/edge-policy/source-citation-adapter.mjs +43 -0
  95. package/src/extension-packs/loader.mjs +4 -3
  96. package/src/extensions/admission.mjs +83 -0
  97. package/src/extensions/client.d.mts +23 -0
  98. package/src/extensions/client.mjs +99 -0
  99. package/src/extensions/contracts.d.mts +29 -0
  100. package/src/extensions/contracts.mjs +163 -0
  101. package/src/extensions/convex.d.mts +24 -0
  102. package/src/extensions/convex.mjs +189 -0
  103. package/src/extensions/delegation.mjs +54 -0
  104. package/src/extensions/drizzle.d.mts +11 -0
  105. package/src/extensions/drizzle.mjs +33 -0
  106. package/src/extensions/postgres.d.mts +7 -0
  107. package/src/extensions/postgres.mjs +66 -0
  108. package/src/extensions/prisma.d.mts +13 -0
  109. package/src/extensions/prisma.mjs +63 -0
  110. package/src/extensions/project.mjs +432 -0
  111. package/src/extensions/scaffold-convex.mjs +130 -0
  112. package/src/extensions/scaffold-sql.mjs +144 -0
  113. package/src/extensions/server.d.mts +20 -0
  114. package/src/extensions/server.mjs +102 -0
  115. package/src/extensions/sql-scope.mjs +22 -0
  116. package/src/graph/knowledge-graph.mjs +1 -1
  117. package/src/graph/repo-contribution.mjs +405 -0
  118. package/src/graph/repo-contribution.types.mjs +32 -0
  119. package/src/graph-access/edge-decision-adapter.mjs +41 -0
  120. package/src/graph-access/edge-record-adapter.mjs +73 -0
  121. package/src/graph-access/index.mjs +253 -0
  122. package/src/graph-access/multi-contribution-record-adapter.mjs +101 -0
  123. package/src/graph-access/multi-record-projection-adapter.mjs +74 -0
  124. package/src/graph-access/source-adapter.mjs +81 -0
  125. package/src/graph-refresh/compare.mjs +110 -0
  126. package/src/graph-refresh/controller.mjs +324 -0
  127. package/src/graph-refresh/index.mjs +3 -0
  128. package/src/graph-refresh/local.mjs +78 -0
  129. package/src/graph-view/babylon/babylon-scene-port.mjs +64 -0
  130. package/src/graph-view/babylon/index.mjs +2 -0
  131. package/src/graph-view/babylon/navigator.mjs +312 -0
  132. package/src/graph-view/babylon/provenance.mjs +36 -0
  133. package/src/graph-view/babylon/scene-controller.mjs +131 -0
  134. package/src/graph-view/babylon/semantic-picking.mjs +35 -0
  135. package/src/graph-view/babylon/web-host.mjs +188 -0
  136. package/src/graph-view/cli.mjs +3 -0
  137. package/src/graph-view/command.mjs +34 -0
  138. package/src/graph-view/contracts.mjs +79 -0
  139. package/src/graph-view/errors.mjs +5 -0
  140. package/src/graph-view/index.mjs +4 -0
  141. package/src/graph-view/intents.mjs +30 -0
  142. package/src/graph-view/query.mjs +213 -0
  143. package/src/graph-view/refs.mjs +2 -0
  144. package/src/graph-view/structured/browser.mjs +262 -0
  145. package/src/graph-view/structured/styles.mjs +8 -0
  146. package/src/graph-workspace/authoring.mjs +185 -0
  147. package/src/graph-workspace/browser/dom.mjs +23 -0
  148. package/src/graph-workspace/browser/index.mjs +6 -0
  149. package/src/graph-workspace/browser/map.mjs +374 -0
  150. package/src/graph-workspace/browser/review.mjs +55 -0
  151. package/src/graph-workspace/browser/styles.mjs +11 -0
  152. package/src/graph-workspace/browser/workbench.mjs +351 -0
  153. package/src/graph-workspace/browser/workspace.mjs +433 -0
  154. package/src/graph-workspace/disclosure.mjs +39 -0
  155. package/src/graph-workspace/geometry.mjs +78 -0
  156. package/src/graph-workspace/index.mjs +11 -0
  157. package/src/graph-workspace/labels.mjs +50 -0
  158. package/src/graph-workspace/model.mjs +109 -0
  159. package/src/graph-workspace/recipes.mjs +100 -0
  160. package/src/graph-workspace/scale.mjs +98 -0
  161. package/src/graph-workspace/theme.mjs +156 -0
  162. package/src/knowledge/ontology-guide.mjs +152 -0
  163. package/src/knowledge-health/proof/installed-check.mjs +2 -2
  164. package/src/knowledge-health/rule-identity.mjs +7 -7
  165. package/src/knowledge-health/workshop.mjs +22 -2
  166. package/src/personal-workspace/selection.mjs +17 -2
  167. package/src/project/config.mjs +1 -1
  168. package/src/project/providers/github.mjs +24 -0
  169. package/src/project/repo-identity.mjs +3 -22
  170. package/src/projection/obsidian/contracts.mjs +1 -1
  171. package/src/projection/obsidian/materialize/path-registry.mjs +1 -1
  172. package/src/projection/obsidian/materialize/prepare-view.mjs +1 -1
  173. package/src/projection/obsidian/materialize/redaction.mjs +1 -1
  174. package/src/projection/obsidian/personal-workspace.mjs +24 -28
  175. package/src/projection/obsidian/plugin-bridge/channel.mjs +1 -1
  176. package/src/projection/obsidian/publication/publisher.mjs +25 -4
  177. package/src/projection/project.mjs +18 -14
  178. package/src/readiness/readiness.mjs +20 -11
  179. package/src/readiness-protocols/bundled-pack.mjs +14 -766
  180. package/src/readiness-protocols/bundled-selection.mjs +32 -0
  181. package/src/readiness-protocols/evidence.mjs +2 -1
  182. package/src/readiness-protocols/packs/mnstry.mjs +766 -0
  183. package/src/readiness-protocols/runtime.mjs +43 -6
  184. package/src/runtime/obsidian/engine.mjs +99 -26
  185. package/src/runtime/obsidian/lifecycle.mjs +3 -3
  186. package/src/runtime/obsidian/login-item.mjs +2 -2
  187. package/src/runtime/obsidian/machine-settings.mjs +52 -8
  188. package/src/runtime/obsidian/opening.mjs +2 -2
  189. package/src/runtime/obsidian/personal-selection.mjs +88 -26
  190. package/src/runtime/obsidian/pipeline.mjs +75 -7
  191. package/src/runtime/obsidian/private-lock.mjs +11 -2
  192. package/src/runtime/obsidian/selection-continuity.mjs +124 -0
  193. package/src/runtime/obsidian/service-main.mjs +15 -9
  194. package/src/runtime/obsidian/service.mjs +14 -0
  195. package/src/runtime/obsidian/unheld-evidence.mjs +1 -1
  196. package/src/runtime/obsidian/view-permission.mjs +2 -1
  197. package/src/upgrade/upgrade.mjs +23 -4
  198. package/templates/distribution-workspace/README.md +2 -2
  199. package/templates/external-project-workspace/atelier.project.json +3 -0
  200. package/templates/external-project-workspace/pack-lifecycle.json +3 -1
  201. package/templates/knowledge-workspace/README.md +2 -2
  202. package/docs/integration-and-review-plan.md +0 -409
  203. package/docs/integration-contract-decisions.md +0 -44
  204. package/docs/knowledge-health/devday/facilitator.md +0 -16
  205. package/docs/knowledge-health/devday/public-default.md +0 -107
  206. package/docs/knowledge-health/devday/quickstart.md +0 -18
  207. package/docs/knowledge-health/devday/support.md +0 -22
  208. package/docs/knowledge-health/exercise.md +0 -17
  209. package/docs/knowledge-health/support.md +0 -34
  210. package/docs/presentation-review-disposition.md +0 -47
  211. package/docs/progressive-ingestion-acceptance.md +0 -89
  212. package/docs/progressive-ingestion.md +0 -235
  213. /package/docs/{capability-stewardship.md → guides/capability-stewardship.md} +0 -0
  214. /package/docs/{exact-upgrades.md → guides/exact-upgrades.md} +0 -0
  215. /package/docs/{guided-upgrades.md → guides/guided-upgrades.md} +0 -0
  216. /package/docs/{inquiry-harnesses.md → guides/inquiry-harnesses.md} +0 -0
  217. /package/docs/{learning-harnesses.md → guides/learning-harnesses.md} +0 -0
  218. /package/docs/{local-review.md → guides/local-review.md} +0 -0
  219. /package/docs/{skill-steward.md → guides/skill-steward.md} +0 -0
  220. /package/docs/{template-upgrades.md → guides/template-upgrades.md} +0 -0
  221. /package/docs/{assurance-controls.md → maintainers/assurance-controls.md} +0 -0
  222. /package/docs/{attestation.md → reference/attestation.md} +0 -0
  223. /package/docs/{coauthor-session.md → reference/coauthor-session.md} +0 -0
  224. /package/docs/{continuity.md → reference/continuity.md} +0 -0
  225. /package/docs/{contract-stability.md → reference/contract-stability.md} +0 -0
  226. /package/docs/{decision-practices.md → reference/decision-practices.md} +0 -0
  227. /package/docs/{evidence-navigation.md → reference/evidence-navigation.md} +0 -0
  228. /package/docs/{extension-pack-lifecycle.md → reference/extension-pack-lifecycle.md} +0 -0
  229. /package/docs/{interaction-and-reflection.md → reference/interaction-and-reflection.md} +0 -0
  230. /package/docs/{local-state.md → reference/local-state.md} +0 -0
  231. /package/docs/{ontology.md → reference/ontology.md} +0 -0
  232. /package/docs/{optional-ingestion.md → reference/optional-ingestion.md} +0 -0
  233. /package/docs/{project-command-inventory.md → reference/project-command-inventory.md} +0 -0
  234. /package/docs/{semantic-ingestion.md → reference/semantic-ingestion.md} +0 -0
  235. /package/docs/{semantic-operation.md → reference/semantic-operation.md} +0 -0
  236. /package/docs/{vault-service.md → reference/vault-service.md} +0 -0
@@ -1,8 +1,12 @@
1
+ import fs from 'node:fs'
1
2
  import path from 'node:path'
2
- import { PersonalWorkspaceRefusal, readPersonalSelectionHead } from '@mnstry/atelier/personal-workspace'
3
+ import { PersonalWorkspaceRefusal } from '@mnstry/atelier/personal-workspace'
3
4
  import { ensureLocalState } from '@mnstry/atelier/project'
4
5
  import { assertPersonalSelection, loadBoundProject, loadBoundProjectOffThread, notePersonalSelectionHead, personalSelectionOf, pinPersonalSelection } from '../../projection/obsidian/personal-workspace.mjs'
5
6
  import { refuse } from './errors.mjs'
7
+ import { readPersonalSelectionSnapshot } from '../../personal-workspace/selection.mjs'
8
+ import { assertOutsideRepositories, ensureWorkspaceIdentity, resolveDataRoot, workspaceStateRoot } from './machine-settings.mjs'
9
+ import { createSelectionContinuity } from './selection-continuity.mjs'
6
10
 
7
11
  // A person's confirmed selection, bound to the maintenance runtime.
8
12
  //
@@ -42,21 +46,19 @@ import { refuse } from './errors.mjs'
42
46
  // already loaded, and a read the engine asks for when its guard stops a view
43
47
  // because an observed file changed or could not be read (once for views
44
48
  // stopped one after another). A
45
- // history that ends earlier than the highest record noted refuses
46
- // `selection-history-shortened` at a load and at a recheck, and nothing is
47
- // loaded or used from it while it is shorter; the guard's read only notes, and
49
+ // history that ends earlier than the highest record noted requires a fresh confirmation or refuses
50
+ // `selection-history-shortened` at a load and at a recheck; the guard's read only notes, and
48
51
  // its view says `mixed-read`. An engine whose project is pinned below the
49
52
  // highest record noted loads it again at its next tick
50
53
  // (personalSelectionBehind). A record added and removed between two of those
51
54
  // reads was never read, and does not count.
52
55
  //
53
- // Only the record number is kept, and only in this process. A record in a
54
- // place already read, with other content, is read as a confirmation. A process
55
- // started after the cut reads the history as it then stands and publishes what
56
- // it ends with: starting the service again accepts the shorter history, and so
57
- // does any start of it (a login, a restart after an upgrade or a failure).
56
+ // The highest number, accepted head and observed record identities are also kept in the workspace's private
57
+ // continuity state. A start of the service reads that evidence too. A shorter history remains refused until the
58
+ // removed records return or the person makes a fresh confirmation. New confirmations have a unique identity, so
59
+ // confirming the same generation against the same predecessor is distinct from restoring an old record's bytes.
58
60
  //
59
- // Who may see a vault is not decided here: that stays the machine's setting.
61
+ // Who may see a vault stays the machine's setting: the engine initializes an undecided personal binding as owner only.
60
62
  //
61
63
  // Where the workspace is does not wait for a load: a bound project's folder is
62
64
  // its private home, so the home alone locates it (locatePersonalHome). A
@@ -78,30 +80,88 @@ export const SELECTION_PRIMITIVES = Object.freeze({
78
80
  const SOURCE = Object.freeze({ source: 'personal-workspace' })
79
81
  const LOCATED = Symbol('atelier.obsidian.located-personal-home')
80
82
 
81
- // The record the history ends with. A history without one refuses, and so does one shorter than this process read it.
82
- function confirmedHead(personalHome, rules) {
83
+ const CONTINUITY = new Map()
84
+
85
+ // Exclusion roots only, shared by location and continuity. Composition remains
86
+ // the authority for the manifest and generation. Never wait on an irregular
87
+ // file while resolving the machine's private state.
88
+ function manifestExclusionRoots(personalHome) {
89
+ let manifest
90
+ let descriptor
91
+ try {
92
+ descriptor = fs.openSync(path.join(personalHome, 'atelier.personal.json'), fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_NONBLOCK ?? 0))
93
+ const stat = fs.fstatSync(descriptor)
94
+ if (!stat.isFile() || stat.size > 1024 * 1024) throw new PersonalWorkspaceRefusal('malformed-input')
95
+ const bytes = fs.readFileSync(descriptor)
96
+ if (bytes.length > 1024 * 1024) throw new PersonalWorkspaceRefusal('malformed-input')
97
+ try { manifest = JSON.parse(bytes) } catch { throw new PersonalWorkspaceRefusal('malformed-input') }
98
+ if (!Array.isArray(manifest?.repos) || manifest.repos.some((repo) => typeof repo?.root !== 'string' || !path.isAbsolute(repo.root) || repo.root.includes('\0'))) throw new PersonalWorkspaceRefusal('malformed-input')
99
+ } catch (error) {
100
+ refuse(error instanceof PersonalWorkspaceRefusal ? error.code : 'personal-home-unavailable', 'the personal workspace manifest could not be read; no selection is loaded', SOURCE)
101
+ } finally { if (descriptor !== undefined) fs.closeSync(descriptor) }
102
+ return manifest.repos.map((repo) => repo.root)
103
+ }
104
+
105
+ function continuityFor({ personalHome, dataRoot, env = process.env, platform = process.platform }) {
106
+ const project = locatePersonalHome({ personalHome })
107
+ const repositoryRoots = [personalHome, ...project.repos.map((repo) => repo.path)]
108
+ const rootOf = (named) => workspaceStateRoot(resolveDataRoot({ dataRoot, pointer: named, project, env, platform }), named.workspaceId)
109
+ // The location is proven outside every declared root before a first use writes its pointer.
110
+ const pointer = ensureWorkspaceIdentity({ project, ...(dataRoot === undefined ? {} : { dataRoot }),
111
+ validate: (named) => assertOutsideRepositories({ managedRoot: rootOf(named), repositoryRoots }) })
112
+ const root = rootOf(pointer)
113
+ fs.mkdirSync(root, { recursive: true, mode: 0o700 })
114
+ const workspaceRoot = fs.realpathSync(root)
115
+ const key = `${personalHome}\0${workspaceRoot}`
116
+ // A retained store writes later, after the manifest may have changed: each write proves the location again from
117
+ // the manifest as it reads now, and a refusal there writes nothing.
118
+ if (!CONTINUITY.has(key)) CONTINUITY.set(key, createSelectionContinuity({ workspaceRoot, workspaceId: pointer.workspaceId, personalHome,
119
+ validateLocation: () => assertOutsideRepositories({ managedRoot: workspaceRoot, repositoryRoots: personalExclusionRootsNow(personalHome) }) }))
120
+ return CONTINUITY.get(key)
121
+ }
122
+
123
+ // The roots a personal home's private state must stay outside, read from its manifest now. Read-only.
124
+ export function personalExclusionRootsNow(personalHome) {
125
+ return [personalHome, ...manifestExclusionRoots(personalHome)]
126
+ }
127
+
128
+ function confirmedHead(options, rules) {
129
+ const { personalHome } = options
130
+ locatePersonalHome({ personalHome })
83
131
  let head
84
- try { head = readPersonalSelectionHead({ personalHome }) } catch (error) {
132
+ try { head = readPersonalSelectionSnapshot({ personalHome }) } catch (error) {
85
133
  if (!(error instanceof PersonalWorkspaceRefusal)) throw error
86
134
  refuse(error.code, 'the selection history of the personal workspace could not be read; nothing is loaded from it', SOURCE)
87
135
  }
88
- notePersonalSelectionHead(personalHome, head, rules)
136
+ const continuity = continuityFor(options)
137
+ const note = (current) => { if (rules.refusesShortened) continuity.note(current) }
138
+ notePersonalSelectionHead(personalHome, head, { ...rules, note })
89
139
  if (head.selected === null) refuse('nothing-selected', 'no generation of the personal workspace is confirmed; nothing is loaded until one is', SOURCE)
90
- return head
140
+ return { head, note }
91
141
  }
92
142
 
93
- function pinned(project, head, rules) {
94
- pinPersonalSelection(project, head, { ...rules.pin, refusesShortened: rules.refusesShortened })
143
+ function pinned(project, { head, note }, rules) {
144
+ pinPersonalSelection(project, head, { ...rules.pin, refusesShortened: rules.refusesShortened, note })
95
145
  if (rules.readsHeadAgain) assertPersonalSelection(project)
96
146
  return project
97
147
  }
98
148
 
99
149
  // What the workspace of a bound personal home is resolved from, without loading anything: a project that holds the home
100
- // as its folder and nothing else, as a bound project's folder is. Its pointer is under the home, the data root is the
101
- // pointer's or the caller's, and a home is outside every Git worktree. Synchronous; nothing is composed.
150
+ // as its folder, plus the manifest's repository roots for exclusion only. A locator never admits a generation or
151
+ // notes a history head. Its pointer is under the home, the data root is the pointer's or the caller's, and a home is
152
+ // outside every Git worktree. Synchronous; nothing is composed. A missing home still locates no prepared workspace.
102
153
  export function locatePersonalHome({ personalHome } = {}) {
103
154
  if (typeof personalHome !== 'string' || !path.isAbsolute(personalHome) || path.resolve(personalHome) !== personalHome) refuse('path-not-absolute', 'a personal home is named by its absolute path', SOURCE)
104
- const located = { configDir: personalHome, [LOCATED]: personalHome }
155
+ let stat = null
156
+ try { stat = fs.lstatSync(personalHome) } catch (error) { if (error.code !== 'ENOENT') refuse('personal-home-unavailable', 'the personal home could not be inspected', SOURCE) }
157
+ if (stat !== null) {
158
+ if (stat.isSymbolicLink() || !stat.isDirectory() || fs.realpathSync(personalHome) !== personalHome) refuse('root-symlinked', 'a personal home is its private directory, not a link', SOURCE)
159
+ if (typeof process.getuid !== 'function' || stat.uid !== process.getuid() || (stat.mode & 0o022) !== 0) refuse('not-private-location', 'a personal home is private to its owner', SOURCE)
160
+ }
161
+ // Include all declared roots, even a withdrawn enrollment: locating state
162
+ // must not write into a repository while composition is unavailable.
163
+ const roots = stat === null ? [] : manifestExclusionRoots(personalHome)
164
+ const located = { configDir: personalHome, repos: roots.map((root) => ({ path: root, external: false })), [LOCATED]: personalHome }
105
165
  located.localState = ensureLocalState(located, { write: false, env: { PATH: process.env.PATH } })
106
166
  return located
107
167
  }
@@ -113,13 +173,15 @@ export { personalSelectionOf }
113
173
 
114
174
  // The project of the confirmed selection, for a loader that may block (a command run once). `bind` and `rules` are
115
175
  // test seams.
116
- export function loadSelectedProject({ personalHome } = {}, { bind, rules = SELECTION_PRIMITIVES } = {}) {
117
- const head = confirmedHead(personalHome, rules)
118
- return pinned(loadBoundProject({ personalHome, generationId: head.selected }, { bind }), head, rules)
176
+ export function loadSelectedProject(options = {}, { bind, rules = SELECTION_PRIMITIVES } = {}) {
177
+ const { personalHome } = options
178
+ const selected = confirmedHead(options, rules)
179
+ return pinned(loadBoundProject({ personalHome, generationId: selected.head.selected }, { bind }), selected, rules)
119
180
  }
120
181
 
121
182
  // The same, for a loader on a service's event loop: the composition runs in a worker, and the engine awaits it.
122
- export async function loadSelectedProjectOffThread({ personalHome } = {}, { bind, rules = SELECTION_PRIMITIVES } = {}) {
123
- const head = confirmedHead(personalHome, rules)
124
- return pinned(await loadBoundProjectOffThread({ personalHome, generationId: head.selected }, { bind }), head, rules)
183
+ export async function loadSelectedProjectOffThread(options = {}, { bind, rules = SELECTION_PRIMITIVES } = {}) {
184
+ const { personalHome } = options
185
+ const selected = confirmedHead(options, rules)
186
+ return pinned(await loadBoundProjectOffThread({ personalHome, generationId: selected.head.selected }, { bind }), selected, rules)
125
187
  }
@@ -1,5 +1,6 @@
1
1
  import { createCooperativeBudget, prepareViewCooperatively } from '../../projection/obsidian/materialize/prepare-view.mjs'
2
2
  import { createHash } from 'node:crypto'
3
+ import fs from 'node:fs'
3
4
  import path from 'node:path'
4
5
  import { buildCanonicalGraph, buildCanonicalGraphSteps, createGraphFileCache } from '../../graph/graph.mjs'
5
6
  import { markdownMetadata } from '../../graph/knowledge-graph.mjs'
@@ -130,8 +131,14 @@ export function profileFor({ project, workspaceId, audienceAllow }) {
130
131
  // on this thread.
131
132
  export function buildGraph({ project, eligibility, cache = null, index = null, deferPersonalValidation = false }) {
132
133
  const observedDigest = observedDigestOf(cache, index)
133
- const canonical = personalWorkspaceGraph(project, { fileCache: cache, observedDigest, defer: deferPersonalValidation }) ?? buildCanonicalGraph(project, { fileCache: cache, observedDigest })
134
- return eligibleGraph(canonical, eligibility)
134
+ let canonical = personalWorkspaceGraph(project, { fileCache: cache, observedDigest, defer: deferPersonalValidation })
135
+ if (canonical === null) {
136
+ try { canonical = buildCanonicalGraph(project, { fileCache: cache, observedDigest }) } catch (error) {
137
+ refuseObservedDirectory(error, { project, index })
138
+ throw error
139
+ }
140
+ }
141
+ return eligibleGraph(canonical, eligibility, { project, index })
135
142
  }
136
143
 
137
144
  // The same graph, built with the event loop given a turn between the Markdown sources of the census, under the
@@ -143,7 +150,7 @@ export async function buildGraphCooperatively({ project, eligibility, cache = nu
143
150
  const observedDigest = observedDigestOf(cache, index)
144
151
  // The personal route answers for a bound project, and refuses a project that carries the reserved key unbound.
145
152
  const personal = personalWorkspaceGraph(project, { fileCache: cache, observedDigest, defer: deferPersonalValidation })
146
- if (personal !== null) return eligibleGraph(personal, eligibility)
153
+ if (personal !== null) return eligibleGraph(personal, eligibility, { project, index })
147
154
  const budget = createCooperativeBudget(scheduling)
148
155
  budget.check()
149
156
  const steps = buildCanonicalGraphSteps(project, { fileCache: cache, observedDigest })
@@ -156,16 +163,66 @@ export async function buildGraphCooperatively({ project, eligibility, cache = nu
156
163
  }
157
164
  } catch (error) {
158
165
  steps.return?.()
166
+ refuseObservedDirectory(error, { project, index })
159
167
  throw error
160
168
  }
161
169
  budget.finish('graph-assembled')
162
- return eligibleGraph(canonical, eligibility)
170
+ return eligibleGraph(canonical, eligibility, { project, index })
163
171
  }
164
172
 
165
173
  const observedDigestOf = (cache, index) => (cache && index ? (repoId, relative) => index.get(sourceKey(repoId, relative))?.digest ?? null : null)
166
174
 
167
- function eligibleGraph(canonical, eligibility) {
168
- if (!canonical.ok) refuse('canonical-graph-invalid', 'the canonical graph has errors; no view is prepared from it', { errorCount: canonical.errors.length })
175
+ // Only a digest from this tick's observation establishes that the path was a readable source before the build.
176
+ // These exceptional checks never enrol a path or admit an invalid graph; without prior observation its error stays.
177
+ function* observedSources({ project, index }, changeClass = null) {
178
+ if (!(index instanceof Map)) return
179
+ for (const repo of project?.repos ?? []) {
180
+ if (repo?.external || typeof repo?.path !== 'string' || typeof repo?.name !== 'string') continue
181
+ const root = path.resolve(repo.path)
182
+ const prefix = sourceKey(repo.name, '')
183
+ for (const [key, entry] of index) {
184
+ if (typeof key !== 'string' || !key.startsWith(prefix) || typeof entry?.digest !== 'string' || (changeClass !== null && entry.changeClass !== changeClass)) continue
185
+ const relative = key.slice(prefix.length)
186
+ const file = path.resolve(root, ...relative.split('/'))
187
+ const inside = path.relative(root, file)
188
+ if (inside === '' || inside === '..' || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside)) continue
189
+ yield file
190
+ }
191
+ }
192
+ }
193
+
194
+ function directoryNow(file) {
195
+ try { return fs.lstatSync(file).isDirectory() } catch { return false }
196
+ }
197
+
198
+ function refuseObservedDirectory(error, observed) {
199
+ if (error?.code !== 'EISDIR') return
200
+ // readFileSync's EISDIR need not name a path. In that case an observed file that is now a directory still proves
201
+ // this build spans two source states; the next full observation decides what the directory now contributes.
202
+ const named = typeof error.path === 'string' ? path.resolve(error.path) : null
203
+ for (const file of observedSources(observed)) {
204
+ if ((named === null || named === file) && directoryNow(file)) refuse('mixed-read', 'an observed source became a directory while the canonical graph was being built', { cause: 'EISDIR' })
205
+ }
206
+ }
207
+
208
+ function eligibleGraph(canonical, eligibility, observed) {
209
+ if (!canonical.ok) {
210
+ // The graph records a sidecar read failure as invalid metadata. A sidecar this tick already read and that is
211
+ // gone now is instead a mixed read, as a source removed during the census is. A never-observed sidecar stays invalid.
212
+ const roots = new Map((observed.project?.repos ?? []).filter((repo) => !repo.external && typeof repo.path === 'string').map((repo) => [repo.name, repo.path]))
213
+ const sidecars = new Set((canonical.nodes ?? []).flatMap((node) => typeof node.sidecar === 'string' && roots.has(node.repo)
214
+ ? [path.resolve(roots.get(node.repo), ...node.sidecar.split('/'))] : []))
215
+ for (const file of observedSources(observed, 'sidecar')) {
216
+ if (!sidecars.has(file)) continue
217
+ let stat
218
+ try { stat = fs.lstatSync(file) } catch (error) {
219
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') refuse('mixed-read', 'an observed sidecar was removed while the canonical graph was being built', { cause: error.code })
220
+ continue
221
+ }
222
+ if (stat.isDirectory()) refuse('mixed-read', 'an observed sidecar became a directory while the canonical graph was being built', { cause: 'EISDIR' })
223
+ }
224
+ refuse('canonical-graph-invalid', 'the canonical graph has errors; no view is prepared from it', { errorCount: canonical.errors.length })
225
+ }
169
226
  return withEligibility(canonical, eligibility.isEligible, assetEligibilityFor({ graph: canonical, eligibility }))
170
227
  }
171
228
 
@@ -190,11 +247,13 @@ export function captureSnapshot({ project, graph, workspaceId, index, configDige
190
247
  return path.join(root, ...relative.split('/'))
191
248
  }
192
249
  const byRepo = new Map([...roots.keys()].map((repoId) => [repoId, new Map()]))
250
+ const observedReads = new Set()
193
251
  // Every node, and every asset a view may copy. A withheld asset is never pinned: it is in no snapshot document.
194
252
  const pinned = [...graph.nodes, ...(Array.isArray(graph.assets) ? graph.assets.filter((asset) => asset.eligible === true) : [])]
195
253
  for (const item of pinned) {
196
254
  if (!byRepo.has(item.repo)) refuse('source-not-in-snapshot', 'the graph names a repository the project does not enrol')
197
255
  const observed = index.get(sourceKey(item.repo, item.path))
256
+ if (typeof observed?.digest === 'string') observedReads.add(sourceKey(item.repo, item.path))
198
257
  const facts = observed?.digest ? observed : readFileFacts(absolute(item.repo, item.path))
199
258
  if (!facts) refuse('mixed-read', 'a file the graph read is no longer there')
200
259
  byRepo.get(item.repo).set(item.path, { path: item.path, rawDigest: facts.digest, byteLength: facts.byteLength })
@@ -217,7 +276,16 @@ export function captureSnapshot({ project, graph, workspaceId, index, configDige
217
276
  repositories,
218
277
  },
219
278
  graph,
220
- readSource: (repoId, relative) => readFileBytes(absolute(repoId, relative)),
279
+ readSource: (repoId, relative) => {
280
+ const file = absolute(repoId, relative)
281
+ try { return readFileBytes(file) } catch (error) {
282
+ if (observedReads.has(sourceKey(repoId, relative))) {
283
+ if (error?.code === 'ELEAFCHANGED') refuse('mixed-read', 'a selected source was replaced while its view was being prepared or published', { cause: error.code })
284
+ if ((error?.code === 'EISDIR' || error?.message === 'state leaf is not a regular file') && directoryNow(file)) refuse('mixed-read', 'a selected source became a directory while its view was being prepared or published', { cause: 'EISDIR' })
285
+ }
286
+ throw error
287
+ }
288
+ },
221
289
  }
222
290
  }
223
291
 
@@ -31,7 +31,7 @@ import { ObsidianMaintenanceRefusal } from './errors.mjs'
31
31
  // No answer in time proves nothing (a busy holder answers late). A ticket
32
32
  // from another machine, an unreadable ticket, an unknown file, or a live
33
33
  // process that recorded no health address all need a person: see
34
- // `inspectPrivateGenerationLock` and docs/local-services.md.
34
+ // `inspectPrivateGenerationLock` and docs/reference/local-services.md.
35
35
 
36
36
  export const LOCK_TICKET_SCHEMA = 'atelier-obsidian-lock-ticket/v1'
37
37
  export const ENGINE_LOCK_DIRECTORY = path.join('state', 'maintenance', 'engine-lock')
@@ -128,7 +128,11 @@ export async function inspectPrivateGenerationLock({ directory, workspaceId, pro
128
128
 
129
129
  // { acquired: true, generation, release() } or { acquired: false, reason, holder }.
130
130
  // Never waits and never throws for a lock that is merely held.
131
- export async function acquirePrivateGenerationLock({ workspaceRoot, directory, workspaceId, purpose, service = null, clock, proveAbandoned, randomBytes = cryptoRandomBytes }) {
131
+ // `validateLocation`, when given, proves the private state's location before the lock directory is made, again after
132
+ // the asynchronous inspection, and once a ticket is published, before predecessors are pruned. A refusal at the first
133
+ // two writes nothing; a refusal at the last withdraws the ticket just published, and prunes nothing.
134
+ export async function acquirePrivateGenerationLock({ workspaceRoot, directory, workspaceId, purpose, service = null, clock, proveAbandoned, randomBytes = cryptoRandomBytes, validateLocation = () => {} }) {
135
+ validateLocation()
132
136
  const contained = ensureContainedPrivateDirectory({ workspaceRoot, directory, label: 'private lock' })
133
137
  const owed = UNWITHDRAWN.get(contained)
134
138
  if (owed !== undefined) {
@@ -140,6 +144,7 @@ export async function acquirePrivateGenerationLock({ workspaceRoot, directory, w
140
144
  const holder = status.ticket ? { pid: status.ticket.pid, purpose: status.ticket.purpose, runtimeId: status.ticket.service?.runtimeId ?? null } : null
141
145
  if (!status.available) return { acquired: false, reason: status.reason, holder }
142
146
  if (status.generation >= MAX_GENERATION) return { acquired: false, reason: 'lock-generations-exhausted', holder }
147
+ validateLocation()
143
148
  const generation = status.generation + 1
144
149
  const ticket = validateTicket({ schema: LOCK_TICKET_SCHEMA, workspaceId, purpose, pid: process.pid, machine: machineDigest(), nonce: randomBytes(16).toString('hex'), acquiredAt, service }, workspaceId)
145
150
  const body = JSON.stringify(ticket)
@@ -167,6 +172,10 @@ export async function acquirePrivateGenerationLock({ workspaceRoot, directory, w
167
172
  try { fs.unlinkSync(file) } catch { /* a lower generation is ignored and pruned by the next holder */ }
168
173
  return { acquired: false, reason: 'a-newer-holder-exists', holder: null }
169
174
  }
175
+ try { validateLocation() } catch (error) {
176
+ withdraw()
177
+ throw error
178
+ }
170
179
  for (const name of after.names) {
171
180
  if (Number(TICKET_NAME.exec(name)[1]) < generation) try { fs.unlinkSync(path.join(contained, name)) } catch { /* pruned by somebody else */ }
172
181
  }
@@ -0,0 +1,124 @@
1
+ import { createHash } from 'node:crypto'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { inspectPrivateLock, withPrivateLock } from '../../project/durable-state.mjs'
5
+ import { atomicReplacePrivateText, ensureContainedPrivateDirectory, openRegularFileNoFollow } from '../../project/private-state.mjs'
6
+ import { canonicalJson, closedObject } from './documents.mjs'
7
+ import { ObsidianMaintenanceRefusal, refuse } from './errors.mjs'
8
+
9
+ // Machine-private continuity evidence, separate from the person's append-only selection history. The maximum never
10
+ // decreases, and previously observed record identities remain known after the person confirms a shorter history.
11
+ export const SELECTION_CONTINUITY_SCHEMA = 'atelier-obsidian-selection-continuity/v1'
12
+ export const MAX_SELECTION_IDENTITIES = 8192
13
+ export const MAX_SELECTION_CONTINUITY_BYTES = 1024 * 1024
14
+ const DIGEST = /^[0-9a-f]{64}$/
15
+ const CONFIRMATION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
16
+ const SOURCE = Object.freeze({ source: 'personal-workspace' })
17
+ const invalid = () => refuse('invalid-selection-continuity', 'the private selection continuity state cannot be verified; nothing is loaded', SOURCE)
18
+ const count = (value) => Number.isSafeInteger(value) && value >= 0
19
+
20
+ function validate(document, workspaceId, homeDigest) {
21
+ closedObject(document, { required: ['schema', 'workspaceId', 'homeDigest', 'highestSequence', 'accepted', 'seen', 'confirmations'] }, 'invalid-selection-continuity', 'the selection continuity state')
22
+ closedObject(document.accepted, { required: ['sequence', 'head'] }, 'invalid-selection-continuity', 'the accepted selection')
23
+ if (document.schema !== SELECTION_CONTINUITY_SCHEMA || document.workspaceId !== workspaceId || document.homeDigest !== homeDigest
24
+ || !count(document.highestSequence) || !count(document.accepted.sequence) || document.accepted.sequence > document.highestSequence
25
+ || !Array.isArray(document.seen) || document.seen.length > MAX_SELECTION_IDENTITIES || document.seen.some((digest) => typeof digest !== 'string' || !DIGEST.test(digest))
26
+ || !Array.isArray(document.confirmations) || document.confirmations.length > MAX_SELECTION_IDENTITIES || document.confirmations.some((id) => typeof id !== 'string' || !CONFIRMATION_ID.test(id))
27
+ || new Set(document.confirmations).size !== document.confirmations.length
28
+ || new Set(document.seen).size !== document.seen.length || document.highestSequence > document.seen.length
29
+ || (document.accepted.sequence === 0 ? document.accepted.head !== 'genesis' : !document.seen.includes(document.accepted.head))) invalid()
30
+ return document
31
+ }
32
+
33
+ export function createSelectionContinuity({ workspaceRoot, workspaceId, personalHome, validateLocation = () => {} }) {
34
+ const homeDigest = createHash('sha256').update(personalHome).digest('hex')
35
+ const directory = () => ensureContainedPrivateDirectory({ workspaceRoot, directory: path.join(workspaceRoot, 'state', 'maintenance'), label: 'selection continuity state' })
36
+ const file = path.join(workspaceRoot, 'state', 'maintenance', 'selection-continuity.json')
37
+ const lockPath = path.join(workspaceRoot, 'state', 'maintenance', 'selection-continuity.lock')
38
+ let observedState = false
39
+ let pending = null // an observed head whose durable write has not yet been proved
40
+ // The durable accepted head that pending was planned over. Pending keeps its maxima and identities, but never its
41
+ // accepted head once another writer has durably accepted something else.
42
+ let pendingBase = null
43
+ function read({ initializing = false } = {}) {
44
+ directory()
45
+ let fd
46
+ try {
47
+ fd = openRegularFileNoFollow(file, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0))
48
+ if (fs.fstatSync(fd).size > MAX_SELECTION_CONTINUITY_BYTES) invalid()
49
+ const bytes = fs.readFileSync(fd)
50
+ if (bytes.length > MAX_SELECTION_CONTINUITY_BYTES) invalid()
51
+ const document = validate(JSON.parse(bytes), workspaceId, homeDigest)
52
+ observedState = true
53
+ return document
54
+ } catch (error) {
55
+ if (error.code === 'ENOENT' && !observedState) {
56
+ const ownership = inspectPrivateLock(lockPath)
57
+ const unused = ownership.available && ownership.reason === 'unused' && ownership.generation === 0
58
+ // Only called inside withPrivateLock's operation: generation one is this operation's own first ticket.
59
+ const ownFirstTicket = initializing && !ownership.available && ownership.reason === 'process exists' && ownership.generation === 1
60
+ if (ownership.legacyDigest === null && (initializing ? ownFirstTicket : unused)) return null
61
+ }
62
+ invalid()
63
+ } finally { if (fd !== undefined) fs.closeSync(fd) }
64
+ }
65
+ function next(before, head) {
66
+ const seen = new Set(before?.seen ?? [])
67
+ const confirmations = new Set(before?.confirmations ?? [])
68
+ const same = before?.accepted.sequence === head.sequence && before?.accepted.head === head.head
69
+ // Length alone never proves continuation: the verified chain must still hold the accepted head at its position.
70
+ const extendsAccepted = before === null || before.accepted.sequence === 0
71
+ || (head.sequence > before.accepted.sequence && head.recordDigests[before.accepted.sequence - 1] === before.accepted.head)
72
+ const shorter = before !== null && (head.sequence < before.highestSequence || head.sequence <= before.accepted.sequence || !extendsAccepted)
73
+ const fresh = head.confirmationId !== null && !confirmations.has(head.confirmationId) && !seen.has(head.head)
74
+ if (!same && shorter && !fresh) refuse('selection-history-shortened', 'the selection history no longer follows the private continuity state; restore the removed records or confirm the selection again', SOURCE)
75
+ for (const digest of head.recordDigests) seen.add(digest)
76
+ for (const id of head.confirmationIds) confirmations.add(id)
77
+ if (seen.size > MAX_SELECTION_IDENTITIES || confirmations.size > MAX_SELECTION_IDENTITIES) refuse('selection-continuity-capacity', 'the private selection continuity state has reached its identity limit; nothing is loaded', SOURCE)
78
+ return validate({ schema: SELECTION_CONTINUITY_SCHEMA, workspaceId, homeDigest,
79
+ highestSequence: Math.max(before?.highestSequence ?? 0, head.sequence), accepted: { sequence: head.sequence, head: head.head }, seen: [...seen].sort(), confirmations: [...confirmations].sort() }, workspaceId, homeDigest)
80
+ }
81
+ function withPending(current) {
82
+ if (pending === null) return current
83
+ if (current === null) return pending
84
+ const seen = [...new Set([...current.seen, ...pending.seen])].sort()
85
+ const confirmations = [...new Set([...current.confirmations, ...pending.confirmations])].sort()
86
+ if (seen.length > MAX_SELECTION_IDENTITIES || confirmations.length > MAX_SELECTION_IDENTITIES) refuse('selection-continuity-capacity', 'the private selection continuity state has reached its identity limit; nothing is loaded', SOURCE)
87
+ const durableMoved = canonicalJson(current.accepted) !== pendingBase
88
+ return { ...pending, highestSequence: Math.max(current.highestSequence, pending.highestSequence), seen, confirmations,
89
+ accepted: durableMoved || current.highestSequence > pending.highestSequence ? current.accepted : pending.accepted }
90
+ }
91
+ function note(head) {
92
+ try {
93
+ validateLocation()
94
+ const disk = read()
95
+ const before = withPending(disk)
96
+ const planned = next(before, head)
97
+ if (disk !== null && canonicalJson(disk) === canonicalJson(planned)) { pending = null; pendingBase = null; return head }
98
+ if (pending === null || (disk !== null && canonicalJson(disk.accepted) !== pendingBase)) pendingBase = disk === null ? null : canonicalJson(disk.accepted)
99
+ pending = planned
100
+ // Proven again before the lock is taken and before the checkpoint is replaced.
101
+ validateLocation()
102
+ withPrivateLock(lockPath, () => {
103
+ const current = read({ initializing: disk === null })
104
+ // Decided again over the checkpoint as it reads under the lock.
105
+ const document = next(withPending(current), head)
106
+ pendingBase = current === null ? null : canonicalJson(current.accepted)
107
+ pending = document
108
+ const text = canonicalJson(document)
109
+ if (Buffer.byteLength(text) > MAX_SELECTION_CONTINUITY_BYTES) invalid()
110
+ validateLocation()
111
+ if (current === null || canonicalJson(current) !== text) atomicReplacePrivateText(file, text)
112
+ // An uncertain write is never authority to return a project.
113
+ if (canonicalJson(read()) !== text) invalid()
114
+ })
115
+ pending = null
116
+ pendingBase = null
117
+ return head
118
+ } catch (error) {
119
+ if (error instanceof ObsidianMaintenanceRefusal) throw error
120
+ refuse('selection-continuity-unavailable', 'the private selection continuity state could not be kept; nothing is loaded', SOURCE)
121
+ }
122
+ }
123
+ return Object.freeze({ note })
124
+ }
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url'
5
5
  import { firstString, parseArgs, resolveProjectConfig } from '../../project/config.mjs'
6
6
  import { workspaceStateRoot } from './machine-settings.mjs'
7
7
  import { loadSelectedProjectOffThread, locatePersonalHome } from './personal-selection.mjs'
8
- import { RELEASE_CHANGED, resolveServiceWorkspace, runMaintenanceService } from './service.mjs'
8
+ import { RELEASE_CHANGED, isLocationRefusalCode, resolveServiceWorkspace, runMaintenanceService } from './service.mjs'
9
9
 
10
10
  // The process of the maintenance service. `start` runs it as a child; a unit
11
11
  // installed at the operating-system level runs it with `--startup`.
@@ -108,6 +108,7 @@ function firstAnswerOf(loadProject) {
108
108
  return {
109
109
  // The project, when the loader answered one at once; null when it threw or answered a promise.
110
110
  project: () => (first.threw || first.promised ? null : first.project),
111
+ threw: () => first.threw === true,
111
112
  loadProject() {
112
113
  if (taken) return loadProject()
113
114
  taken = true
@@ -120,12 +121,15 @@ function firstAnswerOf(loadProject) {
120
121
  // The workspace of a service a login item started, when it can be found: where it keeps its log and records how its
121
122
  // start ended. When the project no longer leads to one (it moved, its pointer is gone, its loader answered no project
122
123
  // at once), the workspace the unit names by its data root and identity, if that exists; it is never created here. Null
123
- // otherwise; a refusal is then printed only, into the unit's own output file.
124
- function startupWorkspace({ project, dataRoot, workspaceId, env = process.env, platform = process.platform }) {
124
+ // otherwise, and null when locating the workspace refused or a personal home's locator refused: the recorded
125
+ // coordinates are not proven outside the roots declared now. A refusal is then printed only, into the unit's own
126
+ // output file.
127
+ function startupWorkspace({ project, located = false, locatorRefused = false, dataRoot, workspaceId, env = process.env, platform = process.platform }) {
128
+ if (located && locatorRefused) return null
125
129
  try {
126
130
  const workspace = project === null ? null : resolveServiceWorkspace({ project, dataRoot, env, platform })
127
131
  if (workspace?.workspaceRoot) return workspace
128
- } catch { /* the workspace the unit names, below */ }
132
+ } catch { return null }
129
133
  if (typeof dataRoot !== 'string' || !path.isAbsolute(dataRoot) || typeof workspaceId !== 'string') return null
130
134
  try {
131
135
  const root = workspaceStateRoot(path.resolve(dataRoot), workspaceId)
@@ -162,7 +166,7 @@ export async function runServiceProcess(options) {
162
166
  const located = typeof options.locateProject === 'function'
163
167
  const asked = located ? options.locateProject : options.loadProject
164
168
  const first = startup && typeof asked === 'function' ? firstAnswerOf(asked) : null
165
- const workspace = startup ? startupWorkspace({ ...options, project: first === null ? null : first.project() }) : null
169
+ const workspace = startup ? startupWorkspace({ ...options, located, locatorRefused: first?.threw() === true, project: first === null ? null : first.project() }) : null
166
170
  const log = workspace === null ? printed : workspaceLog(workspace.workspaceRoot)
167
171
  try {
168
172
  const service = await runMaintenanceService({ log, invokedAs: process.argv[1], ...options, ...(first === null ? {} : { [located ? 'locateProject' : 'loadProject']: first.loadProject }) })
@@ -175,8 +179,10 @@ export async function runServiceProcess(options) {
175
179
  const at = new Date().toISOString()
176
180
  // What was thrown is read without trusting it: a value with no prototype, or a getter that throws, is still logged.
177
181
  const code = readOr(() => (typeof error?.code === 'string' ? error.code : null), null)
178
- log({ at, event: 'refused', code: code ?? 'untyped-error', message: readOr(() => String(error?.message ?? error), 'unprintable') })
179
- if (startup && code !== null) { recordStartup(workspace, { at, outcome: 'refused', code }); process.exitCode = 0; return }
182
+ // A location refusal is printed only: nothing more is written under that private-state root.
183
+ const location = isLocationRefusalCode(code)
184
+ ;(location ? printed : log)({ at, event: 'refused', code: code ?? 'untyped-error', message: readOr(() => String(error?.message ?? error), 'unprintable') })
185
+ if (startup && code !== null) { if (!location) recordStartup(workspace, { at, outcome: 'refused', code }); process.exitCode = 0; return }
180
186
  process.exitCode = code !== null ? EXIT_REFUSED : 1
181
187
  }
182
188
  }
@@ -200,7 +206,7 @@ export function serviceOptionsFromArgv(argv, { env = process.env } = {}) {
200
206
  const intervalMs = args['interval-ms'] === undefined ? undefined : Number(args['interval-ms'])
201
207
  if (intervalMs !== undefined && (!Number.isInteger(intervalMs) || intervalMs < 1)) throw Object.assign(new Error('--interval-ms must be a positive integer'), { code: 'service-arguments-invalid' })
202
208
  return {
203
- loadProject: personalHome !== null ? () => loadSelectedProjectOffThread({ personalHome }) : () => resolveProjectConfig({ argv: [`--project=${configPath}`], cwd: path.dirname(configPath), env, writeLocalState: false }),
209
+ loadProject: personalHome !== null ? () => loadSelectedProjectOffThread({ personalHome, dataRoot, env }) : () => resolveProjectConfig({ argv: [`--project=${configPath}`], cwd: path.dirname(configPath), env, writeLocalState: false }),
204
210
  ...(personalHome === null ? {} : { locateProject: () => locatePersonalHome({ personalHome }) }),
205
211
  ...(dataRoot === undefined ? {} : { dataRoot }), ...(workspaceId === undefined ? {} : { workspaceId }), ...(intervalMs === undefined ? {} : { intervalMs }),
206
212
  ...(firstString(args['runtime-id']) ? { runtimeId: firstString(args['runtime-id']) } : {}),
@@ -213,7 +219,7 @@ const invokedDirectly = (() => { try { return Boolean(process.argv[1]) && fs.rea
213
219
  if (invokedDirectly) {
214
220
  const argv = process.argv.slice(2)
215
221
  // As early as this entry can: its static imports have already loaded most of the modules, so a release that changes
216
- // between this process's start and this reading is not noticed until the next change (docs/obsidian.md says so).
222
+ // between this process's start and this reading is not noticed until the next change (docs/guides/obsidian.md says so).
217
223
  const releaseWatch = argv.includes('--startup') ? watchRelease(process.argv[1]) : null
218
224
  let options = null
219
225
  try {
@@ -76,6 +76,15 @@ export function serviceWorkspaceInputs(project) {
76
76
  }
77
77
 
78
78
  // Where a workspace keeps its private state, without creating an identity.
79
+ // The same location check resolveServiceWorkspace applies, for an identity that is not yet persisted.
80
+ export function serviceLocationCheck({ project: whole, dataRoot, env = process.env, platform = process.platform }) {
81
+ const project = serviceWorkspaceInputs(whole)
82
+ return (pointer) => assertOutsideRepositories({
83
+ managedRoot: workspaceStateRoot(resolveDataRoot({ dataRoot, pointer, project, env, platform }), pointer.workspaceId),
84
+ repositoryRoots: protectedRoots(project),
85
+ })
86
+ }
87
+
79
88
  export function resolveServiceWorkspace({ project: whole, dataRoot, env = process.env, platform = process.platform, create = false }) {
80
89
  const project = serviceWorkspaceInputs(whole)
81
90
  const pointer = readLocalPointer(project)
@@ -87,6 +96,8 @@ export function resolveServiceWorkspace({ project: whole, dataRoot, env = proces
87
96
  return { workspaceId: pointer.workspaceId, workspaceRoot: fs.realpathSync(requested) }
88
97
  }
89
98
 
99
+ // A refusal that the private-state root is no longer proven outside every declared root: nothing more is written there.
100
+ export const isLocationRefusalCode = (code) => /^managed-root-|^repository-inside-managed-root$/.test(String(code ?? ''))
90
101
  const isTyped = (error) => error instanceof ObsidianMaintenanceRefusal || error instanceof AtelierDiagnosticError || error instanceof ObsidianContractRefusal
91
102
  const errorCode = (error) => (typeof error?.code === 'string' && /^[A-Za-z0-9_.:-]{1,64}$/.test(error.code) ? error.code : 'untyped-error')
92
103
  const errorName = (error) => (typeof error?.name === 'string' ? error.name.slice(0, 64) : 'Error')
@@ -237,6 +248,8 @@ export async function runMaintenanceService(options = {}) {
237
248
  if (refusedLastTick.get(item.scopeId) !== reason) log({ at, event: 'view-refused', scopeId: item.scopeId, code: item.code, ...(item.cause ? { cause: item.cause } : {}), ...(item.cleanupCause ? { cleanupCause: item.cleanupCause } : {}) })
238
249
  }
239
250
  refusedLastTick = refused
251
+ // A tick the engine refused for the location of private state leaves the error record as it is.
252
+ if (isLocationRefusalCode(outcome.report.refusal?.code)) return
240
253
  const previous = readLastServiceError({ workspaceRoot, workspaceId })
241
254
  if (previous && previous.resolvedAt === null) writeLastServiceError({ workspaceRoot, workspaceId, document: { ...previous, consecutiveFailures: 0, resolvedAt: at } })
242
255
  return
@@ -245,6 +258,7 @@ export async function runMaintenanceService(options = {}) {
245
258
  refusedLastTick = new Map()
246
259
  // The message may carry a path or a title: it goes to the private log and nowhere else.
247
260
  log({ at, event: 'tick-failed', code: errorCode(outcome.error), name: errorName(outcome.error), message: String(outcome.error?.message ?? outcome.error), consecutiveFailures })
261
+ if (isLocationRefusalCode(outcome.error?.code)) return
248
262
  let previous = null
249
263
  try { previous = readLastServiceError({ workspaceRoot, workspaceId }) } catch { previous = null }
250
264
  writeLastServiceError({ workspaceRoot, workspaceId, document: { schema: SERVICE_ERROR_SCHEMA, workspaceId, runtimeId, code: errorCode(outcome.error), name: errorName(outcome.error), at, consecutiveFailures, totalFailures: (previous?.totalFailures ?? 0) + 1, resolvedAt: null } })
@@ -6,7 +6,7 @@ import { realPathOfLocation } from './vault-location.mjs'
6
6
 
7
7
  // Whether the Obsidian this account runs lists a vault Atelier is filling for
8
8
  // the first time, as the publisher's `direct-unheld` path needs to know
9
- // (publisher.mjs; docs/obsidian-contract.md, "First publication into a vault
9
+ // (publisher.mjs; docs/reference/obsidian-contract.md, "First publication into a vault
10
10
  // no Obsidian lists"). Read from the app's settings file, never from the app:
11
11
  //
12
12
  // { unlisted: true } the file was read, and none of its entries is the vault root, a folder above
@@ -167,7 +167,8 @@ export const DEFAULT_ROW_ENDS_AFTER_MS = 60 * 1000
167
167
  // fails at every reload and answers only at its retry keeps one row. A row of loads after which nothing is kept ends
168
168
  // at the next that keeps a project. A load that named other files than it started over (the first, or one after a
169
169
  // change that names other files) neither counts nor ends that row, so a start and a change, each with one load that
170
- // has nothing to agree with yet, write none of these, unless the change lands while a load reads the files. Any other
170
+ // has nothing to agree with yet, write none of these, unless the change lands while a load reads the files, names the
171
+ // same files and changes the views or what the workspace is resolved from (docs/reference/obsidian-plugin.md). Any other
171
172
  // load that answered in time and kept the project writes nothing unless it ends a row of failures: that is every load
172
173
  // of a healthy service. A line holds the event, milliseconds, the outcome, flags and counts; never a path, a view or a
173
174
  // message.