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

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 +50 -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 +64 -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} +23 -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 +2 -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/constellation.mjs +369 -0
  148. package/src/graph-workspace/browser/dom.mjs +23 -0
  149. package/src/graph-workspace/browser/index.mjs +6 -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 +431 -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 +88 -0
  160. package/src/graph-workspace/scale.mjs +95 -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 +2 -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,6 +1,8 @@
1
1
  import crypto from 'node:crypto'
2
2
  import fs from 'node:fs'
3
3
  import path from 'node:path'
4
+ import formats from 'ajv-formats'
5
+ import { canonicalize } from '../attestation/jcs.mjs'
4
6
  import { createProposalStore } from '../collaboration/proposals.mjs'
5
7
  import { readJson, writeJson } from '../project/config.mjs'
6
8
  import { bundledReadinessPack, bundledReadinessProtocols, protocolById } from './bundled-pack.mjs'
@@ -126,7 +128,40 @@ export function claimsForProtocolRun({ protocol, answers, projectName, runId, cr
126
128
  })
127
129
  }
128
130
 
131
+ // createdAt is the existing run contract's date-time, rather than a filename or filesystem timestamp. Use the same
132
+ // full-format validator as the schema and refuse values the host cannot place on a timeline (including leap seconds).
133
+ const validCreatedAt = formats.get('date-time').validate
134
+ function runInstant(createdAt) {
135
+ if (typeof createdAt !== 'string' || !validCreatedAt(createdAt)) throw new Error('invalid-readiness-run-created-at')
136
+ // Compare the whole second separately so fractions finer than a millisecond do not collapse into a false tie.
137
+ const fraction = /\.(\d+)/.exec(createdAt)?.[1] ?? ''
138
+ const wholeSecond = Date.parse(createdAt.replace(/\.\d+/, ''))
139
+ if (!Number.isFinite(wholeSecond)) throw new Error('invalid-readiness-run-created-at')
140
+ return { wholeSecond, fraction }
141
+ }
142
+ const compareText = (left, right) => left < right ? -1 : left > right ? 1 : 0
143
+ function orderedProtocolRuns(runs) {
144
+ const seen = new Map()
145
+ return runs.map((run) => {
146
+ const instant = runInstant(run?.createdAt)
147
+ if (typeof run?.runId !== 'string' || !/^readiness-run-[a-f0-9]{24}$/.test(run.runId)) throw new Error('invalid-readiness-run-id')
148
+ const content = canonicalize(run)
149
+ if (seen.has(run.runId) && seen.get(run.runId) !== content) throw new Error('conflicting-readiness-run-id')
150
+ seen.set(run.runId, content)
151
+ return { run, ...instant }
152
+ }).sort((left, right) => {
153
+ const precision = Math.max(left.fraction.length, right.fraction.length)
154
+ return left.wholeSecond - right.wholeSecond
155
+ || compareText(left.fraction.padEnd(precision, '0'), right.fraction.padEnd(precision, '0'))
156
+ || compareText(left.run.runId, right.run.runId)
157
+ }).map(({ run }) => run)
158
+ }
159
+ function latestRunsByProtocol(runs) {
160
+ return new Map(orderedProtocolRuns(runs).map((run) => [run.protocolId, run]))
161
+ }
162
+
129
163
  export function buildReadinessRun({ project, protocol, answers = {}, createdAt = nowIso() }) {
164
+ runInstant(createdAt)
130
165
  const normalizedAnswers = normalizeAnswers(protocol, answers)
131
166
  const evaluation = evaluateProtocolAnswers(protocol, normalizedAnswers)
132
167
  const seed = JSON.stringify({ project: project.config.name || path.basename(project.workspaceRoot), protocol: protocol.id, answers: normalizedAnswers, createdAt })
@@ -155,6 +190,7 @@ export function buildReadinessRun({ project, protocol, answers = {}, createdAt =
155
190
  }
156
191
 
157
192
  export function writeReadinessRun(project, run) {
193
+ runInstant(run?.createdAt)
158
194
  const dir = readinessRunsDir(project)
159
195
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
160
196
  const file = path.join(dir, `${run.runId}.json`)
@@ -188,7 +224,7 @@ export function createReadinessProposal(project, run) {
188
224
  export function listProtocolRuns(project) {
189
225
  const dir = readinessRunsDir(project)
190
226
  if (!fs.existsSync(dir)) return []
191
- return fs.readdirSync(dir)
227
+ const runs = fs.readdirSync(dir)
192
228
  .filter((name) => name.endsWith('.json'))
193
229
  .sort()
194
230
  .map((name) => {
@@ -199,6 +235,7 @@ export function listProtocolRuns(project) {
199
235
  }
200
236
  })
201
237
  .filter(Boolean)
238
+ return orderedProtocolRuns(runs)
202
239
  }
203
240
 
204
241
  // Shared per-dimension facts derived from a stored run. Used by both the
@@ -222,8 +259,7 @@ function packProtocolRecords(registry) {
222
259
  }
223
260
 
224
261
  export function summarizeReadinessJourney(project, { runs = listProtocolRuns(project), registry = null } = {}) {
225
- const runsByProtocol = new Map()
226
- for (const run of runs) runsByProtocol.set(run.protocolId, run)
262
+ const runsByProtocol = latestRunsByProtocol(runs)
227
263
  const dimensions = DIMENSIONS.map(([key, label, protocolId]) => {
228
264
  const protocol = protocolById(protocolId)
229
265
  const facts = dimensionRunFacts(runsByProtocol.get(protocolId), protocolId)
@@ -281,8 +317,9 @@ function answersFor(run, key) {
281
317
  // the runtime and CLI tests instead; if it ever gets a schema, register it so
282
318
  // compat checking takes over.
283
319
  export function buildTenantPacket(project, { runs = listProtocolRuns(project), registry = null } = {}) {
284
- const byId = new Map(runs.map((run) => [run.protocolId, run]))
285
- const journey = summarizeReadinessJourney(project, { runs, registry })
320
+ const orderedRuns = orderedProtocolRuns(runs)
321
+ const byId = new Map(orderedRuns.map((run) => [run.protocolId, run]))
322
+ const journey = summarizeReadinessJourney(project, { runs: orderedRuns, registry })
286
323
  // Generic pack contribution: built entirely from run fields, no per-field
287
324
  // plucking (the literal byId.get(...) reads below stay bundled-only). Only
288
325
  // required-gate pack protocols can block export; advisory ones just report.
@@ -363,7 +400,7 @@ export function buildTenantPacket(project, { runs = listProtocolRuns(project), r
363
400
  ...packExportBlockers,
364
401
  ],
365
402
  openQuestions: answersFor(byId.get('mnstry.readiness:tenant-packet'), 'openQuestions') ?? [],
366
- proposedClaims: runs.flatMap((run) => run.claims ?? []).map((claim) => claim.claimId),
403
+ proposedClaims: orderedRuns.flatMap((run) => run.claims ?? []).map((claim) => claim.claimId),
367
404
  ext: {
368
405
  'mnstry.atelier': {
369
406
  extensionPacks: packContribution,
@@ -12,11 +12,12 @@ import { readObsidianEnablement } from './enablement.mjs'
12
12
  import { ObsidianMaintenanceRefusal, refuse } from './errors.mjs'
13
13
  import { createMaintenanceExtensions } from './extension-points.mjs'
14
14
  import {
15
- assertOutsideRepositories, authorizeAutomaticApply, defaultMachineSettings, ensureWorkspaceIdentity, protectedRoots, readLocalPointer,
16
- readMachineSettings, resolveDataRoot, workspaceStateRoot, writeMachineSettings,
15
+ assertOutsideRepositories, authorizeAutomaticApply, ensureWorkspaceIdentity, initializeMachineSettings, protectedRoots, readLocalPointer,
16
+ readMachineSettings, resolveDataRoot, workspaceStateRoot,
17
17
  } from './machine-settings.mjs'
18
18
  import { configKey, hasIndexedHint, listConfigFiles, listSourceFiles, listVaultNotes, readFileFacts, reconcile, reconcileCooperatively, sha256Digest, sourceKey, vaultKey } from './observation.mjs'
19
19
  import { dispatchAutomaticApply, heldPaths, layoutHeldPaths, observeVaultEdits, preserveInRecoveryStore, trustedNoteBases } from './pending-edits.mjs'
20
+ import { personalExclusionRootsNow } from './personal-selection.mjs'
20
21
  import { createProductionSeams, eligibilityFor } from './pipeline.mjs'
21
22
  import { PLUGIN_FILES_WAIT_FOR_APP } from './plugin-presence.mjs'
22
23
  import { ENGINE_LOCK_DIRECTORY, acquirePrivateGenerationLock, createAbandonmentProof } from './private-lock.mjs'
@@ -107,7 +108,7 @@ export const ENGINE_PRIMITIVES = Object.freeze({
107
108
  observedAssetsOf: (graph) => (graph.assets ?? []).filter((asset) => asset.eligible === true).map(({ repo, path: assetPath }) => ({ repo, path: assetPath })),
108
109
  // A change the guard sees in the middle of a tick is read, where it may be a confirmed selection, while it is there.
109
110
  noteSelectionAtGuard: notePersonalSelectionNow,
110
- // A loaded project pinned to a selection record below the highest record this process has read the history ending at
111
+ // A loaded project pinned to a selection different from the most recently accepted head in this process
111
112
  // is loaded again at the next tick, whatever the files say: another reader in the process may have read a
112
113
  // confirmation that is gone again before this engine looked (personalSelectionBehind).
113
114
  reloadsBehindSelection: personalSelectionBehind,
@@ -167,6 +168,11 @@ export const ENGINE_PRIMITIVES = Object.freeze({
167
168
  })
168
169
 
169
170
  const isTypedRefusal = (error) => error instanceof ObsidianMaintenanceRefusal || error instanceof AtelierDiagnosticError || error instanceof ObsidianContractRefusal || error instanceof PublicationRefusal
171
+ // The private-state location proof refused (assertLocation's own code): the state root itself is no longer proven
172
+ // outside every root declared now. That is never one view's refusal: it stops the whole tick, so no later view goes
173
+ // on writing under roots it can no longer trust. A view's own vault in a bad place keeps its own codes and stops
174
+ // only that view.
175
+ const isLocationRefusal = (error) => isTypedRefusal(error) && error.code === 'managed-root-unproven'
170
176
  // The code a view is refused with when its own vault cannot be placed: a typed refusal's, or `vault-location-unusable`
171
177
  // for a file-system error there (a file in the way, no permission, a read-only volume). Null for anything else, which
172
178
  // is a fault of the engine and stops the tick.
@@ -287,7 +293,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
287
293
  let firstTick = true
288
294
  let running = false
289
295
  let watching = { signature: null, handle: null }
290
- let known = null // { stateStore, maintenanceMode } once a workspace has been resolved
296
+ let known = null // { stateStore, maintenanceMode, repositoryRoots } once a workspace has been resolved
291
297
  let heldLock = null
292
298
  let releaseOwed = false
293
299
  // Views somebody asked to have prepared and published once more, at the next tick.
@@ -310,7 +316,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
310
316
  try { return (await readUnheldEvidence({ vaultRoot: store.vaultRoot }))?.unlisted === true } catch { return false }
311
317
  }
312
318
 
313
- async function takeLock(workspaceRoot, workspaceId) {
319
+ async function takeLock(workspaceRoot, workspaceId, validateLocation = undefined) {
314
320
  // A lock an earlier tick could not give back is still this process's, and its ticket on disk unreleased: a tick
315
321
  // that tried to take it anew would find its own ticket and answer `busy` until the process ends. It is given back
316
322
  // first. A release that fails again fails this tick, and is owed to the next.
@@ -322,6 +328,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
322
328
  if (heldLock) return { acquired: true }
323
329
  const lock = await rules.acquireEngineLock({
324
330
  workspaceRoot, directory: path.join(workspaceRoot, ENGINE_LOCK_DIRECTORY), workspaceId, purpose: 'maintenance-engine', service: lockOwner, clock, proveAbandoned,
331
+ ...(validateLocation === undefined ? {} : { validateLocation }),
325
332
  })
326
333
  if (lock.acquired) heldLock = lock
327
334
  return lock
@@ -369,7 +376,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
369
376
  // A vault root that is gone (a folder under the data root the person removed, say) is looked for again too, so the
370
377
  // store made in its place says why rather than failing on the folder that is not there.
371
378
  if (cached?.signature === signature && fs.lstatSync(cached.store.vaultRoot, { throwIfNoEntry: false }) !== undefined) {
372
- try { cached.store.checkAllocatedVault?.(); return cached.store } catch (error) { if (!isTypedRefusal(error)) throw error }
379
+ try { cached.store.checkAllocatedVault?.(); return cached.store } catch (error) { if (!isTypedRefusal(error) || isLocationRefusal(error)) throw error }
373
380
  }
374
381
  stores.delete(scope.scopeId)
375
382
  const store = seams.createRecoveryStore({ workspaceRoot, workspaceId, scopeId: scope.scopeId, repositoryRoots })
@@ -392,22 +399,67 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
392
399
  const blankEntry = (scopeId, now) => ({ scopeId, state: 'stale', reason: 'not-yet-published', generationId: null, preparedGenerationId: null, verified: false, heldNotes: [], changeClasses: [], retainedEdits: 0, checkedAt: now })
393
400
  const demote = (entry, state, reason, now) => ({ ...entry, state, reason, verified: false, checkedAt: now })
394
401
 
395
- // Persists "refused" or "disabled" over whatever is known, and only when a workspace already has state.
396
- async function persistOutcome({ enablement, state, reason, now, scopeIds = [] }) {
402
+ // The private-state location guard's own refusals: under one, the state root itself is in question.
403
+ const MANAGED_ROOT_REFUSALS = new Set(['managed-root-inside-repository', 'managed-root-not-absolute', 'managed-root-realpath-failed',
404
+ 'managed-root-realpath-overlap', 'managed-root-symlink-alias', 'repository-inside-managed-root', 'managed-root-unproven'])
405
+ // The roots private state must stay outside, as declared now: each project's own, and a personal home's manifest
406
+ // read again rather than as it was when the project loaded. Throws when they cannot be read.
407
+ const currentExclusionRoots = (declared) => declared.flatMap((value) => {
408
+ const binding = personalWorkspaceBindingOf(value)
409
+ return [...protectedRoots(value), ...(binding === null ? [] : personalExclusionRootsNow(binding.personalHome))]
410
+ })
411
+ // Whether a state root is proven outside every root declared now. A locator that refuses, or roots that cannot be
412
+ // read, prove nothing.
413
+ const locationProvenNow = (root, loaded) => {
414
+ try {
415
+ const declared = [...(locateProject === null ? [] : [locateProject()]), ...(loaded === null ? [] : [loaded])]
416
+ if (declared.length === 0) return false
417
+ assertOutsideRepositories({ managedRoot: root, repositoryRoots: currentExclusionRoots(declared) })
418
+ return true
419
+ } catch { return false }
420
+ }
421
+
422
+ // Persists "refused" or "disabled" over whatever is known, and only when a workspace already has state. Nothing is
423
+ // written for a location refusal, or unless the state root is proven outside every root declared now: a manifest
424
+ // that newly declares a root holding this state is never written into, cached store or not. When no project can be
425
+ // read now, a cached store is held to the roots it was last proven outside; without either, nothing is written.
426
+ async function persistOutcome({ enablement, state, reason, now, scopeIds = [], locationRefused = false }) {
427
+ if (locationRefused) return
428
+ // The roots declared now. A configured locator that cannot answer leaves them unknown: earlier roots never stand
429
+ // in. Without a locator or a readable project, a cached store is held to the roots it was last proven outside.
430
+ const rootsNow = () => {
431
+ let located = null
432
+ if (locateProject !== null) located = locateProject()
433
+ const declared = [located, project].filter((value) => value !== null && value !== undefined)
434
+ if (declared.length > 0) return currentExclusionRoots(declared)
435
+ if (locateProject === null && Array.isArray(known?.repositoryRoots)) return known.repositoryRoots
436
+ return null
437
+ }
438
+ let repositoryRoots
439
+ try { repositoryRoots = rootsNow() } catch { return }
440
+ if (repositoryRoots === null) return
441
+ const outside = (root) => { try { assertOutsideRepositories({ managedRoot: root, repositoryRoots }); return true } catch { return false } }
442
+ const outsideNow = (root) => { try { const roots = rootsNow(); if (roots === null) return false; assertOutsideRepositories({ managedRoot: root, repositoryRoots: roots }); return true } catch { return false } }
397
443
  if (!known) {
398
- let located = project
399
- try { located ??= locateProject === null ? null : locateProject() } catch { located = null }
444
+ let source = project
445
+ if (locateProject !== null) { try { source = locateProject() } catch { return } }
446
+ if (source === null || source === undefined) return
400
447
  let pointer = null
401
- try { pointer = located ? readLocalPointer(located) : null } catch { pointer = null }
448
+ try { pointer = readLocalPointer(source) } catch { pointer = null }
402
449
  if (!pointer) return
403
450
  let root
404
- try { root = workspaceStateRoot(resolveDataRoot({ dataRoot, pointer, project: located, env, platform }), pointer.workspaceId) } catch { return }
451
+ try { root = workspaceStateRoot(resolveDataRoot({ dataRoot, pointer, project: source, env, platform }), pointer.workspaceId) } catch { return }
452
+ if (!outside(root)) return
405
453
  const stateStore = createMaintenanceStateStore({ workspaceRoot: root, workspaceId: pointer.workspaceId })
406
454
  if (!stateStore.exists()) return
407
- known = { stateStore: createMaintenanceStateStore({ workspaceRoot: fs.realpathSync(root), workspaceId: pointer.workspaceId }), maintenanceMode: 'manual' }
455
+ known = { stateStore: createMaintenanceStateStore({ workspaceRoot: fs.realpathSync(root), workspaceId: pointer.workspaceId }), maintenanceMode: 'manual', repositoryRoots }
408
456
  }
457
+ if (!outside(known.stateStore.workspaceRoot)) return
458
+ // Proven again inside the acquisition and once it returns, against the roots declared then.
459
+ const stillOutside = () => { if (!outsideNow(known.stateStore.workspaceRoot)) refuse('managed-root-unproven', 'the private state can no longer be proved outside every root declared now; nothing is written') }
409
460
  // Another engine is ticking this workspace: its state is not ours to write.
410
- if (!(await takeLock(known.stateStore.workspaceRoot, known.stateStore.workspaceId)).acquired) return
461
+ try { if (!(await takeLock(known.stateStore.workspaceRoot, known.stateStore.workspaceId, stillOutside)).acquired) return } catch (error) { if (isTypedRefusal(error) && error.code === 'managed-root-unproven') return; throw error }
462
+ if (!outsideNow(known.stateStore.workspaceRoot)) return
411
463
  const previous = readPreviousFreshness(known.stateStore)
412
464
  const entries = new Map()
413
465
  for (const entry of previous?.scopes ?? []) entries.set(entry.scopeId, demote(entry, state, reason, now))
@@ -466,20 +518,28 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
466
518
  }
467
519
 
468
520
  // 2. Identity and machine settings, outside every repository.
469
- const pointer = ensureWorkspaceIdentity({ project, ...(randomBytes ? { randomBytes } : {}) })
470
- const { workspaceId } = pointer
471
- const requestedRoot = workspaceStateRoot(resolveDataRoot({ dataRoot, pointer, project, env, platform }), workspaceId)
472
521
  const repositoryRoots = protectedRoots(project)
473
- assertOutsideRepositories({ managedRoot: requestedRoot, repositoryRoots })
522
+ // The project this tick loaded. `project` itself may be cleared later in the tick to force a reload.
523
+ const loadedProject = project
524
+ // Each phase that writes after a cooperative wait proves the location again against the roots declared now; the
525
+ // refusal is a location refusal, so refusal bookkeeping writes nothing either. A held engine lock is still released.
526
+ const assertLocation = () => {
527
+ if (!locationProvenNow(workspaceRoot, loadedProject)) refuse('managed-root-unproven', 'the private state can no longer be proved outside every root declared now; this tick writes nothing more')
528
+ }
529
+ const stateRootOf = (named) => workspaceStateRoot(resolveDataRoot({ dataRoot, pointer: named, project, env, platform }), named.workspaceId)
530
+ const pointer = ensureWorkspaceIdentity({ project, ...(randomBytes ? { randomBytes } : {}),
531
+ validate: (named) => assertOutsideRepositories({ managedRoot: stateRootOf(named), repositoryRoots }) })
532
+ const { workspaceId } = pointer
533
+ const requestedRoot = stateRootOf(pointer)
474
534
  fs.mkdirSync(requestedRoot, { recursive: true, mode: 0o700 })
475
535
  const workspaceRoot = fs.realpathSync(requestedRoot)
476
- const lock = await takeLock(workspaceRoot, workspaceId)
536
+ const lock = await takeLock(workspaceRoot, workspaceId, assertLocation)
477
537
  if (!lock.acquired) return { state: 'busy', reason: 'engine-lock-held', lock: { reason: lock.reason, holder: lock.holder ?? null }, changes: [], scopes: [], pendingEdits: [], dispatched: [], lateWriters: [] }
478
- const machine = readMachineSettings({ workspaceRoot, workspaceId })
479
- ?? writeMachineSettings({ workspaceRoot, workspaceId, repositoryRoots, settings: defaultMachineSettings({ workspaceId, updatedAt: now }) })
538
+ assertLocation()
539
+ const machine = initializeMachineSettings({ workspaceRoot, workspaceId, repositoryRoots, updatedAt: now, personal: personalWorkspaceBindingOf(project) !== null })
480
540
  const eligibility = fixedEligibility ?? eligibilityFor({ machine, project })
481
541
  const stateStore = createMaintenanceStateStore({ workspaceRoot, workspaceId })
482
- known = { stateStore, maintenanceMode: machine.maintenanceMode }
542
+ known = { stateStore, maintenanceMode: machine.maintenanceMode, repositoryRoots }
483
543
  // A view refused for a file-system error (a vault folder that could not be allocated or placed, a configuration
484
544
  // file that could not be read again, a source removed while it was read, candidates that could not be staged) is
485
545
  // settled under the refusal's code alone, and such a tick ends without a failure. The error's code (`EACCES`, `EIO`,
@@ -496,6 +556,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
496
556
  // A view whose own vault cannot be placed: a typed refusal's code and detail, or `vault-location-unusable` for a
497
557
  // file-system error there, whose code is its cause.
498
558
  const refuseViewAlone = (scopeId, error) => {
559
+ if (isLocationRefusal(error)) throw error
499
560
  const code = viewRefusalCode(error)
500
561
  if (code === null) throw error
501
562
  allocationRefusals.set(scopeId, code)
@@ -528,6 +589,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
528
589
  const record = readVaultAllocation({ workspaceRoot, workspaceId, scopeId: scope.scopeId })
529
590
  if (record === null ? !hasCommittedGeneration({ workspaceRoot, scopeId: scope.scopeId }) : allocatedFolderState(record) === 'missing') {
530
591
  const known = await appList()
592
+ assertLocation()
531
593
  if (!known.ok) refuse('app-vault-list-unreadable', 'Obsidian\'s vault list could not be read, so no folder is allocated for this view yet; it is tried again at the next tick', { cause: known.code })
532
594
  vaults = known.vaults
533
595
  }
@@ -575,6 +637,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
575
637
  looked.push({ scope, store, manifest, bases, vault })
576
638
  }
577
639
  } finally { reportPhase('vault-observation', vaultStartedMs) }
640
+ assertLocation()
578
641
  const pending = stateStore.readPendingEdits()
579
642
  let edits = pending.edits
580
643
  for (const { scope, store, manifest, bases, vault } of looked) {
@@ -599,6 +662,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
599
662
  let observed = null
600
663
  if (proposalAdapter !== null && rules.observeEdits(proposalAdapter)) {
601
664
  try { observed = await proposalAdapter.observe(adapterContext(), { retryRefused: full }) } catch { observed = { adapterId: proposalAdapter.id, failed: 'proposal-adapter-threw' } }
665
+ assertLocation()
602
666
  }
603
667
 
604
668
  // 4. Automatic apply, through the registered operation only.
@@ -608,6 +672,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
608
672
  edits, applyOperation: extensions.applyOperation(), now, nowMs, retryIntervalMs,
609
673
  authorize: () => rules.authorize({ workspaceRoot, workspaceId }),
610
674
  })
675
+ assertLocation()
611
676
  edits = outcome.edits
612
677
  dispatched = outcome.dispatched
613
678
  stateStore.writePendingEdits({ ...pending, edits })
@@ -619,6 +684,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
619
684
  let proposals = null
620
685
  if (proposalAdapter !== null) {
621
686
  try { proposals = await proposalAdapter.propose(adapterContext()) } catch { proposals = { adapterId: proposalAdapter.id, failed: 'proposal-adapter-threw' } }
687
+ assertLocation()
622
688
  }
623
689
 
624
690
  // 5. Late writers. The publisher looks twice per publication; a holder can write later still.
@@ -652,6 +718,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
652
718
  const sourcesStartedMs = phaseTime()
653
719
  let sources
654
720
  try { sources = await reconcileCooperatively({ index, files: listSourceFiles(project, { assets: observedAssets }), prefix: SOURCE_PREFIX, full, hinted, lstat }, createCooperativeBudget()) } finally { reportPhase('source-observation', sourcesStartedMs) }
721
+ assertLocation()
655
722
  for (const change of sources.changes) changes.push({ changeClass: change.changeClass })
656
723
  const { scopes: _declared, ...settingsWithoutScopes } = enablement.settings
657
724
  // What the configuration says apart from this integration's own member: a change to the member alone is an
@@ -712,7 +779,9 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
712
779
  entries.set(scopeId, demote(previous?.scopes.find((entry) => entry.scopeId === scopeId && entry.state !== 'disabled') ?? blankEntry(scopeId, now), 'stale', code, now))
713
780
  }
714
781
  for (const entry of previous?.scopes ?? []) if (!entries.has(entry.scopeId)) entries.set(entry.scopeId, demote(entry, 'disabled', 'scope-not-configured', now))
715
- const persist = () => stateStore.writeFreshness(freshnessDocument({ workspaceId, enablement: 'enabled', maintenanceMode: machine.maintenanceMode, now, entries }))
782
+ // Freshness is written only while the state root is still proven outside the roots declared now: a manifest that
783
+ // changed during this tick to enclose it gets no write, refusal or not.
784
+ const persist = () => { if (locationProvenNow(workspaceRoot, loadedProject)) stateStore.writeFreshness(freshnessDocument({ workspaceId, enablement: 'enabled', maintenanceMode: machine.maintenanceMode, now, entries })) }
716
785
  persist()
717
786
 
718
787
  // 8. Rebuild and publish.
@@ -729,6 +798,8 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
729
798
  let selectionNoted = false
730
799
  const noteSelectionOnce = () => { if (!selectionNoted) { selectionNoted = true; rules.noteSelectionAtGuard(project) } }
731
800
  const assertBindings = () => {
801
+ // Every cooperative wait of preparation and publication resumes here before it writes again.
802
+ assertLocation()
732
803
  let changed
733
804
  try {
734
805
  changed = bindings.some(file => (readFileFacts(file.absolute)?.digest ?? null) !== file.digest)
@@ -835,7 +906,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
835
906
  profile: seams.profileFor({ project, workspaceId, audienceAllow: machine.audienceAllow }),
836
907
  }
837
908
  } catch (error) {
838
- if (!isTypedRefusal(error)) throw error
909
+ if (!isTypedRefusal(error) || isLocationRefusal(error)) throw error
839
910
  for (const scopeId of attempt.keys()) { entries.set(scopeId, demote(entries.get(scopeId), 'stale', error.code, now)); noteRefusalCause(scopeId, error) }
840
911
  if (REREAD_CODES.has(error.code)) forceFull = true
841
912
  // The personal workspace refused the bound generation: the project is loaded again at the next tick, so a
@@ -845,7 +916,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
845
916
  for (const { scope, store } of built ? scopes.filter((item) => attempt.has(item.scope.scopeId)) : []) {
846
917
  const { scopeId } = scope
847
918
  const entry = entries.get(scopeId)
848
- // What this attempt found worth naming: notes and rules (see "Notes that were laid out anyway" in docs/obsidian-contract.md).
919
+ // What this attempt found worth naming: notes and rules (see "Notes that were laid out anyway" in docs/reference/obsidian-contract.md).
849
920
  let diagnostics = []
850
921
  const settle = (state, reason, extra = {}) => {
851
922
  const { diagnostics: _earlier, ...rest } = { ...entry, ...extra, state, reason, verified: extra.verified === true, checkedAt: now }
@@ -951,6 +1022,8 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
951
1022
  }
952
1023
  } catch (error) {
953
1024
  if (!isTypedRefusal(error)) { settle('stale', 'publisher-error'); persist(); throw error }
1025
+ // Nothing more is recorded for any view: the tick stops and refuses as a whole.
1026
+ if (isLocationRefusal(error)) throw error
954
1027
  // A redaction refusal names the note and the rule it concerns, never the value.
955
1028
  const where = error.code === 'redaction-failure' ? error.detail ?? {} : {}
956
1029
  diagnostics = typeof where.rule === 'string' ? [{ code: error.code, rule: where.rule, ...(typeof where.notePath === 'string' ? { notePath: where.notePath } : {}), ...(typeof where.filePath === 'string' ? { filePath: where.filePath } : {}) }] : []
@@ -1010,7 +1083,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
1010
1083
  throw error
1011
1084
  }
1012
1085
  // Fail closed: nothing is published under a refusal, and every known view says why.
1013
- try { await persistOutcome({ enablement: 'refused', state: 'stale', reason: error.code, now: isoTime(clock) }) } catch { /* the refusal itself is still reported */ }
1086
+ try { await persistOutcome({ enablement: 'refused', state: 'stale', reason: error.code, now: isoTime(clock), locationRefused: MANAGED_ROOT_REFUSALS.has(error.code) }) } catch { /* the refusal itself is still reported */ }
1014
1087
  project = null
1015
1088
  semantic = null
1016
1089
  firstTick = true
@@ -16,10 +16,10 @@ import {
16
16
  serviceNameFor, servicePaths, writeServiceSettings,
17
17
  } from './service-record.mjs'
18
18
  import { personalHomeOf } from './personal-selection.mjs'
19
- import { resolveServiceWorkspace } from './service.mjs'
19
+ import { resolveServiceWorkspace, serviceLocationCheck } from './service.mjs'
20
20
 
21
21
  // start / status / stop of the maintenance service of one workspace, as
22
- // docs/local-services.md describes them.
22
+ // docs/reference/local-services.md describes them.
23
23
  //
24
24
  // Ownership is proven, never assumed. A running service is ours only when the
25
25
  // owner-only record and the health answer agree on service name, workspace,
@@ -67,7 +67,7 @@ export const LIFECYCLE_PRIMITIVES = Object.freeze({
67
67
  function context({ loadProject, locateProject = null, dataRoot, env, platform, create = false, randomBytes }) {
68
68
  if (typeof loadProject !== 'function') throw new TypeError('the service lifecycle needs loadProject')
69
69
  const project = locateProject === null ? loadProject() : locateProject()
70
- if (create) ensureWorkspaceIdentity({ project, ...(randomBytes ? { randomBytes } : {}) })
70
+ if (create) ensureWorkspaceIdentity({ project, ...(randomBytes ? { randomBytes } : {}), validate: serviceLocationCheck({ project, dataRoot, env, platform }) })
71
71
  const workspace = resolveServiceWorkspace({ project, dataRoot, env, platform, create })
72
72
  return { project, workspace }
73
73
  }
@@ -10,7 +10,7 @@ import { ensureWorkspaceIdentity, readLocalPointer, resolveDataRoot } from './ma
10
10
  import { SERVICE_MANAGER_KINDS } from './service-managers.mjs'
11
11
  import { readLastStartup, readServiceSettings, serviceNameFor, servicePaths, writeServiceSettings } from './service-record.mjs'
12
12
  import { personalHomeOf } from './personal-selection.mjs'
13
- import { resolveServiceWorkspace } from './service.mjs'
13
+ import { resolveServiceWorkspace, serviceLocationCheck } from './service.mjs'
14
14
  import { STARTUP_PLATFORMS, buildStartupAdapter, startupSearchPath } from './startup-adapters.mjs'
15
15
 
16
16
  // A login item: the operating system starts this workspace's maintenance
@@ -198,7 +198,7 @@ export async function installLoginItem(options = {}) {
198
198
  const { loadProject, dataRoot, env = process.env, platform = process.platform, manager, consent, ownEntry, entryArgs = [], nodePath = realNodePath(), pathValue = env.PATH, clock = () => new Date(), temporary = temporaryRoots(), beforeInstall = null } = options
199
199
  checkManager(manager, platform)
200
200
  const project = loadProject()
201
- ensureWorkspaceIdentity({ project, ...(dataRoot === undefined ? {} : { dataRoot }) })
201
+ ensureWorkspaceIdentity({ project, ...(dataRoot === undefined ? {} : { dataRoot }), validate: serviceLocationCheck({ project, dataRoot, env, platform }) })
202
202
  const workspace = resolveServiceWorkspace({ project, dataRoot, env, platform, create: true })
203
203
  const { entryPath, source } = resolveLoginItemEntry({ project, ownEntry, temporary })
204
204
  const recorded = readLoginItemRecord(workspace)
@@ -3,6 +3,7 @@ import fs from 'node:fs'
3
3
  import os from 'node:os'
4
4
  import path from 'node:path'
5
5
  import { LOCAL_STATE_DIR, localStateRoot } from '../../project/config.mjs'
6
+ import { withPrivateLock } from '../../project/durable-state.mjs'
6
7
  import { checkManagedRoots } from '../../project/file-class.mjs'
7
8
  import { atomicReplacePrivateText, ensureContainedPrivateDirectory, readRegularTextNoFollow } from '../../project/private-state.mjs'
8
9
  import { OBSIDIAN_EXT_KEY, ObsidianContractRefusal, assertObsidianContract } from '../../projection/obsidian/contracts.mjs'
@@ -146,12 +147,20 @@ export function writeLocalPointer(project, pointer) {
146
147
  }
147
148
 
148
149
  // Reads the persisted workspace identity, or issues a random one and persists
149
- // it. `randomBytes` is injectable so tests are deterministic.
150
- export function ensureWorkspaceIdentity({ project, dataRoot, randomBytes = cryptoRandomBytes } = {}) {
150
+ // it. `randomBytes` is injectable so tests are deterministic. `validate`, when
151
+ // given, checks the location an identity names: a new identity is checked
152
+ // before its pointer is written, so a refused location leaves no pointer, and
153
+ // the identity actually persisted is checked again before it is returned.
154
+ export function ensureWorkspaceIdentity({ project, dataRoot, randomBytes = cryptoRandomBytes, validate = null } = {}) {
151
155
  const existing = readLocalPointer(project)
152
- if (existing) return existing
153
- const pointer = { schema: LOCAL_POINTER_SCHEMA, workspaceId: `ws-${randomBytes(12).toString('hex')}`, ...(dataRoot === undefined ? {} : { dataRoot: path.resolve(dataRoot) }) }
154
- return writeLocalPointer(project, pointer)
156
+ if (existing) { validate?.(existing); return existing }
157
+ const pointer = validatePointer({ schema: LOCAL_POINTER_SCHEMA, workspaceId: `ws-${randomBytes(12).toString('hex')}`, ...(dataRoot === undefined ? {} : { dataRoot: path.resolve(dataRoot) }) })
158
+ validate?.(pointer)
159
+ writeLocalPointer(project, pointer)
160
+ const persisted = readLocalPointer(project)
161
+ if (persisted === null) refuse('invalid-local-pointer', 'the Obsidian pointer was not kept')
162
+ validate?.(persisted)
163
+ return persisted
155
164
  }
156
165
 
157
166
  // ---------------------------------------------------------------------------
@@ -260,6 +269,18 @@ export function defaultMachineSettings({ workspaceId, updatedAt }) {
260
269
  }
261
270
  }
262
271
 
272
+ // The already adopted owner-only audience for a personal binding, recorded in the same machine setting an ordinary
273
+ // workspace uses. Existing explicit decisions keep their exact list and unclassified choice; a clear stays empty.
274
+ export function ownerOnlyPersonalSettings(settings, { decidedAt }) {
275
+ const decision = settings.decisions.audience
276
+ if (decision === null && settings.audienceAllow.length === 0) {
277
+ return withDecision({ ...settings, audienceAllow: [...ONLY_YOU_AUDIENCES], updatedAt: decidedAt }, 'audience',
278
+ { choice: 'only-you', unclassified: 'shown' }, { decidedAt, via: 'defaults' })
279
+ }
280
+ if (decision?.choice === 'only-you' || (decision?.choice === 'custom' && settings.audienceAllow.length === 0)) return settings
281
+ refuse('personal-binding-owner-only', 'a personal workspace vault is owner only; select `audience set me` or clear its audience before publishing')
282
+ }
283
+
263
284
  // The settings with one decision recorded, validated; nothing is written. `decision` holds the decision's own members
264
285
  // (`{ choice, unclassified }`, `{ parent }`, `{ choice }`); the stamp says when, by whom (an identifier, or null when not
265
286
  // known) and how.
@@ -278,15 +299,38 @@ export function readMachineSettings({ workspaceRoot, workspaceId }) {
278
299
  return document === null ? null : normalizeMachineSettings(document, workspaceId)
279
300
  }
280
301
 
281
- // Writes v2, whatever version it is handed, and returns what it wrote.
282
- export function writeMachineSettings({ workspaceRoot, workspaceId, settings, repositoryRoots }) {
283
- const document = normalizeMachineSettings(settings, workspaceId)
302
+ function withMachineSettingsWriteLock({ workspaceRoot, repositoryRoots }, operation) {
284
303
  assertOutsideRepositories({ managedRoot: workspaceRoot, repositoryRoots })
285
304
  fs.mkdirSync(workspaceRoot, { recursive: true, mode: 0o700 })
305
+ return withPrivateLock(path.join(settingsDirectory(workspaceRoot), 'machine-settings.lock'), operation)
306
+ }
307
+
308
+ function writeMachineSettingsUnlocked({ workspaceRoot, document }) {
286
309
  atomicReplacePrivateText(path.join(settingsDirectory(workspaceRoot), 'machine.json'), canonicalJson(document))
287
310
  return document
288
311
  }
289
312
 
313
+ // Writes v2, whatever version it is handed, and returns what it wrote. Commands and initialization share this lock.
314
+ export function writeMachineSettings({ workspaceRoot, workspaceId, settings, repositoryRoots }) {
315
+ const document = normalizeMachineSettings(settings, workspaceId)
316
+ return withMachineSettingsWriteLock({ workspaceRoot, repositoryRoots }, () => writeMachineSettingsUnlocked({ workspaceRoot, document }))
317
+ }
318
+
319
+ // Read-only when already decided. If initialization is needed, reread under the write lock so a committed clear wins.
320
+ export function initializeMachineSettings({ workspaceRoot, workspaceId, repositoryRoots, updatedAt, personal = false }) {
321
+ const initialized = (current) => {
322
+ const initial = current ?? defaultMachineSettings({ workspaceId, updatedAt })
323
+ return personal ? ownerOnlyPersonalSettings(initial, { decidedAt: updatedAt }) : initial
324
+ }
325
+ const existing = readMachineSettings({ workspaceRoot, workspaceId })
326
+ if (initialized(existing) === existing) return existing
327
+ return withMachineSettingsWriteLock({ workspaceRoot, repositoryRoots }, () => {
328
+ const current = readMachineSettings({ workspaceRoot, workspaceId })
329
+ const document = normalizeMachineSettings(initialized(current), workspaceId)
330
+ return document === current ? current : writeMachineSettingsUnlocked({ workspaceRoot, document })
331
+ })
332
+ }
333
+
290
334
  // ---------------------------------------------------------------------------
291
335
  // Installed apply policy
292
336
  // ---------------------------------------------------------------------------
@@ -134,7 +134,7 @@ export const REASON_NEXT = Object.freeze({
134
134
  // publisher or editor is involved.
135
135
  'plugin-file-changed': 'a file of Atelier\'s plugin in this vault (under .obsidian/plugins) changed while the view was published, by another program or by you, so the publication did not finish; it is retried automatically, and if this keeps happening, stop what keeps writing there (a sync tool, say)',
136
136
  'settings-changed': 'a settings file Atelier writes in this vault (the plugin list or a plugin setting under .obsidian) changed while the view was published, by Obsidian, another program or you, and was not written over; it is retried automatically, reading the file as it is then',
137
- 'selection-history-shortened': 'the selection history of the personal home ends at an earlier record than the running maintenance service has read: records were removed by something other than a confirmation, and the service publishes nothing while the history is shorter. Put the removed records back, and it goes on as before. Or accept the history as it now stands: a service started again (`atelier obsidian service stop`, then open again) reads the history as it is and follows the selection it then ends with, publishing it if its generation still holds (`atelier obsidian status` reports that selection, or why it cannot be used); a confirmation made after the records were removed is followed without a restart once the history is as long as it was',
137
+ 'selection-history-shortened': 'the selection history no longer follows the machine-private continuity evidence, so nothing is published. Put the removed records back, or explicitly confirm the generation again through the personal workspace selection operation. One fresh confirmation accepts the shorter history if the generation is still eligible. Restarting the service preserves this refusal.',
138
138
  'vault-inside-another-vault': 'Obsidian lists another vault at a folder that contains this view\'s vault; open never adds a vault inside another one, and never sends a call that could reach that vault instead: remove that vault from Obsidian\'s vault list, or keep Atelier\'s data root outside that folder, then open again',
139
139
  })
140
140
 
@@ -253,7 +253,7 @@ export function scopeReport({ workspace, scopeId, repositoryRoots, serviceState,
253
253
  freshness: entry === null ? null : { state: entry.state, reason: entry.reason, verified: entry.verified, generationId: entry.generationId, preparedGenerationId: entry.preparedGenerationId, heldNoteCount: entry.heldNotes.length, retainedEdits: entry.retainedEdits, checkedAt: entry.checkedAt },
254
254
  readBack: { readable: verification.readable, reason: verification.reason, generationId: verification.generationId, intact: verification.intact, noteCount: verification.noteCount, differing: verification.differing, missing: verification.missing },
255
255
  pendingEdits: pendingSummary(stateStore, scopeId, applyAvailable),
256
- // Notes of the view worth naming, and the rule concerned; see "Notes that were laid out anyway" in docs/obsidian-contract.md.
256
+ // Notes of the view worth naming, and the rule concerned; see "Notes that were laid out anyway" in docs/reference/obsidian-contract.md.
257
257
  ...(entry?.diagnostics ? { diagnostics: entry.diagnostics } : {}),
258
258
  vaultRoot: verification.vaultRoot ?? null,
259
259
  }