@mnstry/atelier 0.2.0-alpha.13 → 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 (253) hide show
  1. package/CHANGELOG.md +283 -0
  2. package/README.md +102 -52
  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/capability-stewardship.md +1 -1
  10. package/docs/blocks/claims.md +2 -2
  11. package/docs/blocks/conformance-admission.md +1 -1
  12. package/docs/blocks/learning-harnesses.md +1 -1
  13. package/docs/{atelier-sync.md → guides/atelier-sync.md} +20 -14
  14. package/docs/{capability-stewardship.md → guides/capability-stewardship.md} +2 -4
  15. package/docs/guides/co-authoring.md +113 -0
  16. package/docs/{distributions.md → guides/distributions.md} +13 -8
  17. package/docs/guides/extensions.md +189 -0
  18. package/docs/guides/graph-refresh.md +152 -0
  19. package/docs/guides/graph-workspace.md +64 -0
  20. package/docs/{ingestion.md → guides/ingestion.md} +16 -15
  21. package/docs/{inquiry-harnesses.md → guides/inquiry-harnesses.md} +2 -3
  22. package/docs/{knowledge-workspace.md → guides/knowledge-workspace.md} +2 -2
  23. package/docs/{learning-harnesses.md → guides/learning-harnesses.md} +2 -3
  24. package/docs/{learning.md → guides/learning.md} +12 -5
  25. package/docs/{obsidian.md → guides/obsidian.md} +370 -23
  26. package/docs/guides/ontology-guide.md +111 -0
  27. package/docs/{personal-workspace.md → guides/personal-workspace.md} +17 -5
  28. package/docs/{research-starter.md → guides/research-starter.md} +1 -1
  29. package/docs/{tenant-readiness.md → guides/tenant-readiness.md} +39 -0
  30. package/docs/{upgrade.md → guides/upgrade.md} +197 -11
  31. package/docs/knowledge-health/assessment/capabilities.md +6 -15
  32. package/docs/knowledge-health/facilitator.md +15 -4
  33. package/docs/knowledge-health/{devday/participant-response.md → participant-response.md} +4 -2
  34. package/docs/knowledge-health/quickstart.md +97 -75
  35. package/docs/knowledge-health/readme.md +13 -5
  36. package/docs/maintainers/describing-the-atelier.md +173 -0
  37. package/docs/{release-engineering.md → maintainers/release-engineering.md} +5 -4
  38. package/docs/{announcements.md → reference/announcements.md} +1 -1
  39. package/docs/{architecture.md → reference/architecture.md} +31 -5
  40. package/docs/{atelier-runtime.md → reference/atelier-runtime.md} +1 -1
  41. package/docs/{connected-composition.md → reference/connected-composition.md} +7 -10
  42. package/docs/{coordination.md → reference/coordination.md} +1 -1
  43. package/docs/{decisions.md → reference/decisions.md} +3 -3
  44. package/docs/{design.md → reference/design.md} +1 -1
  45. package/docs/reference/edge-policy.md +151 -0
  46. package/docs/reference/graph-view-structured.md +119 -0
  47. package/docs/reference/graph-view.md +139 -0
  48. package/docs/reference/graph-workspace.md +37 -0
  49. package/docs/{intake-and-guides.md → reference/intake-and-guides.md} +3 -4
  50. package/docs/{knowledge-graph.md → reference/knowledge-graph.md} +120 -6
  51. package/docs/{local-services.md → reference/local-services.md} +43 -4
  52. package/docs/{obsidian-contract.md → reference/obsidian-contract.md} +29 -4
  53. package/docs/{obsidian-plugin.md → reference/obsidian-plugin.md} +218 -68
  54. package/docs/{practical-judgment.md → reference/phronesis.md} +12 -3
  55. package/docs/reference/portability.md +92 -0
  56. package/docs/{portable-presentation.md → reference/portable-presentation.md} +2 -2
  57. package/docs/{presentation-consumer-boundaries.md → reference/presentation-consumer-boundaries.md} +5 -0
  58. package/docs/{project-options.md → reference/project-options.md} +17 -0
  59. package/docs/{repo-boundary-guard.md → reference/repo-boundary-guard.md} +50 -4
  60. package/docs/{review-portability.md → reference/review-portability.md} +4 -0
  61. package/docs/{semantic-operation.md → reference/semantic-operation.md} +4 -3
  62. package/docs/{template-profile.md → reference/template-profile.md} +1 -1
  63. package/docs/{trackables.md → reference/trackables.md} +2 -2
  64. package/docs/start/independent-start.md +89 -0
  65. package/docs/{install.md → start/install.md} +9 -9
  66. package/docs/{knowledge-setup.md → start/knowledge-setup.md} +5 -5
  67. package/docs/{research-starter-quickstart.md → start/research-starter-quickstart.md} +6 -6
  68. package/fixtures/atelier-architecture/catalog/invalid/document.json +2 -2
  69. package/fixtures/atelier-architecture/catalog/valid/document.json +2 -2
  70. package/fixtures/graph-view/synthetic-authorized-input.v1.json +437 -0
  71. package/fixtures/graph-view/synthetic-view.v1.json +1 -0
  72. package/fixtures/knowledge-health/workshop/README.md +1 -1
  73. package/fixtures/templates/local-library.v1.json +3 -1
  74. package/package.json +22 -8
  75. package/plugins/obsidian/main.js +57 -8
  76. package/plugins/obsidian/manifest.json +1 -1
  77. package/skills/claude/atelier-guided-upgrade/SKILL.md +1 -1
  78. package/skills/claude/atelier-knowledge-setup/SKILL.md +2 -2
  79. package/skills/claude/atelier-local-service/SKILL.md +1 -1
  80. package/skills/claude/atelier-practical-judgment/SKILL.md +7 -2
  81. package/skills/claude/atelier-skill-steward/SKILL.md +1 -1
  82. package/skills/claude/mnstry-readiness/SKILL.md +2 -2
  83. package/skills/codex/atelier-guided-upgrade/SKILL.md +1 -1
  84. package/skills/codex/atelier-knowledge-setup/SKILL.md +2 -2
  85. package/skills/codex/atelier-local-service/SKILL.md +1 -1
  86. package/skills/codex/atelier-practical-judgment/SKILL.md +7 -2
  87. package/skills/codex/atelier-skill-steward/SKILL.md +1 -1
  88. package/skills/codex/mnstry-readiness/SKILL.md +2 -2
  89. package/src/architecture/catalog.mjs +2 -2
  90. package/src/attestation/sign.mjs +1 -1
  91. package/src/boundary/content-rules.mjs +534 -68
  92. package/src/boundary/policy.mjs +187 -60
  93. package/src/cli/run.mjs +26 -9
  94. package/src/commands/distribution.mjs +1 -1
  95. package/src/commands/extension.mjs +27 -0
  96. package/src/commands/ingest.mjs +1 -1
  97. package/src/commands/learn.mjs +1 -1
  98. package/src/commands/obsidian.mjs +49 -21
  99. package/src/commands/review.mjs +3 -0
  100. package/src/edge-policy/contract.mjs +69 -0
  101. package/src/edge-policy/policy.mjs +142 -0
  102. package/src/edge-policy/source-citation-adapter.mjs +43 -0
  103. package/src/extension-packs/loader.mjs +4 -3
  104. package/src/extensions/admission.mjs +83 -0
  105. package/src/extensions/client.d.mts +23 -0
  106. package/src/extensions/client.mjs +99 -0
  107. package/src/extensions/contracts.d.mts +29 -0
  108. package/src/extensions/contracts.mjs +163 -0
  109. package/src/extensions/convex.d.mts +24 -0
  110. package/src/extensions/convex.mjs +189 -0
  111. package/src/extensions/delegation.mjs +54 -0
  112. package/src/extensions/drizzle.d.mts +11 -0
  113. package/src/extensions/drizzle.mjs +33 -0
  114. package/src/extensions/postgres.d.mts +7 -0
  115. package/src/extensions/postgres.mjs +66 -0
  116. package/src/extensions/prisma.d.mts +13 -0
  117. package/src/extensions/prisma.mjs +63 -0
  118. package/src/extensions/project.mjs +432 -0
  119. package/src/extensions/scaffold-convex.mjs +130 -0
  120. package/src/extensions/scaffold-sql.mjs +144 -0
  121. package/src/extensions/server.d.mts +20 -0
  122. package/src/extensions/server.mjs +102 -0
  123. package/src/extensions/sql-scope.mjs +22 -0
  124. package/src/graph/graph.mjs +13 -5
  125. package/src/graph/knowledge-graph.mjs +20 -2
  126. package/src/graph/repo-contribution.mjs +405 -0
  127. package/src/graph/repo-contribution.types.mjs +32 -0
  128. package/src/graph-access/edge-decision-adapter.mjs +41 -0
  129. package/src/graph-access/edge-record-adapter.mjs +73 -0
  130. package/src/graph-access/index.mjs +253 -0
  131. package/src/graph-access/multi-contribution-record-adapter.mjs +101 -0
  132. package/src/graph-access/multi-record-projection-adapter.mjs +74 -0
  133. package/src/graph-access/source-adapter.mjs +81 -0
  134. package/src/graph-refresh/compare.mjs +110 -0
  135. package/src/graph-refresh/controller.mjs +324 -0
  136. package/src/graph-refresh/index.mjs +3 -0
  137. package/src/graph-refresh/local.mjs +78 -0
  138. package/src/graph-view/babylon/babylon-scene-port.mjs +64 -0
  139. package/src/graph-view/babylon/index.mjs +2 -0
  140. package/src/graph-view/babylon/navigator.mjs +312 -0
  141. package/src/graph-view/babylon/provenance.mjs +36 -0
  142. package/src/graph-view/babylon/scene-controller.mjs +131 -0
  143. package/src/graph-view/babylon/semantic-picking.mjs +35 -0
  144. package/src/graph-view/babylon/web-host.mjs +188 -0
  145. package/src/graph-view/cli.mjs +3 -0
  146. package/src/graph-view/command.mjs +34 -0
  147. package/src/graph-view/contracts.mjs +79 -0
  148. package/src/graph-view/errors.mjs +5 -0
  149. package/src/graph-view/index.mjs +4 -0
  150. package/src/graph-view/intents.mjs +30 -0
  151. package/src/graph-view/query.mjs +213 -0
  152. package/src/graph-view/refs.mjs +2 -0
  153. package/src/graph-view/structured/browser.mjs +262 -0
  154. package/src/graph-view/structured/styles.mjs +8 -0
  155. package/src/graph-workspace/authoring.mjs +185 -0
  156. package/src/graph-workspace/browser/constellation.mjs +369 -0
  157. package/src/graph-workspace/browser/dom.mjs +23 -0
  158. package/src/graph-workspace/browser/index.mjs +6 -0
  159. package/src/graph-workspace/browser/review.mjs +55 -0
  160. package/src/graph-workspace/browser/styles.mjs +11 -0
  161. package/src/graph-workspace/browser/workbench.mjs +351 -0
  162. package/src/graph-workspace/browser/workspace.mjs +431 -0
  163. package/src/graph-workspace/disclosure.mjs +39 -0
  164. package/src/graph-workspace/geometry.mjs +78 -0
  165. package/src/graph-workspace/index.mjs +11 -0
  166. package/src/graph-workspace/labels.mjs +50 -0
  167. package/src/graph-workspace/model.mjs +109 -0
  168. package/src/graph-workspace/recipes.mjs +88 -0
  169. package/src/graph-workspace/scale.mjs +95 -0
  170. package/src/graph-workspace/theme.mjs +156 -0
  171. package/src/ingestion/optional/graphify/graphify_supplier.py +5 -0
  172. package/src/ingestion/optional/graphify/native-closure-pins.json +275 -0
  173. package/src/ingestion/optional/graphify/native-pins.json +306 -0
  174. package/src/knowledge/ontology-guide.mjs +152 -0
  175. package/src/knowledge-health/proof/installed-check.mjs +2 -2
  176. package/src/knowledge-health/rule-identity.mjs +14 -9
  177. package/src/knowledge-health/workshop.mjs +22 -2
  178. package/src/personal-workspace/index.mjs +1 -1
  179. package/src/personal-workspace/selection.mjs +34 -3
  180. package/src/project/config.mjs +1 -1
  181. package/src/project/path-match.mjs +2 -1
  182. package/src/project/providers/github.mjs +24 -0
  183. package/src/project/repo-identity.mjs +3 -22
  184. package/src/projection/obsidian/contracts.mjs +1 -1
  185. package/src/projection/obsidian/materialize/path-registry.mjs +1 -1
  186. package/src/projection/obsidian/materialize/prepare-view.mjs +88 -6
  187. package/src/projection/obsidian/materialize/redaction.mjs +1 -1
  188. package/src/projection/obsidian/personal-workspace.mjs +84 -5
  189. package/src/projection/obsidian/plugin-bridge/channel.mjs +1 -1
  190. package/src/projection/obsidian/proposals/adapter.mjs +46 -23
  191. package/src/projection/obsidian/proposals/observation.mjs +40 -2
  192. package/src/projection/obsidian/publication/publisher.mjs +96 -8
  193. package/src/projection/project.mjs +21 -16
  194. package/src/readiness/readiness.mjs +20 -11
  195. package/src/readiness-protocols/bundled-pack.mjs +14 -766
  196. package/src/readiness-protocols/bundled-selection.mjs +32 -0
  197. package/src/readiness-protocols/evidence.mjs +2 -1
  198. package/src/readiness-protocols/packs/mnstry.mjs +766 -0
  199. package/src/readiness-protocols/runtime.mjs +43 -6
  200. package/src/runtime/obsidian/engine.mjs +387 -76
  201. package/src/runtime/obsidian/lifecycle.mjs +20 -15
  202. package/src/runtime/obsidian/login-item.mjs +6 -3
  203. package/src/runtime/obsidian/machine-settings.mjs +52 -8
  204. package/src/runtime/obsidian/observation.mjs +36 -4
  205. package/src/runtime/obsidian/opening.mjs +4 -3
  206. package/src/runtime/obsidian/personal-selection.mjs +187 -0
  207. package/src/runtime/obsidian/pipeline.mjs +115 -6
  208. package/src/runtime/obsidian/private-lock.mjs +50 -6
  209. package/src/runtime/obsidian/selection-continuity.mjs +124 -0
  210. package/src/runtime/obsidian/service-main.mjs +68 -16
  211. package/src/runtime/obsidian/service.mjs +80 -6
  212. package/src/runtime/obsidian/unheld-evidence.mjs +1 -1
  213. package/src/runtime/obsidian/view-permission.mjs +335 -0
  214. package/src/ui/control-system.mjs +201 -0
  215. package/src/ui/html-primitives.mjs +3 -0
  216. package/src/upgrade/closure-diagnostics.mjs +135 -0
  217. package/src/upgrade/closure.mjs +649 -0
  218. package/src/upgrade/upgrade.mjs +42 -11
  219. package/templates/distribution-workspace/README.md +2 -2
  220. package/templates/external-project-workspace/atelier.project.json +3 -0
  221. package/templates/external-project-workspace/pack-lifecycle.json +3 -1
  222. package/templates/knowledge-workspace/README.md +2 -2
  223. package/docs/integration-and-review-plan.md +0 -409
  224. package/docs/integration-contract-decisions.md +0 -44
  225. package/docs/knowledge-health/devday/facilitator.md +0 -16
  226. package/docs/knowledge-health/devday/public-default.md +0 -107
  227. package/docs/knowledge-health/devday/quickstart.md +0 -18
  228. package/docs/knowledge-health/devday/support.md +0 -22
  229. package/docs/knowledge-health/exercise.md +0 -17
  230. package/docs/knowledge-health/support.md +0 -34
  231. package/docs/presentation-review-disposition.md +0 -47
  232. package/docs/progressive-ingestion-acceptance.md +0 -89
  233. package/docs/progressive-ingestion.md +0 -235
  234. /package/docs/{exact-upgrades.md → guides/exact-upgrades.md} +0 -0
  235. /package/docs/{guided-upgrades.md → guides/guided-upgrades.md} +0 -0
  236. /package/docs/{local-review.md → guides/local-review.md} +0 -0
  237. /package/docs/{skill-steward.md → guides/skill-steward.md} +0 -0
  238. /package/docs/{template-upgrades.md → guides/template-upgrades.md} +0 -0
  239. /package/docs/{assurance-controls.md → maintainers/assurance-controls.md} +0 -0
  240. /package/docs/{attestation.md → reference/attestation.md} +0 -0
  241. /package/docs/{coauthor-session.md → reference/coauthor-session.md} +0 -0
  242. /package/docs/{continuity.md → reference/continuity.md} +0 -0
  243. /package/docs/{contract-stability.md → reference/contract-stability.md} +0 -0
  244. /package/docs/{decision-practices.md → reference/decision-practices.md} +0 -0
  245. /package/docs/{evidence-navigation.md → reference/evidence-navigation.md} +0 -0
  246. /package/docs/{extension-pack-lifecycle.md → reference/extension-pack-lifecycle.md} +0 -0
  247. /package/docs/{interaction-and-reflection.md → reference/interaction-and-reflection.md} +0 -0
  248. /package/docs/{local-state.md → reference/local-state.md} +0 -0
  249. /package/docs/{ontology.md → reference/ontology.md} +0 -0
  250. /package/docs/{optional-ingestion.md → reference/optional-ingestion.md} +0 -0
  251. /package/docs/{project-command-inventory.md → reference/project-command-inventory.md} +0 -0
  252. /package/docs/{semantic-ingestion.md → reference/semantic-ingestion.md} +0 -0
  253. /package/docs/{vault-service.md → reference/vault-service.md} +0 -0
@@ -1,8 +1,10 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
+ import { performance } from 'node:perf_hooks'
3
4
  import { AtelierDiagnosticError } from '../../project/config.mjs'
4
5
  import { OBSIDIAN_EXT_KEY, ObsidianContractRefusal, manifestLayoutVersion } from '../../projection/obsidian/contracts.mjs'
5
- import { PERSONAL_VALIDATION_PENDING, personalWorkspaceBindingOf, personalWorkspaceInputs, validatePersonalWorkspace } from '../../projection/obsidian/personal-workspace.mjs'
6
+ import { createCooperativeBudget } from '../../projection/obsidian/materialize/prepare-view.mjs'
7
+ import { PERSONAL_VALIDATION_PENDING, notePersonalSelectionNow, personalSelectionBehind, personalWorkspaceBindingOf, personalWorkspaceInputs, validatePersonalWorkspace } from '../../projection/obsidian/personal-workspace.mjs'
6
8
  import { PROTOCOL_ID } from '../../projection/obsidian/publication/bridge-script.mjs'
7
9
  import { PublicationRefusal, allocatedFolderState, hasCommittedGeneration, readVaultAllocation } from '../../projection/obsidian/recovery/store.mjs'
8
10
  import { canonicalJson, compareText, isoTime } from './documents.mjs'
@@ -10,11 +12,12 @@ import { readObsidianEnablement } from './enablement.mjs'
10
12
  import { ObsidianMaintenanceRefusal, refuse } from './errors.mjs'
11
13
  import { createMaintenanceExtensions } from './extension-points.mjs'
12
14
  import {
13
- assertOutsideRepositories, authorizeAutomaticApply, defaultMachineSettings, ensureWorkspaceIdentity, protectedRoots, readLocalPointer,
14
- readMachineSettings, resolveDataRoot, workspaceStateRoot, writeMachineSettings,
15
+ assertOutsideRepositories, authorizeAutomaticApply, ensureWorkspaceIdentity, initializeMachineSettings, protectedRoots, readLocalPointer,
16
+ readMachineSettings, resolveDataRoot, workspaceStateRoot,
15
17
  } from './machine-settings.mjs'
16
- import { configKey, listConfigFiles, listSourceFiles, listVaultNotes, readFileFacts, reconcile, sha256Digest, sourceKey, vaultKey } from './observation.mjs'
18
+ import { configKey, hasIndexedHint, listConfigFiles, listSourceFiles, listVaultNotes, readFileFacts, reconcile, reconcileCooperatively, sha256Digest, sourceKey, vaultKey } from './observation.mjs'
17
19
  import { dispatchAutomaticApply, heldPaths, layoutHeldPaths, observeVaultEdits, preserveInRecoveryStore, trustedNoteBases } from './pending-edits.mjs'
20
+ import { personalExclusionRootsNow } from './personal-selection.mjs'
18
21
  import { createProductionSeams, eligibilityFor } from './pipeline.mjs'
19
22
  import { PLUGIN_FILES_WAIT_FOR_APP } from './plugin-presence.mjs'
20
23
  import { ENGINE_LOCK_DIRECTORY, acquirePrivateGenerationLock, createAbandonmentProof } from './private-lock.mjs'
@@ -103,6 +106,15 @@ export const ENGINE_PRIMITIVES = Object.freeze({
103
106
  },
104
107
  // The embedded assets observed beside the walk: those the last built graph lets a view copy, and no withheld one.
105
108
  observedAssetsOf: (graph) => (graph.assets ?? []).filter((asset) => asset.eligible === true).map(({ repo, path: assetPath }) => ({ repo, path: assetPath })),
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.
110
+ noteSelectionAtGuard: notePersonalSelectionNow,
111
+ // A loaded project pinned to a selection different from the most recently accepted head in this process
112
+ // is loaded again at the next tick, whatever the files say: another reader in the process may have read a
113
+ // confirmation that is gone again before this engine looked (personalSelectionBehind).
114
+ reloadsBehindSelection: personalSelectionBehind,
115
+ // After a tick that failed without a typed reason, the next one looks at every view again, as the first tick of a
116
+ // process does: see tick().
117
+ rechecksEveryViewAfterFailure: true,
106
118
  // One engine per workspace at a time, across processes.
107
119
  acquireEngineLock: acquirePrivateGenerationLock,
108
120
  // The registered proposal adapter observes the open edits on every tick, so a structural edit is recorded as
@@ -126,9 +138,41 @@ export const ENGINE_PRIMITIVES = Object.freeze({
126
138
  heldNoteDigest({ file }) {
127
139
  try { return readFileFacts(file)?.digest ?? null } catch { return null }
128
140
  },
141
+ // Whether an error met by the check of a tick's settings, at a wait, is a failure to read them: a file-system error
142
+ // (no permission, an I/O error, no descriptor left), recognised as one is where a vault is placed. Anything else is a
143
+ // fault of the engine and stops the tick.
144
+ guardReadFailed: (error) => typeof error?.code === 'string' && /^E[A-Z]+$/.test(error.code),
145
+ // Whether an error met when the graph build, a view's preparation or the look again before its commit reads a
146
+ // source is a source, or a folder of sources, removed after this tick observed it: no such file (or a parent that is
147
+ // no longer a folder), at a path that is gone now and that this tick's walks over the sources (before the build, and
148
+ // the look again after it) recorded with a digest (a source of a repository the project observes), or a folder that
149
+ // held such a source. A source changed during the tick is refused as `mixed-read`; one removed is refused the same,
150
+ // and the next tick reads everything again. Anything else stops the tick, as before: a path still there, a path the
151
+ // walk listed and could not read (it was recorded without a digest), or one it never listed, so a path that can never
152
+ // be read still fails every tick.
153
+ sourceRemovedSinceObserved(error, { project, index }) {
154
+ if ((error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') || typeof error.path !== 'string' || !(index instanceof Map)) return false
155
+ const missing = path.resolve(error.path)
156
+ if (fs.existsSync(missing)) return false
157
+ for (const repo of project?.repos ?? []) {
158
+ if (repo?.external || typeof repo?.path !== 'string' || typeof repo?.name !== 'string') continue
159
+ const relative = path.relative(path.resolve(repo.path), missing)
160
+ if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) continue
161
+ const posix = relative.split(path.sep).join('/')
162
+ if (typeof index.get(sourceKey(repo.name, posix))?.digest === 'string') return true
163
+ const folder = sourceKey(repo.name, `${posix}/`)
164
+ for (const [key, entry] of index) if (key.startsWith(folder) && typeof entry?.digest === 'string') return true
165
+ }
166
+ return false
167
+ },
129
168
  })
130
169
 
131
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'
132
176
  // The code a view is refused with when its own vault cannot be placed: a typed refusal's, or `vault-location-unusable`
133
177
  // for a file-system error there (a file in the way, no permission, a read-only volume). Null for anything else, which
134
178
  // is a fault of the engine and stops the tick.
@@ -137,7 +181,7 @@ const digestOfJson = (value) => sha256Digest(Buffer.from(canonicalJson(value)))
137
181
  // The configuration a tick observes. A project bound to a personal workspace adds the person's authored manifest and
138
182
  // overlay and its generation's record: a change to any of them is a configuration change, so every view is prepared
139
183
  // again, asks the composition, and is refused at once when the generation no longer holds.
140
- const configFilesOf = (project) => [...listConfigFiles(project), ...personalWorkspaceInputs(project).map((absolute) => ({ key: configKey(absolute), changeClass: 'config', absolute }))]
184
+ export const configFilesOf = (project) => [...listConfigFiles(project), ...personalWorkspaceInputs(project).map((absolute) => ({ key: configKey(absolute), changeClass: 'config', absolute }))]
141
185
 
142
186
  // An editor adapter that `build` makes, synchronously or not, the first time the publisher calls it; a refusal of
143
187
  // `build` is thrown from that call, and `refusal()` answers it afterwards.
@@ -160,6 +204,9 @@ function uncoordinatedAdapter(refusal) {
160
204
  }
161
205
  // Views a tick may be asked to prepare again, at most, before it runs.
162
206
  const MAX_PREPARATION_REQUESTS = 64
207
+ // At most this many views a tick refuses for a file-system error are named with that error's code (`refusalCauses`); the
208
+ // others of that tick are not named.
209
+ const MAX_REFUSAL_CAUSES = 64
163
210
 
164
211
  function journalTime(journalId) {
165
212
  const match = /^journal-(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\d{3})-/.exec(journalId)
@@ -198,13 +245,32 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
198
245
  quietPeriodMs, lstat = fs.lstatSync, randomBytes, env = process.env, platform = process.platform,
199
246
  // A service names where it answers health, so a lock it leaves behind can be proven abandoned.
200
247
  lockOwner = null, lockProbe = probeHealth,
248
+ // Answers at once, without loading, what the workspace is resolved from (locatePersonalHome), for a loader that can
249
+ // refuse before any project was loaded: the views published before are then told why nothing is published now.
250
+ locateProject = null,
201
251
  } = options
202
252
  if (typeof loadProject !== 'function') throw new TypeError('the engine needs loadProject')
203
253
  if (typeof clock !== 'function') throw new TypeError('the engine needs an injected clock')
204
254
  // No default: the production adapter reaches a running app, and only the owner of the lifecycle may decide that.
205
255
  if (typeof adapterFactory !== 'function') throw new TypeError('the engine needs an adapterFactory')
206
- const seams = { ...createProductionSeams(), ...(options.seams ?? {}) }
256
+ const production = createProductionSeams()
257
+ const seams = { ...production, ...(options.seams ?? {}) }
258
+ // A caller that replaced prepareView and left the cooperative preparation as it ships prepares views its own way: its
259
+ // seam is used, synchronously, whether it handed in that one member or the production seams with that member replaced.
260
+ if (seams.prepareView !== production.prepareView && seams.prepareViewCooperatively === production.prepareViewCooperatively) seams.prepareViewCooperatively = null
261
+ // The same for the graph: a caller that replaced buildGraph and left the cooperative build as it ships builds its own way.
262
+ if (seams.buildGraph !== production.buildGraph && seams.buildGraphCooperatively === production.buildGraphCooperatively) seams.buildGraphCooperatively = null
207
263
  const rules = { ...ENGINE_PRIMITIVES, ...primitives }
264
+ // Optional local qualification telemetry. It has no authority to change a
265
+ // tick's result, and records elapsed boundaries rather than a budget claim.
266
+ const phaseTime = () => performance.timeOrigin + performance.now()
267
+ const reportPhase = (phase, startedMs) => {
268
+ try { seams.onPhase?.({ phase, startedMs, endedMs: phaseTime() }) } catch { /* telemetry never changes custody */ }
269
+ }
270
+ const measured = (phase, operation) => {
271
+ const startedMs = phaseTime()
272
+ try { return operation() } finally { reportPhase(phase, startedMs) }
273
+ }
208
274
 
209
275
  const index = new Map()
210
276
  const hintedKeys = new Set()
@@ -227,8 +293,9 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
227
293
  let firstTick = true
228
294
  let running = false
229
295
  let watching = { signature: null, handle: null }
230
- let known = null // { stateStore, maintenanceMode } once a workspace has been resolved
296
+ let known = null // { stateStore, maintenanceMode, repositoryRoots } once a workspace has been resolved
231
297
  let heldLock = null
298
+ let releaseOwed = false
232
299
  // Views somebody asked to have prepared and published once more, at the next tick.
233
300
  const preparationRequests = new Set()
234
301
  // The views the project declared at the last tick; null before the first.
@@ -249,19 +316,36 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
249
316
  try { return (await readUnheldEvidence({ vaultRoot: store.vaultRoot }))?.unlisted === true } catch { return false }
250
317
  }
251
318
 
252
- async function takeLock(workspaceRoot, workspaceId) {
319
+ async function takeLock(workspaceRoot, workspaceId, validateLocation = undefined) {
320
+ // A lock an earlier tick could not give back is still this process's, and its ticket on disk unreleased: a tick
321
+ // that tried to take it anew would find its own ticket and answer `busy` until the process ends. It is given back
322
+ // first. A release that fails again fails this tick, and is owed to the next.
323
+ if (heldLock && releaseOwed) {
324
+ heldLock.release()
325
+ heldLock = null
326
+ releaseOwed = false
327
+ }
253
328
  if (heldLock) return { acquired: true }
254
329
  const lock = await rules.acquireEngineLock({
255
330
  workspaceRoot, directory: path.join(workspaceRoot, ENGINE_LOCK_DIRECTORY), workspaceId, purpose: 'maintenance-engine', service: lockOwner, clock, proveAbandoned,
331
+ ...(validateLocation === undefined ? {} : { validateLocation }),
256
332
  })
257
333
  if (lock.acquired) heldLock = lock
258
334
  return lock
259
335
  }
260
336
 
337
+ // Gives the lock back at the end of a tick. One that cannot be given back (its released ticket could not be written)
338
+ // stays held, and the failure is the tick's: the next tick gives it back before anything else. One given back owes
339
+ // nothing more, whether this tick took it or an earlier one could not give it back.
261
340
  function dropLock() {
262
341
  const lock = heldLock
342
+ if (lock === null) return
343
+ try { lock.release() } catch (error) {
344
+ releaseOwed = true
345
+ throw error
346
+ }
263
347
  heldLock = null
264
- lock?.release()
348
+ releaseOwed = false
265
349
  }
266
350
 
267
351
  function onWatcherEvent(roots, { rootId, relative }) {
@@ -292,7 +376,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
292
376
  // A vault root that is gone (a folder under the data root the person removed, say) is looked for again too, so the
293
377
  // store made in its place says why rather than failing on the folder that is not there.
294
378
  if (cached?.signature === signature && fs.lstatSync(cached.store.vaultRoot, { throwIfNoEntry: false }) !== undefined) {
295
- 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 }
296
380
  }
297
381
  stores.delete(scope.scopeId)
298
382
  const store = seams.createRecoveryStore({ workspaceRoot, workspaceId, scopeId: scope.scopeId, repositoryRoots })
@@ -315,20 +399,67 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
315
399
  const blankEntry = (scopeId, now) => ({ scopeId, state: 'stale', reason: 'not-yet-published', generationId: null, preparedGenerationId: null, verified: false, heldNotes: [], changeClasses: [], retainedEdits: 0, checkedAt: now })
316
400
  const demote = (entry, state, reason, now) => ({ ...entry, state, reason, verified: false, checkedAt: now })
317
401
 
318
- // Persists "refused" or "disabled" over whatever is known, and only when a workspace already has state.
319
- 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 } }
320
443
  if (!known) {
444
+ let source = project
445
+ if (locateProject !== null) { try { source = locateProject() } catch { return } }
446
+ if (source === null || source === undefined) return
321
447
  let pointer = null
322
- try { pointer = project ? readLocalPointer(project) : null } catch { pointer = null }
448
+ try { pointer = readLocalPointer(source) } catch { pointer = null }
323
449
  if (!pointer) return
324
450
  let root
325
- try { root = workspaceStateRoot(resolveDataRoot({ dataRoot, pointer, project, 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
326
453
  const stateStore = createMaintenanceStateStore({ workspaceRoot: root, workspaceId: pointer.workspaceId })
327
454
  if (!stateStore.exists()) return
328
- 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 }
329
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') }
330
460
  // Another engine is ticking this workspace: its state is not ours to write.
331
- 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
332
463
  const previous = readPreviousFreshness(known.stateStore)
333
464
  const entries = new Map()
334
465
  for (const entry of previous?.scopes ?? []) entries.set(entry.scopeId, demote(entry, state, reason, now))
@@ -371,7 +502,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
371
502
  if (personalWorkspaceBindingOf(project) === null) project = await loadProject()
372
503
  } else {
373
504
  const config = reconcile({ index, files: configFilesOf(project), prefix: CONFIG_PREFIX, full, hinted, lstat })
374
- if (config.changes.length > 0) {
505
+ if (config.changes.length > 0 || rules.reloadsBehindSelection(project)) {
375
506
  project = await loadProject()
376
507
  reconcile({ index, files: configFilesOf(project), prefix: CONFIG_PREFIX, full: true, lstat })
377
508
  }
@@ -387,20 +518,50 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
387
518
  }
388
519
 
389
520
  // 2. Identity and machine settings, outside every repository.
390
- const pointer = ensureWorkspaceIdentity({ project, ...(randomBytes ? { randomBytes } : {}) })
391
- const { workspaceId } = pointer
392
- const requestedRoot = workspaceStateRoot(resolveDataRoot({ dataRoot, pointer, project, env, platform }), workspaceId)
393
521
  const repositoryRoots = protectedRoots(project)
394
- 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)
395
534
  fs.mkdirSync(requestedRoot, { recursive: true, mode: 0o700 })
396
535
  const workspaceRoot = fs.realpathSync(requestedRoot)
397
- const lock = await takeLock(workspaceRoot, workspaceId)
536
+ const lock = await takeLock(workspaceRoot, workspaceId, assertLocation)
398
537
  if (!lock.acquired) return { state: 'busy', reason: 'engine-lock-held', lock: { reason: lock.reason, holder: lock.holder ?? null }, changes: [], scopes: [], pendingEdits: [], dispatched: [], lateWriters: [] }
399
- const machine = readMachineSettings({ workspaceRoot, workspaceId })
400
- ?? writeMachineSettings({ workspaceRoot, workspaceId, repositoryRoots, settings: defaultMachineSettings({ workspaceId, updatedAt: now }) })
538
+ assertLocation()
539
+ const machine = initializeMachineSettings({ workspaceRoot, workspaceId, repositoryRoots, updatedAt: now, personal: personalWorkspaceBindingOf(project) !== null })
401
540
  const eligibility = fixedEligibility ?? eligibilityFor({ machine, project })
402
541
  const stateStore = createMaintenanceStateStore({ workspaceRoot, workspaceId })
403
- known = { stateStore, maintenanceMode: machine.maintenanceMode }
542
+ known = { stateStore, maintenanceMode: machine.maintenanceMode, repositoryRoots }
543
+ // A view refused for a file-system error (a vault folder that could not be allocated or placed, a configuration
544
+ // file that could not be read again, a source removed while it was read, candidates that could not be staged) is
545
+ // settled under the refusal's code alone, and such a tick ends without a failure. The error's code (`EACCES`, `EIO`,
546
+ // `ENOENT`), and that of a cleanup after it that failed too, are reported beside it for the service's log. Only
547
+ // codes shaped as a file-system error's are kept: never a message, which may name a path.
548
+ const refusalCauses = []
549
+ const errnoOf = (value) => (typeof value === 'string' && /^E[A-Z]+$/.test(value) ? value : null)
550
+ const noteRefusalCause = (scopeId, refusal) => {
551
+ const cause = errnoOf(refusal?.detail?.cause)
552
+ const cleanupCause = errnoOf(refusal?.detail?.cleanupCause)
553
+ if ((cause === null && cleanupCause === null) || refusalCauses.length >= MAX_REFUSAL_CAUSES) return
554
+ refusalCauses.push({ scopeId, code: refusal.code, ...(cause === null ? {} : { cause }), ...(cleanupCause === null ? {} : { cleanupCause }) })
555
+ }
556
+ // A view whose own vault cannot be placed: a typed refusal's code and detail, or `vault-location-unusable` for a
557
+ // file-system error there, whose code is its cause.
558
+ const refuseViewAlone = (scopeId, error) => {
559
+ if (isLocationRefusal(error)) throw error
560
+ const code = viewRefusalCode(error)
561
+ if (code === null) throw error
562
+ allocationRefusals.set(scopeId, code)
563
+ noteRefusalCause(scopeId, isTypedRefusal(error) ? error : { code, detail: { cause: error.code } })
564
+ }
404
565
  // Each view's vault: allocated now where this workspace decided its vaults live, when it has none and was never
405
566
  // published under the data root (vault-location.mjs). A view whose folder cannot be allocated is not prepared on
406
567
  // this tick, and its freshness says why; the other views go on.
@@ -428,15 +589,12 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
428
589
  const record = readVaultAllocation({ workspaceRoot, workspaceId, scopeId: scope.scopeId })
429
590
  if (record === null ? !hasCommittedGeneration({ workspaceRoot, scopeId: scope.scopeId }) : allocatedFolderState(record) === 'missing') {
430
591
  const known = await appList()
592
+ assertLocation()
431
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 })
432
594
  vaults = known.vaults
433
595
  }
434
596
  ensureVaultAllocation({ workspaceRoot, workspaceId, scopeId: scope.scopeId, location: machine.decisions.location, projectName: projectDisplayName(project), repositoryRoots, vaults, allocatedPaths, now })
435
- } catch (error) {
436
- const code = viewRefusalCode(error)
437
- if (code === null) throw error
438
- allocationRefusals.set(scope.scopeId, code)
439
- }
597
+ } catch (error) { refuseViewAlone(scope.scopeId, error) }
440
598
  }
441
599
  }
442
600
  // Each view's store, on its own: one whose vault cannot be placed (a record naming a folder inside a repository,
@@ -444,11 +602,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
444
602
  const scopes = []
445
603
  for (const scope of enablement.scopes) {
446
604
  if (allocationRefusals.has(scope.scopeId)) continue
447
- try { scopes.push({ scope, store: storeFor(scope, workspaceRoot, workspaceId, repositoryRoots) }) } catch (error) {
448
- const code = viewRefusalCode(error)
449
- if (code === null) throw error
450
- allocationRefusals.set(scope.scopeId, code)
451
- }
605
+ try { scopes.push({ scope, store: storeFor(scope, workspaceRoot, workspaceId, repositoryRoots) }) } catch (error) { refuseViewAlone(scope.scopeId, error) }
452
606
  }
453
607
 
454
608
  watch([
@@ -460,20 +614,38 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
460
614
 
461
615
  // 3. Vault notes: preserve, then queue. Compared with the bytes each note
462
616
  // was generated with, not with the previous look.
463
- const pending = stateStore.readPendingEdits()
464
- let edits = pending.edits
617
+ //
618
+ // Every view's notes are looked at first, and the walk waits between notes, so a large vault does not hold the
619
+ // listener for its whole length (at a full reconciliation every note is hashed, and after a publication the
620
+ // watcher has named each note it wrote). Nothing is checked at those waits and nothing is written: within
621
+ // Atelier only this engine commits a manifest or writes the pending edits, under the lock this tick holds, so the
622
+ // manifest read before the walk is the one after it. A note changed after its turn is the next tick's to see, by
623
+ // its stat hint or the watcher, as one changed just after a walk that ran through is. The pending edits are then
624
+ // read, compared and written in one stretch, as before; the bytes an edit keeps are read there, not at the walk.
465
625
  const basesOf = new Map()
466
626
  const earlierLayout = new Set()
467
- for (const { scope, store } of scopes) {
468
- const { manifest, bases } = trustedNoteBases(store)
469
- basesOf.set(scope.scopeId, bases)
470
- if (!manifest) continue
471
- if (manifestLayoutVersion(manifest) === 1) earlierLayout.add(scope.scopeId)
472
- const vault = reconcile({ index, files: listVaultNotes({ scopeId: scope.scopeId, vaultRoot: store.vaultRoot, manifest }), prefix: vaultKey(scope.scopeId, ''), full, hinted, lstat })
627
+ const looked = []
628
+ const vaultStartedMs = phaseTime()
629
+ try {
630
+ const budget = createCooperativeBudget()
631
+ for (const { scope, store } of scopes) {
632
+ const { manifest, bases } = trustedNoteBases(store)
633
+ basesOf.set(scope.scopeId, bases)
634
+ if (!manifest) continue
635
+ if (manifestLayoutVersion(manifest) === 1) earlierLayout.add(scope.scopeId)
636
+ const vault = await reconcileCooperatively({ index, files: listVaultNotes({ scopeId: scope.scopeId, vaultRoot: store.vaultRoot, manifest }), prefix: vaultKey(scope.scopeId, ''), full, hinted, lstat }, budget)
637
+ looked.push({ scope, store, manifest, bases, vault })
638
+ }
639
+ } finally { reportPhase('vault-observation', vaultStartedMs) }
640
+ assertLocation()
641
+ const pending = stateStore.readPendingEdits()
642
+ let edits = pending.edits
643
+ for (const { scope, store, manifest, bases, vault } of looked) {
473
644
  const digestOf = (notePath) => index.get(vaultKey(scope.scopeId, notePath))?.digest ?? null
474
645
  const observed = observeVaultEdits({ store, workspaceId, scopeId: scope.scopeId, manifest, bases, digestOf, edits, now, preserve: rules.preserve })
475
646
  edits = observed.edits
476
- // A note that has gone is restored by the publisher; its disappearance is a change, once.
647
+ // A note that has gone is a change, once: the view is prepared again and its read-back says the note is missing.
648
+ // The publisher writes it back when it next publishes a generation of the view.
477
649
  if (observed.events.length > 0 || vault.changes.some((change) => change.kind === 'removed')) changes.push({ changeClass: 'vault-note', scopeId: scope.scopeId })
478
650
  }
479
651
  stateStore.writePendingEdits({ ...pending, edits })
@@ -490,6 +662,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
490
662
  let observed = null
491
663
  if (proposalAdapter !== null && rules.observeEdits(proposalAdapter)) {
492
664
  try { observed = await proposalAdapter.observe(adapterContext(), { retryRefused: full }) } catch { observed = { adapterId: proposalAdapter.id, failed: 'proposal-adapter-threw' } }
665
+ assertLocation()
493
666
  }
494
667
 
495
668
  // 4. Automatic apply, through the registered operation only.
@@ -499,6 +672,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
499
672
  edits, applyOperation: extensions.applyOperation(), now, nowMs, retryIntervalMs,
500
673
  authorize: () => rules.authorize({ workspaceRoot, workspaceId }),
501
674
  })
675
+ assertLocation()
502
676
  edits = outcome.edits
503
677
  dispatched = outcome.dispatched
504
678
  stateStore.writePendingEdits({ ...pending, edits })
@@ -510,30 +684,41 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
510
684
  let proposals = null
511
685
  if (proposalAdapter !== null) {
512
686
  try { proposals = await proposalAdapter.propose(adapterContext()) } catch { proposals = { adapterId: proposalAdapter.id, failed: 'proposal-adapter-threw' } }
687
+ assertLocation()
513
688
  }
514
689
 
515
690
  // 5. Late writers. The publisher looks twice per publication; a holder can write later still.
516
691
  const lateWriters = []
517
692
  const lateDocument = stateStore.readLateWriters()
518
693
  const knownFindings = new Set(lateDocument.findings.map((item) => `${item.scopeId}\u0000${item.journalId}\u0000${item.unit}\u0000${item.observedDigest}`))
519
- for (const { scope, store } of scopes) {
520
- const journalIds = journalsToRecheck(store, { nowMs, full, windowMs: lateWriterWindowMs })
521
- if (journalIds.length === 0) continue
522
- for (const finding of seams.recheckDisplacedFiles({ store, journalIds, clock })) {
523
- if (finding.code !== 'late-writer-captured') continue
524
- const key = `${scope.scopeId}\u0000${finding.journalId}\u0000${finding.unit}\u0000${finding.observedDigest}`
525
- if (knownFindings.has(key)) continue
526
- knownFindings.add(key)
527
- lateWriters.push({ scopeId: scope.scopeId, journalId: finding.journalId, unit: finding.unit, notePath: finding.notePath ?? null, digestAtMove: finding.digestAtMove ?? null, observedDigest: finding.observedDigest, objectRef: finding.objectRef, detectedAt: now })
694
+ const lateStartedMs = phaseTime()
695
+ try {
696
+ for (const { scope, store } of scopes) {
697
+ const journalIds = journalsToRecheck(store, { nowMs, full, windowMs: lateWriterWindowMs })
698
+ if (journalIds.length === 0) continue
699
+ for (const finding of seams.recheckDisplacedFiles({ store, journalIds, clock })) {
700
+ if (finding.code !== 'late-writer-captured') continue
701
+ const key = `${scope.scopeId}\u0000${finding.journalId}\u0000${finding.unit}\u0000${finding.observedDigest}`
702
+ if (knownFindings.has(key)) continue
703
+ knownFindings.add(key)
704
+ lateWriters.push({ scopeId: scope.scopeId, journalId: finding.journalId, unit: finding.unit, notePath: finding.notePath ?? null, digestAtMove: finding.digestAtMove ?? null, observedDigest: finding.observedDigest, objectRef: finding.objectRef, detectedAt: now })
705
+ }
528
706
  }
529
- }
707
+ } finally { reportPhase('late-writer-recheck', lateStartedMs) }
530
708
  if (lateWriters.length > 0) {
531
709
  const findings = [...lateDocument.findings, ...lateWriters].sort((left, right) => compareText(left.detectedAt, right.detectedAt) || compareText(left.journalId, right.journalId) || left.unit - right.unit || compareText(left.observedDigest, right.observedDigest))
532
710
  stateStore.writeLateWriters({ schema: LATE_WRITERS_SCHEMA, workspaceId, findings })
533
711
  }
534
712
 
535
- // 6. Sources, settings, scopes, eligibility.
536
- const sources = reconcile({ index, files: listSourceFiles(project, { assets: observedAssets }), prefix: SOURCE_PREFIX, full, hinted, lstat })
713
+ // 6. Sources, settings, scopes, eligibility. The walk over the sources waits between files, as the walk over the
714
+ // vault does; at a full reconciliation it hashes every one. A source changed after its turn is the next tick's to
715
+ // see, by its stat hint or the watcher, or at the latest by the next full reconciliation. When this tick builds the
716
+ // graph, the look again at the sources after the build (`graph-source-recheck`) refuses such a change as
717
+ // `mixed-read`.
718
+ const sourcesStartedMs = phaseTime()
719
+ let sources
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()
537
722
  for (const change of sources.changes) changes.push({ changeClass: change.changeClass })
538
723
  const { scopes: _declared, ...settingsWithoutScopes } = enablement.settings
539
724
  // What the configuration says apart from this integration's own member: a change to the member alone is an
@@ -594,42 +779,135 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
594
779
  entries.set(scopeId, demote(previous?.scopes.find((entry) => entry.scopeId === scopeId && entry.state !== 'disabled') ?? blankEntry(scopeId, now), 'stale', code, now))
595
780
  }
596
781
  for (const entry of previous?.scopes ?? []) if (!entries.has(entry.scopeId)) entries.set(entry.scopeId, demote(entry, 'disabled', 'scope-not-configured', now))
597
- 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 })) }
598
785
  persist()
599
786
 
600
787
  // 8. Rebuild and publish.
601
788
  if (attempt.size > 0) {
789
+ // Cooperative preparation/publication can outlive the settings that
790
+ // selected this tick. Reuse the existing configuration/personal-input
791
+ // observation keys, machine settings and eligibility revision as the
792
+ // fence; do not invent another selection authority.
793
+ const bindings = configFilesOf(project).map(file => ({ ...file, digest: index.get(file.key)?.digest ?? null }))
794
+ const machineDigest = digestOfJson(machine)
795
+ const eligibilityRevision = String(eligibility.revision())
796
+ // The history's end is read once for a run of stopped views, however many views one change stops; a check that
797
+ // passes ends the run, so a change that stops a later view is read again.
798
+ let selectionNoted = false
799
+ const noteSelectionOnce = () => { if (!selectionNoted) { selectionNoted = true; rules.noteSelectionAtGuard(project) } }
800
+ const assertBindings = () => {
801
+ // Every cooperative wait of preparation and publication resumes here before it writes again.
802
+ assertLocation()
803
+ let changed
804
+ try {
805
+ changed = bindings.some(file => (readFileFacts(file.absolute)?.digest ?? null) !== file.digest)
806
+ || digestOfJson(readMachineSettings({ workspaceRoot, workspaceId })) !== machineDigest
807
+ || String(eligibility.revision()) !== eligibilityRevision
808
+ } catch (error) {
809
+ // A file that cannot be read now is not known to hold what this tick observed. The view is refused as one
810
+ // whose settings changed, and the next tick reads everything again: what an unreadable configuration file
811
+ // means is decided there, by observation and the project loader, as before.
812
+ if (!rules.guardReadFailed(error)) throw error
813
+ forceFull = true
814
+ // The file may be a selection record that cannot be read now (no permission, an I/O error, a file replaced while
815
+ // it was read): the history's end is read for the note.
816
+ noteSelectionOnce()
817
+ refuse('mixed-read', 'a file that decides source selection could not be read again while a view was being prepared or published', { cause: error.code })
818
+ }
819
+ if (changed) {
820
+ forceFull = true
821
+ // The change may be a confirmation in a personal home. The guard compares files and reads no history, so the
822
+ // history's end is read here for the note: a record that stopped this view counts from now on, also when it
823
+ // is gone again before the next tick looks.
824
+ noteSelectionOnce()
825
+ refuse('mixed-read', 'source selection or eligibility changed while a view was being prepared or published')
826
+ }
827
+ selectionNoted = false
828
+ }
602
829
  let built = null
830
+ // Whether a selected source of a prepared view moved since this tick observed it, decided as observation
831
+ // decides (reconcile): a source that still has the stat hint it was indexed with is taken as unchanged, and one
832
+ // whose hint moved is read and must still be the pinned bytes. Bytes that drift under an unchanged hint stay
833
+ // within observation's own bound, as they do for the graph and for a reused note: the next full reconciliation
834
+ // sees them. It waits between sources, and the settings that selected the view are checked at each wait.
835
+ const assertSourcesUnmoved = async (prepared) => {
836
+ const startedMs = phaseTime()
837
+ try {
838
+ const budget = createCooperativeBudget({ guard: assertBindings })
839
+ budget.check()
840
+ const roots = new Map((project.repos ?? []).filter((repo) => !repo.external && typeof repo.path === 'string').map((repo) => [repo.name, repo.path]))
841
+ const selected = new Set(prepared.manifest.notes.map((note) => note.nodeId))
842
+ const attachments = new Set(prepared.manifest.attachments.map((item) => {
843
+ const source = item.ext?.[OBSIDIAN_EXT_KEY]
844
+ return source?.kind === 'embedded-asset' ? sourceKey(source.repoId, source.assetPath) : null
845
+ }))
846
+ const nodes = built.snapshot.graph.nodes.filter((node) => selected.has(node.id))
847
+ const assets = (built.snapshot.graph.assets ?? []).filter((asset) => attachments.has(sourceKey(asset.repo, asset.path)))
848
+ const pins = new Map(built.snapshot.document.repositories.flatMap((repo) => repo.files.map((file) => [sourceKey(repo.repoId, file.path), file])))
849
+ for (const source of [...nodes, ...assets]) {
850
+ await budget.checkpoint('commit-source-recheck')
851
+ const key = sourceKey(source.repo, source.path)
852
+ const root = roots.get(source.repo)
853
+ if (root !== undefined && hasIndexedHint(index.get(key), path.join(root, ...source.path.split('/')), lstat)) continue
854
+ const bytes = built.snapshot.readSource(source.repo, source.path)
855
+ const pin = pins.get(key)
856
+ if (!pin || !Buffer.isBuffer(bytes) || bytes.length !== pin.byteLength || sha256Digest(bytes) !== pin.rawDigest) refuse('mixed-read', 'a selected source changed while its view was being prepared or published; its generation is not committed')
857
+ }
858
+ budget.check()
859
+ } finally { reportPhase('commit-source-recheck', startedMs) }
860
+ }
603
861
  try {
604
862
  if (personalRefusal !== null) refuse(personalRefusal, 'the personal workspace refused this generation; nothing is prepared from it', { source: 'personal-workspace' })
605
863
  // A personal workspace whose validity key is not confirmed yet is composed off the event loop, then built again.
606
864
  const build = () => seams.buildGraph({ project, eligibility, cache: graphCache, index, deferPersonalValidation: true })
607
865
  let graph
608
- try { graph = build() } catch (error) {
609
- if (error?.code !== PERSONAL_VALIDATION_PENDING) throw error
610
- const verdict = await validatePersonalWorkspace(project)
611
- if (verdict?.ok !== true) refuse(verdict?.code ?? 'personal-binding-unrecognized', 'the personal workspace refused this generation; nothing is prepared from it', { source: 'personal-workspace' })
612
- try { graph = build() } catch (again) {
613
- if (again?.code !== PERSONAL_VALIDATION_PENDING) throw again
614
- refuse('personal-graph-unconfirmed', 'the graph built for the personal workspace is not the one its composition built; nothing is prepared from it', { source: 'personal-workspace' })
866
+ try {
867
+ // Any other project is built with the event loop given a turn between the Markdown sources of the census: a
868
+ // first tick reads every source. The settings that select the views are checked at each wait, as during a
869
+ // preparation, and the look again at the sources after the build sees a source that moved during it.
870
+ if (typeof seams.buildGraphCooperatively === 'function' && personalWorkspaceBindingOf(project) === null) {
871
+ graph = await seams.buildGraphCooperatively({ project, eligibility, cache: graphCache, index, scheduling: { guard: assertBindings } })
872
+ } else try { graph = build() } catch (error) {
873
+ if (error?.code !== PERSONAL_VALIDATION_PENDING) throw error
874
+ const verdict = await validatePersonalWorkspace(project)
875
+ if (verdict?.ok !== true) refuse(verdict?.code ?? 'personal-binding-unrecognized', 'the personal workspace refused this generation; nothing is prepared from it', { source: 'personal-workspace' })
876
+ try { graph = build() } catch (again) {
877
+ if (again?.code !== PERSONAL_VALIDATION_PENDING) throw again
878
+ refuse('personal-graph-unconfirmed', 'the graph built for the personal workspace is not the one its composition built; nothing is prepared from it', { source: 'personal-workspace' })
879
+ }
615
880
  }
881
+ } catch (error) {
882
+ if (!isTypedRefusal(error) && rules.sourceRemovedSinceObserved(error, { project, index })) refuse('mixed-read', 'a source was removed while the canonical graph was being built', { cause: error.code })
883
+ throw error
616
884
  }
617
885
  // The assets this graph lets a view copy are observed from now on, and hashed now so the snapshot pins
618
886
  // what observation saw. A withheld asset is not observed: its bytes can change no view.
619
887
  const formerAssetKeys = new Set(observedAssets.map((asset) => sourceKey(asset.repo, asset.path)))
620
888
  observedAssets = rules.observedAssetsOf(graph)
621
889
  const assetKeys = new Set(observedAssets.map((asset) => sourceKey(asset.repo, asset.path)))
622
- const settled = reconcile({ index, files: listSourceFiles(project, { assets: observedAssets }), prefix: SOURCE_PREFIX, full: false, lstat })
890
+ const settled = measured('graph-source-recheck', () => reconcile({ index, files: listSourceFiles(project, { assets: observedAssets }), prefix: SOURCE_PREFIX, full: false, lstat }))
623
891
  // An asset newly observed, or one no longer embedded, is the list changing. Anything else that moved since this tick looked at the sources moved while the graph was being read.
624
892
  if (settled.changes.some((change) => !(change.kind === 'added' && assetKeys.has(change.key)) && !(change.kind === 'removed' && formerAssetKeys.has(change.key) && !assetKeys.has(change.key)))) throw new ObsidianMaintenanceRefusal('mixed-read', 'a source changed while the canonical graph was being built')
625
893
  const configDigest = digestOfJson([...index].filter(([key]) => key.startsWith(CONFIG_PREFIX)).map(([key, entry]) => [path.basename(key.slice(CONFIG_PREFIX.length)), entry.digest]))
894
+ const snapshot = seams.captureSnapshot({ project, graph, workspaceId, index, configDigest, capturedAt: now })
895
+ const observedProject = project
626
896
  built = {
627
- snapshot: seams.captureSnapshot({ project, graph, workspaceId, index, configDigest, capturedAt: now }),
897
+ // A view's preparation, and the look again at its sources before the commit, read a source through the
898
+ // snapshot. A selected source removed after this tick observed it, read there, refuses the view as
899
+ // `mixed-read`, as a removal during the build does; the other views of the tick are still prepared.
900
+ snapshot: { ...snapshot, readSource: (repoId, relative) => {
901
+ try { return snapshot.readSource(repoId, relative) } catch (error) {
902
+ if (!isTypedRefusal(error) && rules.sourceRemovedSinceObserved(error, { project: observedProject, index })) refuse('mixed-read', 'a selected source was removed while its view was being prepared or published', { cause: error.code })
903
+ throw error
904
+ }
905
+ } },
628
906
  profile: seams.profileFor({ project, workspaceId, audienceAllow: machine.audienceAllow }),
629
907
  }
630
908
  } catch (error) {
631
- if (!isTypedRefusal(error)) throw error
632
- for (const scopeId of attempt.keys()) entries.set(scopeId, demote(entries.get(scopeId), 'stale', error.code, now))
909
+ if (!isTypedRefusal(error) || isLocationRefusal(error)) throw error
910
+ for (const scopeId of attempt.keys()) { entries.set(scopeId, demote(entries.get(scopeId), 'stale', error.code, now)); noteRefusalCause(scopeId, error) }
633
911
  if (REREAD_CODES.has(error.code)) forceFull = true
634
912
  // The personal workspace refused the bound generation: the project is loaded again at the next tick, so a
635
913
  // loader that now binds another generation is followed.
@@ -638,7 +916,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
638
916
  for (const { scope, store } of built ? scopes.filter((item) => attempt.has(item.scope.scopeId)) : []) {
639
917
  const { scopeId } = scope
640
918
  const entry = entries.get(scopeId)
641
- // 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).
642
920
  let diagnostics = []
643
921
  const settle = (state, reason, extra = {}) => {
644
922
  const { diagnostics: _earlier, ...rest } = { ...entry, ...extra, state, reason, verified: extra.verified === true, checkedAt: now }
@@ -646,11 +924,17 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
646
924
  }
647
925
  try {
648
926
  const held = heldPaths(edits, scopeId)
649
- const prepared = seams.prepareView({
927
+ const prepared = await (seams.prepareViewCooperatively ?? seams.prepareView)({
650
928
  snapshot: built.snapshot, profile: built.profile, scope, persistentPathRegistry: stateStore.readPathRegistry(), priorManifest: store.readCurrentManifest(),
651
929
  existingSettings: null, clock, vaultRootBytes: Buffer.byteLength(store.vaultRoot, 'utf8'), cache: preparationCacheFor(scopeId),
652
930
  heldNotePaths: held, layoutHeldNotePaths: layoutHeldOf(scopeId), viewScopeIds: scopes.map((item) => item.scope.scopeId),
931
+ // The cooperative preparation would read every selected source once more before it returns. The engine
932
+ // checks them itself, below and again before the commit, without opening a source observation would not.
933
+ scheduling: { guard: assertBindings, recheckSources: false },
653
934
  })
935
+ assertBindings()
936
+ // Before anything is written: a source that moved while the view was being prepared refuses it here.
937
+ await assertSourcesUnmoved(prepared)
654
938
  stateStore.writePathRegistry(prepared.persistentPathRegistry)
655
939
  diagnostics = (prepared.manifest.ext?.[OBSIDIAN_EXT_KEY]?.diagnostics ?? []).slice(0, 100)
656
940
  const preparedGenerationId = prepared.manifest.generationId
@@ -684,10 +968,13 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
684
968
  }
685
969
  let result
686
970
  try {
971
+ // Once more while the publisher holds its locks, before it says the publication settled.
972
+ const beforeCommit = () => assertSourcesUnmoved(prepared)
687
973
  result = await seams.publishView({
688
974
  preparedView: prepared, protocolId: PROTOCOL_ID, expectedGeneration: trusted()?.generationId ?? null, recoveryStore: store, adapter, clock,
689
975
  ...(quietPeriodMs === undefined ? {} : { quietPeriodMs }),
690
976
  ...(typeof readUnheldEvidence === 'function' ? { unheldEvidence: readUnheldEvidence, platform } : {}),
977
+ scheduling: { guard: assertBindings }, beforeCommit,
691
978
  })
692
979
  // Stopped before its commit for want of an app that coordinates: the app's refusal is what the person has to
693
980
  // act on, and `open` answers it before it would add the vault to the app.
@@ -705,15 +992,24 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
705
992
  const common = { generationId: pointerNow?.generationId ?? null, preparedGenerationId, retainedEdits: pointerNow?.retained?.length ?? 0 }
706
993
  if (result.state === 'refused') {
707
994
  settle(CONFLICT_REFUSALS.has(result.refusal.code) ? 'publisher-conflict' : 'stale', result.refusal.code, { ...common, heldNotes: held })
995
+ noteRefusalCause(scopeId, result.refusal)
708
996
  } else if (result.state === 'updating') {
709
997
  const blocking = result.notes.filter((note) => note.blocking)
710
998
  const byEdit = blocking.length > 0 && blocking.every((note) => EDIT_OUTCOMES.has(note.outcome))
711
999
  settle(byEdit ? 'held-for-your-edit' : 'publisher-conflict', blocking[0]?.outcome ?? 'publication-incomplete', { ...common, heldNotes: [...new Set([...held, ...blocking.filter((note) => EDIT_OUTCOMES.has(note.outcome)).map((note) => note.path)])].sort(compareText) })
712
1000
  } else {
713
- // Read back: every note of the committed generation, by digest.
714
- const { manifest, bases } = trustedNoteBases(store)
1001
+ // Read back: every note of the committed generation, by digest. The walk waits between notes, so a large
1002
+ // view does not hold the service's listener for its whole length. Nothing is checked at those waits: the
1003
+ // generation is committed, the view is reported as it was read, and what changes from here on is the next
1004
+ // tick's to see, as it is after a read-back that ran through.
1005
+ const readBackStartedMs = phaseTime()
1006
+ let bases
1007
+ try {
1008
+ const committed = trustedNoteBases(store)
1009
+ bases = committed.bases
1010
+ await reconcileCooperatively({ index, files: listVaultNotes({ scopeId, vaultRoot: store.vaultRoot, manifest: committed.manifest }), prefix: vaultKey(scopeId, ''), full: true, lstat }, createCooperativeBudget())
1011
+ } finally { reportPhase('publication-read-back', readBackStartedMs) }
715
1012
  basesOf.set(scopeId, bases)
716
- reconcile({ index, files: listVaultNotes({ scopeId, vaultRoot: store.vaultRoot, manifest }), prefix: vaultKey(scopeId, ''), full: true, lstat })
717
1013
  const differing = [...bases].filter(([notePath, base]) => index.get(vaultKey(scopeId, notePath))?.digest !== base.digest).map(([notePath]) => notePath).sort(compareText)
718
1014
  const editedUnderPublisher = result.notes.some((note) => note.outcome === 'edit-kept' || note.changedAfterPublication === true)
719
1015
  if (held.length > 0) settle('held-for-your-edit', 'edit-pending', { ...common, heldNotes: held })
@@ -726,10 +1022,13 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
726
1022
  }
727
1023
  } catch (error) {
728
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
729
1027
  // A redaction refusal names the note and the rule it concerns, never the value.
730
1028
  const where = error.code === 'redaction-failure' ? error.detail ?? {} : {}
731
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 } : {}) }] : []
732
1030
  settle('stale', error.code)
1031
+ noteRefusalCause(scopeId, error)
733
1032
  if (REREAD_CODES.has(error.code)) forceFull = true
734
1033
  }
735
1034
  }
@@ -748,7 +1047,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
748
1047
  state: 'ticked', full, workspaceId, maintenanceMode: machine.maintenanceMode,
749
1048
  changes: changes.map((change) => ({ ...change })), scopes: [...entries.values()].sort((left, right) => compareText(left.scopeId, right.scopeId)),
750
1049
  pendingEdits: edits.filter((edit) => edit.closedAt === null).map(({ editId, scopeId, path: notePath, state }) => ({ editId, scopeId, path: notePath, state })),
751
- dispatched, lateWriters, ...(observed === null ? {} : { observed }), ...(proposals === null ? {} : { proposals }),
1050
+ dispatched, lateWriters, ...(observed === null ? {} : { observed }), ...(proposals === null ? {} : { proposals }), ...(refusalCauses.length === 0 ? {} : { refusalCauses }),
752
1051
  }
753
1052
  }
754
1053
 
@@ -770,9 +1069,21 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
770
1069
  try {
771
1070
  return await runTick()
772
1071
  } catch (error) {
773
- if (!isTypedRefusal(error)) throw error
1072
+ if (!isTypedRefusal(error)) {
1073
+ // A tick that failed without a typed reason may have looked at files and recorded what it saw in the index
1074
+ // without acting on it: a note gone from a vault, a source that changed, or a configuration file that changed
1075
+ // before the project could be loaded again, is a change once, and the next look would find nothing to change.
1076
+ // The next tick therefore starts as the first tick of a process does: it loads the project again and looks
1077
+ // at every view, so whatever is missing or changed makes its view stale, or is published, not left current.
1078
+ if (rules.rechecksEveryViewAfterFailure) {
1079
+ project = null
1080
+ semantic = null
1081
+ firstTick = true
1082
+ }
1083
+ throw error
1084
+ }
774
1085
  // Fail closed: nothing is published under a refusal, and every known view says why.
775
- 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 */ }
776
1087
  project = null
777
1088
  semantic = null
778
1089
  firstTick = true