@mnstry/atelier 0.2.0-alpha.11 → 0.2.0-alpha.12

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 (81) hide show
  1. package/CHANGELOG.md +344 -0
  2. package/README.md +10 -6
  3. package/contracts/atelier-obsidian-generation-manifest.v2.schema.json +372 -0
  4. package/contracts/compat-baseline.json +1 -1
  5. package/docs/assurance-controls.md +3 -2
  6. package/docs/install.md +3 -3
  7. package/docs/local-services.md +62 -11
  8. package/docs/obsidian-contract.md +669 -35
  9. package/docs/obsidian-plugin.md +585 -0
  10. package/docs/obsidian.md +233 -50
  11. package/docs/release-engineering.md +7 -1
  12. package/docs/upgrade.md +23 -3
  13. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/case-only-collision.v2.json +160 -0
  14. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/flat-note-path.v2.json +160 -0
  15. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/hidden-folder.v2.json +160 -0
  16. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/identity-in-the-body.v2.json +160 -0
  17. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/link-breaking-name.v2.json +160 -0
  18. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/missing-identity-region.v2.json +156 -0
  19. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/missing-layout-version.v2.json +159 -0
  20. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/note-named-like-a-folder.v2.json +160 -0
  21. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/unknown-region-kind.v2.json +160 -0
  22. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/v1-contract-version.v2.json +161 -0
  23. package/fixtures/obsidian/contracts/generation-manifest-v2/valid/three-notes.v2.json +160 -0
  24. package/fixtures/obsidian/edits/workspace.json +1 -1
  25. package/fixtures/obsidian/materialization/expected-full.json +21 -21
  26. package/fixtures/obsidian/materialization/expected-full.layout-1.json +99 -0
  27. package/fixtures/obsidian/materialization/expected-scoped.json +7 -7
  28. package/fixtures/obsidian/materialization/expected-scoped.layout-1.json +39 -0
  29. package/package.json +7 -3
  30. package/plugins/obsidian/main.js +444 -0
  31. package/plugins/obsidian/manifest.json +9 -0
  32. package/plugins/obsidian/styles.css +22 -0
  33. package/src/commands/obsidian.mjs +89 -10
  34. package/src/contracts/corpus.mjs +6 -0
  35. package/src/egress/forbidden-egress.mjs +2 -0
  36. package/src/project/private-state.mjs +10 -1
  37. package/src/projection/obsidian/contracts.mjs +224 -18
  38. package/src/projection/obsidian/edits/apply.mjs +12 -7
  39. package/src/projection/obsidian/edits/regions.mjs +137 -42
  40. package/src/projection/obsidian/materialize/index.mjs +6 -3
  41. package/src/projection/obsidian/materialize/path-registry.mjs +443 -57
  42. package/src/projection/obsidian/materialize/prepare-view.mjs +382 -131
  43. package/src/projection/obsidian/materialize/redaction.mjs +327 -0
  44. package/src/projection/obsidian/materialize/settings.mjs +113 -34
  45. package/src/projection/obsidian/plugin-bridge/bundle.mjs +50 -0
  46. package/src/projection/obsidian/plugin-bridge/channel.mjs +174 -0
  47. package/src/projection/obsidian/plugin-bridge/index.mjs +10 -0
  48. package/src/projection/obsidian/proposals/observation.mjs +10 -5
  49. package/src/projection/obsidian/publication/bridge-script.mjs +11 -5
  50. package/src/projection/obsidian/publication/index.mjs +2 -1
  51. package/src/projection/obsidian/publication/publisher.mjs +173 -26
  52. package/src/projection/obsidian/publication/transport.mjs +63 -8
  53. package/src/projection/obsidian/publication/vault-list.mjs +199 -0
  54. package/src/projection/obsidian/recovery/restart.mjs +3 -1
  55. package/src/projection/obsidian/recovery/store.mjs +11 -4
  56. package/src/projection/obsidian/selection-ui/focus.mjs +14 -7
  57. package/src/projection/obsidian/selection-ui/selection-state.mjs +2 -2
  58. package/src/projection/obsidian/selection-ui/selection.mjs +8 -3
  59. package/src/runtime/obsidian/app-capability.mjs +104 -12
  60. package/src/runtime/obsidian/app-production-seams.mjs +126 -39
  61. package/src/runtime/obsidian/app-registration.mjs +166 -0
  62. package/src/runtime/obsidian/engine.mjs +127 -11
  63. package/src/runtime/obsidian/extension-points.mjs +1 -1
  64. package/src/runtime/obsidian/index.mjs +5 -1
  65. package/src/runtime/obsidian/launch-plan.mjs +88 -0
  66. package/src/runtime/obsidian/lifecycle.mjs +105 -14
  67. package/src/runtime/obsidian/opening.mjs +227 -55
  68. package/src/runtime/obsidian/pending-edits.mjs +10 -0
  69. package/src/runtime/obsidian/plugin-channel.mjs +281 -0
  70. package/src/runtime/obsidian/plugin-choice.mjs +170 -0
  71. package/src/runtime/obsidian/plugin-drift.mjs +76 -0
  72. package/src/runtime/obsidian/plugin-presence.mjs +48 -0
  73. package/src/runtime/obsidian/service-main.mjs +18 -5
  74. package/src/runtime/obsidian/service-record.mjs +36 -0
  75. package/src/runtime/obsidian/service-server.mjs +50 -12
  76. package/src/runtime/obsidian/service.mjs +100 -6
  77. package/src/runtime/obsidian/state-store.mjs +13 -1
  78. package/src/server/local-sidecar.mjs +24 -5
  79. package/src/server/server.mjs +18 -5
  80. package/src/upgrade/upgrade.mjs +1 -1
  81. package/templates/external-project-workspace/pack-lifecycle.json +1 -1
@@ -10,16 +10,19 @@ here enables a feature. The publisher under
10
10
  ## Registered shapes
11
11
 
12
12
  Eleven closed v1 schemas, `contracts/atelier-obsidian-<shape>.v1.schema.json`,
13
+ and one v2 schema, `contracts/atelier-obsidian-generation-manifest.v2.schema.json`,
13
14
  are registered in `src/contracts/corpus.mjs` with valid and invalid fixtures
14
- under `fixtures/obsidian/contracts/<shape>/`. `src/projection/obsidian/contracts.mjs`
15
- validates them and adds the refusals a schema cannot express.
15
+ under `fixtures/obsidian/contracts/<shape>/` (the v2 manifest under
16
+ `generation-manifest-v2/`). `src/projection/obsidian/contracts.mjs` validates
17
+ them and adds the refusals a schema cannot express; a generation manifest is
18
+ validated against the version its `schema` names.
16
19
 
17
20
  | Shape | Portable | Purpose |
18
21
  | --- | --- | --- |
19
22
  | `corpus-profile` | yes | Workspace and repository identities, enrollment, audience |
20
23
  | `scope` | yes | Mode and selector set-AST; an empty selection is valid and empty |
21
24
  | `source-snapshot` | yes | Raw byte digests, graph pin, dirty state, single consistent read |
22
- | `generation-manifest` | yes | Path map, byte regions, link inversion map, completeness |
25
+ | `generation-manifest` | yes | Path map, byte regions, link inversion map, completeness. v1 is vault layout 1; v2 is layout 2 (see "Vault layout") |
23
26
  | `publication-journal` | no | Conditional operations, partial transition, restart; protocol ID required |
24
27
  | `service-state` | no | Literal loopback, port, runtime ID, PID, executable, consent |
25
28
  | `edit-operation` | yes | Object identity, origin generation, base digest, idempotency key |
@@ -46,15 +49,38 @@ refusals live in `fixtures/obsidian/contracts/oracles/scope-cases.json`.
46
49
 
47
50
  ### What redaction covers
48
51
 
49
- Emission is byte-faithful: a visible author's bytes are emitted unchanged. A
50
- visible author's own references to withheld or out-of-selection documents
51
- (canonical identities in front matter, repository-relative paths, link text)
52
- therefore appear in the vault exactly as written. Only generated bytes are
53
- redacted: generated regions, note paths, manifest entries and link inversions
54
- never carry a withheld node's identity, title, summary, tags or path. A scoped
55
- vault is not a confidentiality boundary against what visible authors wrote. A
56
- redaction boundary over authored bytes cannot coexist with byte-faithful
57
- emission; it would be a separate mode with its own evidence.
52
+ The guarantee: Atelier never generates a reference to a note outside the
53
+ view. No link, embed or relation row the emitter writes targets one, no
54
+ identity block names one, and no folder name or collision qualifier is
55
+ derived from one; manifest entries and link inversions carry nothing about
56
+ one either.
57
+
58
+ Text that comes from an author is carried as authored. Emission is
59
+ byte-faithful: a visible author's bytes are emitted unchanged, so a visible
60
+ author's own references to withheld or out-of-selection documents (canonical
61
+ identities in front matter, repository-relative paths, link text) appear in
62
+ the vault exactly as written. Generated regions repeat author text as well:
63
+ the titles of in-view notes in relation rows, and a wrapped file's title,
64
+ summary and tags in its note. That text is carried as its authors wrote it
65
+ too, escaped for Markdown or, in file names and link aliases, with the
66
+ characters those cannot hold turned into spaces. A scoped vault is not a
67
+ confidentiality boundary against what visible authors wrote (owner decision,
68
+ 2026-09-22). A redaction boundary over authored bytes cannot coexist with
69
+ byte-faithful emission; it would be a separate mode with its own evidence.
70
+ The deny-list below is defence in depth over the author text that generated
71
+ regions repeat, not the guarantee, and it follows the audience: a note outside
72
+ the selection that the view's audience may see is not withheld from the
73
+ vault's readers, so text naming it is reported, not refused (see "The
74
+ redaction guard").
75
+
76
+ A layout 2 vault shows, in its folder names, the repository identity and the
77
+ source directory chain of the notes in the view, and every note names its
78
+ repository and source path in its `atelier-repo` and `atelier-source`
79
+ properties; layout 1 showed neither. A folder exists only because a note of
80
+ the view, a file the view copies for an embed, or a file held for an open edit
81
+ is in it, though it may hold only some of that source directory's files. This
82
+ surface is acceptable for every vault, scoped ones included (owner decision,
83
+ 2026-09-24).
58
84
 
59
85
  Each note may carry the generated line `Relationships leading outside this
60
86
  view: N`. N counts that note's relationships to notes that are visible but not
@@ -62,15 +88,416 @@ selected, so it reveals that count, and two views of one workspace can be
62
88
  compared. It never counts a withheld node: edges to a withheld endpoint are
63
89
  dropped before the count is taken.
64
90
 
65
- ## Note paths
66
-
67
- `notes/<readable title>--<identity suffix>.md`. The suffix derives from the
68
- stable repository identity plus node identity, starts at 12 hexadecimal
69
- characters and lengthens on collision. Titles are Unicode-normalized, platform
70
- reserved names are avoided, and case-folding collisions are detected. Duplicate
71
- titles are allowed; duplicate canonical identities refuse; a title-only link
72
- that matches more than one visible note refuses. Paths are allocated once per
73
- workspace and reused by every view.
91
+ A collision qualifier is the qualified note's own source file stem, or a
92
+ short id of its own identity (see "Collisions"); a qualified folder carries a
93
+ short id of its own source folder or repository. A qualifier appears only
94
+ because something this vault shows took the name first: another note of the
95
+ view, an asset the view copies, or a file held for an open edit, which stays
96
+ in the vault while its edit is open, also after its note has left the view.
97
+ The one exception is fixed and names no note: a repository whose folder would
98
+ be `notes` or `attachments` spelled another way (see "Folders"). It stays while the
99
+ qualified note is in the view, also after the other has left, because a path
100
+ never moves while its note is in the view. A note of another view, a withheld
101
+ note, or one that was never in this view never causes a qualifier by itself:
102
+ each view allocates among its own notes and the files its vault holds.
103
+
104
+ ### The redaction guard
105
+
106
+ Before anything is returned, `prepareView` checks the whole result, every
107
+ note included, whether it was emitted on this call or reused from the
108
+ preparation cache. Any failure refuses the view with `redaction-failure`,
109
+ whose detail names the rule (`allow-list`, `identity-block` or `deny-list`)
110
+ and, where there is one, the note or file of this view it concerns: a path
111
+ allocated to this view (`notePath` or `filePath`), never the value that was
112
+ found and never a path in question. A deny-list refusal names the note whose
113
+ generated region holds the text, which for an incoming relation row is the
114
+ note the row is in, not the note whose title it repeats. An allow-list
115
+ refusal names the note by its allocated path, or the rule alone when the path
116
+ in question is the only one at hand (an attachment that is not the view's, a
117
+ manifest entry of no note of the view). The engine records the same in the
118
+ view's freshness entry, and `atelier obsidian status` shows it. Three rules,
119
+ in the vault layout the view is prepared in:
120
+
121
+ 1. Allow-list. Every path the emitter wrote must be a path allocated to this
122
+ view: every note and attachment path of the manifest, every rewritten link
123
+ or embed target (the bytes an inversion emitted must be such a path, as it
124
+ is or percent-encoded the way the emitter encodes it; the words an
125
+ author's wikilink keeps after `|` are the author's), every attachment an
126
+ embed names, and every `[[…]]` or `![[…]]` target in a generated region,
127
+ without its `#` fragment or `|` alias (relation rows, a wrapper's link and
128
+ embed of its file). The identity block of each note must name exactly that
129
+ note's own repository, identity and source path, and nothing else may
130
+ appear in it.
131
+ 2. Deny-list, defence in depth, following the audience. The free text of the
132
+ generated regions (the titles in relation rows, a wrapper's title, summary
133
+ and tags), the link targets excluded, is read as a reader sees it, with the
134
+ backslashes the emitter adds to escape Markdown removed, and compared in
135
+ NFC, against the identifiers of every census node and embedded asset
136
+ outside this view:
137
+ - Of a node or asset the view's audience may not see (withheld), an
138
+ unambiguous identifier refuses the view: an identity qualified by any
139
+ repository of the census (`<repository>:…`, whichever repository holds
140
+ the note), a repository-qualified source path (`<repository>/<path>`), a
141
+ repository-relative path that holds a folder (a `/`), or a vault path
142
+ recorded for it (see below). An identity that is a bare
143
+ word, and a file name at a repository's root (`README.md`, `index.md`),
144
+ are ambiguous and only reported: in-view text names such words and files
145
+ all the time.
146
+ - Of a node or asset the audience may see but this view does not hold (not
147
+ selected, or an asset the view does not copy), every identifier, a vault
148
+ path of it in another view included, is only reported: every view of a
149
+ workspace has one audience today, so such a note is not withheld from
150
+ anyone who reads this vault (decided 2026-09-24 under the owner decision
151
+ of 2026-09-22). So is a vault path of a note no longer in the census.
152
+ The rule follows the audience: a view with an audience of its own would
153
+ refuse for whatever that audience may not see, with no change to this
154
+ rule.
155
+ - A value that also identifies a note of this view names nothing outside it
156
+ and is neither.
157
+
158
+ What is reported is recorded as a diagnostic (see "Notes that were laid out
159
+ anyway"). A match is a whole token when no letter or digit is beside it,
160
+ and no one of `. _ : / -` joined to a letter or digit (a full stop that
161
+ ends a sentence is beside a whole token; `x.north-desk:a` is one longer
162
+ token); it is compared exactly, case included. The identity block, the one
163
+ place a note names its own repository-relative path, is held to rule 1.
164
+ 3. Coverage. Rules 1 and 2 run over every note of the view on every
165
+ preparation; a reused note is judged exactly as an emitted one.
166
+
167
+ What an author wrote inside the authored body or front matter of an in-view
168
+ note (a withheld node's canonical identity in a relation list, a link to its
169
+ path) is authored bytes: it is emitted as written and never triggers the
170
+ guard. When the same words reach generated prose unchanged, as an incoming
171
+ relation row repeats an in-view title or a wrapper repeats its summary, rule 2
172
+ refuses the view. Where they reach generated text only in a form made safe for
173
+ a name, the outgoing relation row's alias and every file name, `:` and `/`
174
+ have become spaces and runs of whitespace one space; that form is the
175
+ author's text carried as authored, and rule 2 does not match it: there is no
176
+ fuzzy matching (the residual of the 2026-09-22 decision above).
177
+
178
+ A refusing deny-list is itself a signal. Someone who can edit the text of an
179
+ in-view note and watch the view go stale learns that the words they wrote name
180
+ something the audience may not see; the refusal never says which note or
181
+ value. That existence oracle is open only to a person who can already edit
182
+ in-view text, and is accepted with the rule.
183
+
184
+ The vault paths the deny-list knows come from two places only: the sections of
185
+ the persistent registry, which hold each view's last allocation, this view's
186
+ included, and this view's prior generation. So after the registry is lost, a
187
+ vault path another view holds is not denied until that view is prepared again
188
+ and writes its section; within one tick, what a view denies of another view
189
+ depends on the order the views are prepared in; and a view still held in
190
+ layout 1 records its layout 2 allocation in its section, not the layout 1
191
+ paths its vault holds, which other views therefore do not deny. The
192
+ identities and source paths of the same notes are always denied.
193
+
194
+ Identities are what the graph records. A sidecar's identity is checked for its
195
+ form, but a Markdown note's `kg.id` may be any non-empty string, a bare word
196
+ included; Atelier's own documents use repository-qualified identities
197
+ (`<repository>:<name>`). An identity qualified by any repository of the census
198
+ is unambiguous, whichever repository holds its note. A bare word could be any
199
+ word of a title, and a root file name such as `README.md` any repository's, so
200
+ both are reported, never refused. The matcher is one Aho–Corasick automaton
201
+ over every value, with the token boundary tested at each hit: building it
202
+ takes time and memory linear in the total length of the values, on every
203
+ preparation (some tenths of a second and about 100 MB at 120,000 values), and
204
+ it then reads a text once, however many values there are.
205
+
206
+ In layout 1 the guard additionally refuses any `--<hex>` identity suffix of a
207
+ census identity outside the view, anywhere in generated text or emitted paths,
208
+ as the earlier release did. `test/obsidian-materialization.test.mjs` carries a
209
+ mutation control per rule: with that rule removed, a view that breaks it is
210
+ accepted.
211
+
212
+ ## Vault layout
213
+
214
+ A prepared view lays its files out in layout 2: the file name is the title a
215
+ person reads, the folders are the repository's own folders, and the identity a
216
+ machine needs is written into the note, not into its name. Obsidian shows a
217
+ file name as the note's title in the file explorer, tabs, the graph, search and
218
+ link completion, so the file name is the human title. The generation manifest
219
+ records the layout (`layoutVersion: 2`, in
220
+ `atelier-obsidian-generation-manifest/v2`) and keeps the path of every note and
221
+ attachment beside its identity, as before. Layout 1
222
+ (`notes/<readable title>--<identity suffix>.md`, flat, with attachments under
223
+ `attachments/`) is what releases up to 0.2.0-alpha.11 wrote; see "Upgrade from
224
+ layout 1".
225
+
226
+ ### Folders
227
+
228
+ A note lives at `<repository folder>/<source directory>/<file name>`.
229
+ `<source directory>` is the directory of its source relative to its repository
230
+ root, segment by segment, and `<repository folder>` is the repository's
231
+ identity (`repoId`); each segment is made safe as a name (below). Nothing is
232
+ stripped or shared across repositories: a vault of several repositories has
233
+ one top-level folder per repository. No part of a path depends on a property
234
+ of the whole census, such as a prefix every path shares, so adding or removing
235
+ a file never moves another.
236
+
237
+ Two folders whose names differ only in case or Unicode normalization are one
238
+ folder on a case-insensitive file system (APFS, NTFS), with one spelling. The
239
+ first allocation of a folder in a view fixes its spelling, and a later note
240
+ whose folder differs from it only that way takes the existing spelling: every
241
+ path of a vault spells each folder one way, so the app reports each note under
242
+ exactly the path the manifest records, which is how the publisher finds the
243
+ note's open editors. Two repositories of a view whose folder names would
244
+ collide that way are told apart: the one allocated later gets ` (<id>)`, the
245
+ first 6 hexadecimal characters of the SHA-256 of its identity. A source folder
246
+ whose mirrored folder would take the name of a file already allocated (a
247
+ source directory `docs/Guide.md/` after a note `docs/Guide.md`, or `a/Data/`
248
+ after a wrapped file `a/data`) is qualified the same way, with the id of the
249
+ source folder: `docs/Guide.md (1a2b3c)/`. So is a repository folder whose name
250
+ a file held for an edit already spells another way in its folder (a layout 1
251
+ file under `notes/` and a repository named `Notes`).
252
+
253
+ The folders `notes` and `attachments`, where layout 1 kept every note and file,
254
+ count as taken in every vault: an upgrade retires their files but leaves the
255
+ folders, and on a case-insensitive file system a repository folder named
256
+ `Notes` would otherwise be merged into `notes`, spelled as the disk has it and
257
+ not as the manifest does. A repository whose folder differs from either only
258
+ in case or normalization gets ` (<id>)`; a repository named exactly `notes` or
259
+ `attachments` uses the folder as it is.
260
+
261
+ ### File names
262
+
263
+ The name of a note is:
264
+
265
+ - for a Markdown source, its canonical title, the one the graph read: the
266
+ front-matter `title`, else the first H1, with the author's casing and
267
+ spacing. A title that ends in the source's own extension
268
+ (`07-tide-survey-brief.md`) loses that extension. When the graph's title
269
+ is exactly the title-cased file name, the graph's fallback, the title came
270
+ from the file name or equals it, and the file stem is used as written
271
+ (`getting-started`, not `Getting Started`). The rule reads the node record
272
+ only, so every allocation, and a selection resolved without the sources,
273
+ gives the same name. The record does not say where its title came from, so
274
+ an author heading that is exactly the title-cased file name (`# Getting
275
+ Started` in `getting-started.md`) names the note by its stem as well.
276
+ - for any other source, a wrapped file, the file name itself: the note is
277
+ `<file name>.md` (`depth-chart.pdf.md`) and the file keeps its own name
278
+ (`depth-chart.pdf`) in the same folder. The pairing is visible, a search
279
+ for the file finds both, and the file's own title (from its sidecar or
280
+ `<title>`) is the first heading of the note.
281
+
282
+ Every name and folder segment is made safe for macOS, Linux, Windows and
283
+ Obsidian links:
284
+
285
+ 1. It is NFC-normalized.
286
+ 2. Control characters (C0, C1, U+2028 and U+2029) and
287
+ `/ \ : * ? " < > | # ^ [ ]` become a space; bidirectional embedding,
288
+ override and isolate controls and U+FEFF are removed.
289
+ 3. Runs of spaces become one space, and leading and trailing spaces and dots
290
+ are removed: a leading dot hides a file from the app, and Windows allows no
291
+ trailing dot or space.
292
+ 4. It is cut to 150 bytes of UTF-8 (a folder segment to 120, a wrapped file's
293
+ stem so that its extension still fits), on a character boundary; a
294
+ combining character, zero-width joiner or variation selector left at the end by
295
+ the cut is removed.
296
+ 5. A Windows device name (`CON`, `PRN`, `AUX`, `NUL`, `COM0` to `COM9`,
297
+ `LPT0` to `LPT9`, `COM¹` to `COM³`, `LPT¹` to `LPT³`, `CONIN$`, `CONOUT$`),
298
+ compared case-insensitively on the part before the first dot, gets a `_`
299
+ in front.
300
+ 6. A name left empty is the file stem, made safe the same way, else
301
+ `Untitled`; a folder segment left empty is `_`.
302
+
303
+ A path longer than the file system allows (255 bytes a name, and
304
+ `maxFullPathBytes`, 1024 by default, with the view's own vault root) is
305
+ shortened by cutting the title part further, never below 16 bytes. A folder is
306
+ kept only where a name of 16 bytes and its extensions still fits in it,
307
+ whatever the title of the note at hand, so a folder's spelling depends on its
308
+ source directory alone. A folder chain that leaves less room than that keeps
309
+ as many leading folders as fit, and the last folder kept, cut, carries
310
+ ` (<id>)`, the first 6 hexadecimal characters of the SHA-256 of the
311
+ repository and the whole source directory: two deep directories under one
312
+ readable prefix stay two folders. When not even `<repository folder>/(<id>)`
313
+ leaves that room, the note sits directly in its repository's folder. Only a
314
+ note whose name does not fit even there, qualified if it collides, refuses
315
+ the view, with `path-too-long`: its repository's folder under the view's vault
316
+ root leaves no room for it.
317
+
318
+ ### Collisions
319
+
320
+ Names are compared the way a case-insensitive, normalization-insensitive file
321
+ system compares them (`collisionKey`: NFKC, case-folded), across every file of
322
+ the vault: notes, wrapped files and embedded assets. When two would share a
323
+ name in one folder, the one allocated later is qualified:
324
+
325
+ 1. `<name> (<source file stem>).md`, the stem made safe and cut to 60 bytes;
326
+ 2. when that is taken too, `<name> (<stem>) (<id>).md`, where `<id>` is the
327
+ first 6 hexadecimal characters of the SHA-256 of the repository identity
328
+ and the node identity, lengthened two at a time, up to 16, until it is
329
+ free.
330
+
331
+ A wrapped file's name is already its file name, so it is qualified by the
332
+ identity alone, before its extension: `depth-chart (1a2b3c).pdf` and
333
+ `depth-chart (1a2b3c).pdf.md`. An embedded asset is qualified the same way.
334
+ A name that collides with nothing carries no qualifier and no hash.
335
+
336
+ Each view allocates among its own notes only, in canonical identity order
337
+ (repository, then node), then its embedded assets, and an allocation that its
338
+ prior generation published is never changed while the note is in the view: a
339
+ newcomer that collides takes the qualifier. The same notes and prior
340
+ generation give the same paths in whatever order the census lists them.
341
+
342
+ ### Stability
343
+
344
+ A view's paths are seeded from what its prior generation published: a path,
345
+ once published, stays while its note is in the view. A retitled source keeps
346
+ its path: the note shows the new title in its heading and every relation row
347
+ that names it shows it too. Renaming on retitle arrives with the in-vault
348
+ plugin, which can use Obsidian's own rename so that the app's bookmarks, open
349
+ tabs, graph layout and links follow; a rename made by a program outside the
350
+ app would break all of them.
351
+
352
+ A note that leaves the view (deleted, withheld, no longer selected, or renamed
353
+ into a new identity, since a source without an identity of its own is
354
+ identified by its path) releases its path there, unless its file is held for
355
+ an edit: then the file stays in the vault and its name stays taken. A renamed
356
+ source therefore takes its name back instead of keeping a qualifier its old
357
+ name forced. A note that comes back is allocated again like a newcomer: it
358
+ gets its earlier path only when its title is unchanged and the names around it
359
+ are as they were, nothing having taken its name meanwhile and no note it
360
+ collided with having left. A published path that no longer fits the view's
361
+ vault root (a root that grew) is allocated again as well. One identity may
362
+ have different paths in different views: each view allocates among its own
363
+ notes, with its own vault root.
364
+
365
+ The persistent path registry keeps each view's last allocation in a section of
366
+ its own (`views.<scopeId>`); a selection resolved without the view's
367
+ generation reads it. The engine passes the views it maintains
368
+ (`viewScopeIds`), and the registry drops the section of any other view.
369
+ Preparation seeds from the prior generation itself, so a lost registry costs
370
+ no view its paths and is written again on the next preparation.
371
+
372
+ ### Notes that were laid out anyway
373
+
374
+ What is worth a person's look, but no reason to refuse a view, is recorded as a
375
+ diagnostic that names the note and never a value: in the generation manifest
376
+ (`ext["mnstry.atelier.obsidian"].diagnostics`, layout 2), in the view's
377
+ freshness entry (the first 100), and in `atelier obsidian status`. The manifest
378
+ holds those of the preparation that first committed its generation: a
379
+ preparation that yields the same generation (the same files) does not commit
380
+ it again. The freshness entry, and so `status`, holds those of the latest
381
+ preparation. Each is
382
+ `{ code, rule?, repoId, nodeId, notePath }` (an embedded asset has `assetPath`
383
+ and `filePath` instead of `nodeId` and `notePath`):
384
+
385
+ - `folder-shortened`: the folder chain was cut to fit (see "File names");
386
+ - `folder-qualified`: a folder met a file of the same name (see "Folders");
387
+ - `author-identity-properties`: the author's own front matter holds one of the
388
+ identity keys, which Properties shows instead of the generated block (see
389
+ "Identity in the note");
390
+ - `bare-identity-in-generated-text` (rule `deny-list`): generated text of the
391
+ note holds, as a whole token, the bare-word identity or the root file name
392
+ (`README.md`) of a note the audience may not see (see "The redaction
393
+ guard");
394
+ - `unselected-identity-in-generated-text` (rule `deny-list`): generated text of
395
+ the note holds an identifier of a note or asset the audience may see but the
396
+ view does not hold.
397
+
398
+ A view refused by the redaction guard records `{ code: "redaction-failure",
399
+ rule, notePath | filePath }` in its freshness entry the same way.
400
+
401
+ ### Identity in the note
402
+
403
+ Every note names its identity in three generated properties that Obsidian
404
+ shows (Properties, Bases, search) and any YAML reader or agent can read
405
+ without the manifest:
406
+
407
+ ```yaml
408
+ atelier-id: "north-desk:harbor-plan"
409
+ atelier-repo: "north-desk"
410
+ atelier-source: "plans/harbor-plan.md"
411
+ ```
412
+
413
+ `atelier-id` is the canonical node identity, `atelier-repo` the repository
414
+ identity and `atelier-source` the repository-relative source path, each a
415
+ JSON string. They are the last lines of the note's front matter, directly
416
+ before its closing delimiter, when the source's front matter is a plain block
417
+ mapping at column 0 that does not already use one of the three keys. A source
418
+ without front matter gets a generated front matter holding only them (after a
419
+ byte order prefix, which the app does not read). Any other front matter (a flow
420
+ mapping, an indented or sequence root, a document marker, a line that is not a
421
+ key) could change meaning with a line added, and one that already uses one of
422
+ the three keys would hold it twice, so for those sources the lines go into a
423
+ generated `%%` comment block at the very end of the note instead, the last
424
+ generated region. Keys are not otherwise compared: a front matter that repeats
425
+ a key of its own still takes the lines. The line ending is the source's. When
426
+ the author's own front matter holds one of the three keys, it is what
427
+ Properties shows, not the generated block; the note is reported as
428
+ `author-identity-properties` (see "Notes that were laid out anyway").
429
+
430
+ The source digest is not written into the note. Once a person's edit is
431
+ applied, the source has a new digest, and the hold on the edited note lifts
432
+ only when the note prepared from the new source is byte for byte the note the
433
+ person edited; a digest in the note would keep it held forever. The digest of
434
+ every generation is in the manifest
435
+ (`notes[].ext["mnstry.atelier.obsidian"].source.rawDigest`).
436
+
437
+ The block is generated. The manifest records its bytes as `regions.identity`,
438
+ inverting a note removes them, and the edit lens never attributes them to the
439
+ author. An edit to them is a front-matter edit, which is never applied to a
440
+ source: it becomes a semantic proposal (see "Proposal adapter for structural
441
+ edits"). In the end-of-note placement it is a generated-region edit and
442
+ refuses.
443
+
444
+ ### Links
445
+
446
+ A rewritten link or embed, a relation row and a wrapper's link to its file
447
+ name the target by its full vault path:
448
+ `[[north-desk/plans/Harbor plan.md|the plan]]`,
449
+ `[tide table](south-desk/tables/Tide%20table.md#spring)`,
450
+ `![[east-desk/pages/img/gauge.png|200]]`. A wikilink carries `.md`, so a note
451
+ named after a file never resolves to the file. Obsidian's resolver
452
+ (`MetadataCache.getLinkpathDest`, read in the 1.13.7 application bundle) looks
453
+ a path that contains a folder up as an exact vault path before it tries any
454
+ suffix or file-name match, so the target is exactly the allocated file,
455
+ whatever folder the linking note is in. A wikilink rewrite keeps the words the
456
+ author chose by appending `|<what the author wrote>`, as before.
457
+
458
+ ### Upgrade from layout 1
459
+
460
+ A view whose prior generation is in layout 1 is laid out again once, in one
461
+ generation:
462
+
463
+ 1. `prepareView` allocates layout 2 paths for every note and asset. The
464
+ layout 1 entries of the persistent registry are dropped, and the registry
465
+ records `layout: 2`. The generation is an
466
+ `atelier-obsidian-generation-manifest/v2`, and `changes.removed` names
467
+ every layout 1 path.
468
+ 2. The publisher retires each layout 1 path with its ordinary remove unit. A
469
+ note or file that still holds its published bytes is moved to the recovery
470
+ area with a receipt; a note somebody edited is never removed: it stays
471
+ where it is and is surfaced as a retained edit. Nothing is deleted.
472
+ 3. While a note of the view has an open edit, the view is not laid out again.
473
+ The engine passes as `heldNotePaths` the files of open edits, which stay in
474
+ the vault and keep their names taken in layout 2, and as
475
+ `layoutHeldNotePaths` those and the files whose edit closed on this tick.
476
+ `prepareView` prepares a view whose prior generation is in layout 1 and
477
+ holds one of the latter in layout 1, exactly as the earlier release did,
478
+ a new note included
479
+ (`notes/<title>--<suffix>.md`). A held note therefore stays under its
480
+ layout 1 path and is held, applied and withdrawn by the rules it was held
481
+ under. The engine lays a settled layout 1 view out again at the first tick
482
+ where no note holds it.
483
+ 4. An edit made on a layout 1 note can be applied after the upgrade: apply
484
+ and the proposal adapter prepare the generation the edit was made on in
485
+ that generation's own layout (`layout`) to recover the note as it was
486
+ published.
487
+
488
+ The folders `notes/` and `attachments/` are left behind empty: the publisher
489
+ moves files, never folders, and a person may delete them. While a view waits
490
+ for a held note, a selection resolved from the registry names layout 2 paths
491
+ that the vault does not hold yet.
492
+
493
+ An edit that closes on a tick still holds the layout on that tick. In
494
+ automatic mode a policy applies an edit on the very tick the edit is observed:
495
+ the view is prepared in layout 1 once more, from the new source, so the note
496
+ the person edited becomes the published note, and the next tick lays the view
497
+ out again and retires that file to recovery like any other. It is never left
498
+ in the vault unobserved. In manual mode the hold lifts when the observed note
499
+ equals the published one, one tick in layout 1 follows the same way, and the
500
+ view is then laid out again.
74
501
 
75
502
  ## Graph behaviour changes since the shared link resolver
76
503
 
@@ -124,9 +551,11 @@ In a view, an asset is copied only when the embedding note is in the vault
124
551
  set, the asset record carries `eligible: true` (`withEligibility` takes a
125
552
  second predicate; without it every asset is withheld) and the profile enrols
126
553
  its repository under the same audience rule as a node without an audience of
127
- its own. It is copied once, byte for byte, to
128
- `attachments/<readable stem>--<identity suffix>.<ext>`, read through the pinned
129
- snapshot (`source-not-in-snapshot`, `mixed-read`). The embed target is
554
+ its own. It is copied once, byte for byte, to its own path mirrored in the
555
+ vault (`<repository folder>/<source directory>/<file name>`, allocated like a
556
+ note; see "Vault layout"; in layout 1,
557
+ `attachments/<readable stem>--<identity suffix>.<ext>`), read through the
558
+ pinned snapshot (`source-not-in-snapshot`, `mixed-read`). The embed target is
130
559
  rewritten through the link inversion machinery: a Markdown embed to the
131
560
  percent-encoded vault-root path, a wikilink embed to the plain path with its
132
561
  size or fragment left as authored and no alias added. The manifest lists the
@@ -185,13 +614,18 @@ seen by the graph, the snapshot or the view until observation hashes the file
185
614
  again. The result reports `fileCensus: { reused, derived, read }`.
186
615
 
187
616
  Preparation stage: `prepareView` takes `cache` (`createPreparationCache`).
188
- Each note's bytes and manifest entry are a function of its pinned source
189
- digest, node record, allocated path, generated rows, outside-selection count
190
- and rewritten occurrences with their emitted targets; those inputs form a
191
- dependency key, and a note whose key equals the cached one reuses the cached
192
- bytes, manifest entry, attachment record and inversions. Everything else
193
- (asset copies, links, settings, collision checks, the manifest and the
194
- redaction guard over the whole result) runs as before. The result reports
617
+ Each note's bytes and manifest entry are a function of the vault layout and
618
+ emitter version, its pinned source digest, node record (repository, identity
619
+ and source path, which also make its identity block), allocated path (which
620
+ also names a wrapped file), generated rows (which carry the paths and titles
621
+ of their targets), outside-selection count and rewritten occurrences with
622
+ their emitted targets; those inputs form a dependency key, and a note whose
623
+ key equals the cached one reuses the cached bytes, manifest entry, attachment
624
+ record and inversions. Where the identity block goes and with which line
625
+ ending is decided from the source bytes, which the pinned digest covers.
626
+ Everything else (asset copies, links, settings, collision checks, the
627
+ manifest and the redaction guard over the whole result) runs as before. The
628
+ result reports
195
629
  `preparation: { emitted, reused }`, and a preparation that emits exactly the
196
630
  notes whose output changed is what the tests pin.
197
631
 
@@ -243,7 +677,7 @@ This is a layout inside the private store; the protocol is unchanged.
243
677
  | --- | --- |
244
678
  | Any candidate while it is written and fsynced (generated bytes only) | `staging/<journalId>/NNNNNN.candidate`, or `NNNNNN.late.candidate` for one that could not be known when the run began |
245
679
  | Candidate of a file that is created (exclusive link, no exchange) | stays at that staging path until it is linked |
246
- | Candidate of a file that is replaced (note, attachment, policy settings) | moved, complete, to `recovery/<journalId>/<unit>/exchange.candidate`, or `exchange.late.candidate` |
680
+ | Candidate of a file that is replaced (note, attachment, policy settings, Atelier's plugin files) | moved, complete, to `recovery/<journalId>/<unit>/exchange.candidate`, or `exchange.late.candidate` |
247
681
  | Displaced bytes | the vault path, then that same candidate path, then `recovery/<journalId>/<unit>/displaced.bin` |
248
682
 
249
683
  The journal names every exchange candidate, by path and SHA-256, before the
@@ -264,8 +698,10 @@ share nothing but the vault, so the vault lock lives under the vault's real
264
698
  path in `.atelier-publication/`, a dot-directory that no note path can name
265
699
  and that the app does not show. The second publisher refuses with
266
700
  `publication-in-progress` and writes nothing. It is the only thing the
267
- publisher writes in a vault besides notes, attachments and the policy
268
- settings file.
701
+ publisher writes in a vault besides notes, attachments, the two policy
702
+ settings files (`core-plugins.json`, `community-plugins.json`) and Atelier's
703
+ own plugin files under `.obsidian/plugins/atelier-projection/` (see
704
+ [obsidian-plugin.md](obsidian-plugin.md)).
269
705
 
270
706
  Release and recovery follow the view lock exactly. A finished publication
271
707
  writes a release marker. A publisher that dies leaves a ticket naming its
@@ -322,6 +758,13 @@ A fifth run aborted because the harness could not open a note after the
322
758
  second-window case; no bytes were involved. The receipts are maintainer-held
323
759
  and are not part of this repository.
324
760
 
761
+ Every vault Atelier publishes now carries Atelier's own plugin
762
+ ([obsidian-plugin.md](obsidian-plugin.md)), so a trusted vault runs one
763
+ community plugin. In phase 1 it registers no editor extension, no vault event
764
+ handler and nothing that writes; it only talks to the maintenance service over
765
+ loopback. The protocol cases above have not been re-run with it loaded; that
766
+ run is gate G20, before the plugin carries any part of publication.
767
+
325
768
  ### Receiving obligations for the production publisher
326
769
 
327
770
  The publisher may not claim the protocol outside the proven boundary until
@@ -345,6 +788,193 @@ each open item is discharged with its own evidence.
345
788
  | Link-then-rename | Rejected. It leaves a window in which a concurrent replacement is destroyed. |
346
789
  | App-driven save after replacement | Rejected. Observed losing an outside writer's bytes; kept as a negative control. |
347
790
 
791
+ ## Obsidian's vault list
792
+
793
+ Obsidian opens a vault from a link only when the folder is in its own vault
794
+ list. `atelier obsidian open` puts the view's vault there.
795
+ Outside its own storage (the data root, which holds the vaults and their
796
+ policy-owned `.obsidian/core-plugins.json`), the list is the only file of
797
+ another application that Atelier writes; to write it, Atelier writes nothing
798
+ else outside the app's user-data directory, and no vault note.
799
+
800
+ **The file.** `obsidian.json` in the app's user-data directory:
801
+ `$HOME/Library/Application Support/obsidian/` on macOS and
802
+ `$XDG_CONFIG_HOME/obsidian/` (else `$HOME/.config/obsidian/`) on Linux; no
803
+ location is known on other platforms. Its shape is
804
+ `{ "vaults": { "<16 hex>": { "path", "ts", "open"? } }, …other settings }`.
805
+ The app reads it when it starts and rewrites all of it, with
806
+ `JSON.stringify`, whenever its list changes; `open` is true while a window of
807
+ that vault is open and stays true for the vaults open when the app quit.
808
+ Modules: `src/projection/obsidian/publication/vault-list.mjs` (read only) and
809
+ `src/runtime/obsidian/app-registration.mjs` (the write).
810
+
811
+ **While Obsidian runs and answers its command line**, the file is the running
812
+ app's and is only read. `open` asks the app instead, through `obsidian-cli
813
+ eval` with constant scripts: `vault-list` (the app's own map) and `vault-open`
814
+ with the folder and `false`, which adds an existing folder to the list (the app
815
+ writes its own file) and opens it in a window; `true`, which would create a
816
+ folder, is never sent. The folder travels as a base64-encoded JSON payload, as
817
+ in the publication bridge, so a path never becomes code. The addition is
818
+ verified by reading the app's list again and finding the vault root's entry
819
+ (asked again, a bounded number of times, while the window the app just opened
820
+ is still loading and answers that a command does not exist yet); an addition
821
+ the app did not answer (a call that timed out) is looked up the same way, and
822
+ is `addition-not-answered` when the list does not show it. `open` then waits,
823
+ bounded, until the app answers for exactly that vault. Like Obsidian's own
824
+ "open folder as vault", `vault-open` also adds the folder to the operating
825
+ system's recently used documents (Recent Items on macOS).
826
+
827
+ An entry is the vault root's when its folder, as written, is the root as
828
+ given or its real path; a listed folder's own real path is read only when its
829
+ last component is the root's (the root reached through a linked parent), so
830
+ a vault on a mount that does not answer is never waited on, and a link to the
831
+ root under another name is not recognised.
832
+
833
+ **While Obsidian runs with no vault open**, its command line answers nothing
834
+ and the file is still the running app's: it is read, never written. A vault
835
+ it already lists is opened by path; one it does not is refused as
836
+ `no-vault-open`.
837
+
838
+ **Never inside another vault.** In every state, a vault inside a folder the
839
+ list has as a vault already (compared as written, against the vault root and
840
+ its real path) is not added, through the
841
+ app or in the file: that vault would show its notes too, and a call run in
842
+ its folder would reach that vault. `open` answers `launch-failed` with reason
843
+ `vault-inside-another-vault`.
844
+
845
+ **One folder open in several windows.** The list can name one folder more
846
+ than once (under another letter case, or through a link), and the app opens
847
+ one window per entry. When more than one of the entries that name the view's
848
+ folder is open, each window holds the vault and a call reaches one of them
849
+ only (`vaultRoute` answers `duplicated`): no publication call is made (the
850
+ publisher refuses `vault-open-in-several-windows`, a `publisher-conflict`),
851
+ and `open` answers `publisher-conflict` with that reason, names the entries
852
+ (`duplicates`) and launches nothing. The entries are read from the list's
853
+ `open` marks, and the app keeps the last window it closed marked open, so this
854
+ can hold with a single window showing; the next step is to remove the extra
855
+ entries from the list. While one entry has a window, a call
856
+ reaches only that entry, and `open` finds and launches it, so a closed entry of
857
+ the same folder is never opened beside it.
858
+
859
+ **A Flatpak or snap build** keeps its list inside its sandbox
860
+ (`~/.var/app/md.obsidian.Obsidian/config/obsidian/`,
861
+ `~/snap/obsidian/current/.config/obsidian/`) and never reads the file above.
862
+ On Linux the build in use is the one whose `obsidian.json` (native, Flatpak or
863
+ snap) was written last, since every build rewrites its own when a vault window
864
+ opens or closes; only when none was ever written, a Flatpak or snap
865
+ installation or leftover decides (`~/.var/app/md.obsidian.Obsidian`,
866
+ `~/.local/share/flatpak/app/md.obsidian.Obsidian`,
867
+ `/var/lib/flatpak/app/md.obsidian.Obsidian`, `~/snap/obsidian`,
868
+ `/snap/obsidian`, `/var/lib/snapd/snap/obsidian`). For a Flatpak or snap
869
+ build the file is neither read nor written, `open` answers
870
+ `obsidian-sandboxed`, and the vault is added through the running app only.
871
+
872
+ **While no Obsidian runs**, the vault is added to the file itself, and only
873
+ then:
874
+
875
+ 1. The process table is read and must say, positively, `absent`. `running` or
876
+ `unknown` refuses (`app-may-be-running`).
877
+ 2. The user-data directory and the file must exist, be this user's own, and be
878
+ a real directory and a regular file (neither a link), and the file must be
879
+ a JSON object whose `vaults`, when present, is an object, of at most 4 MiB.
880
+ A file with a second name (a hard link) is refused as
881
+ `obsidian-settings-unsafe`: the replacement would leave that name with the
882
+ old list. A missing
883
+ directory or file means Obsidian has not run on this account and is
884
+ refused (`obsidian-settings-missing`): the file is never created. The other
885
+ refusals are `obsidian-settings-unsafe`, `obsidian-settings-not-owned`,
886
+ `obsidian-settings-unreadable` and `obsidian-settings-not-object`.
887
+ 3. A vault whose real path an entry already has is left as it is; nothing is
888
+ written. A vault inside a folder an entry has is refused
889
+ (`vault-inside-another-vault`).
890
+ 4. The new document is the file's object with one entry added under a fresh
891
+ random 16-hex id that no entry has: `{ path: <the vault root's real path>,
892
+ ts: <now, ms>, open: true }`. Every other key and every other entry is kept
893
+ in its place, as values. A document that would be larger than 4 MiB is
894
+ refused (`obsidian-settings-too-large`).
895
+ 5. A temporary file in the same directory is written with the file's mode and
896
+ fsynced; the bytes as they were are written, fsynced, to
897
+ `obsidian.json.atelier-backup-<UTC time>` beside it.
898
+ 6. Immediately before the rename, the process table must still say `absent`
899
+ and the file must still hold the bytes that were read; otherwise both new
900
+ files are removed and the write is refused (`app-may-be-running`,
901
+ `obsidian-settings-changed`).
902
+ 7. The temporary file is renamed over `obsidian.json` and the directory is
903
+ fsynced. Of the backups beside it, the first (the list as it was before
904
+ Atelier wrote it) and this one are kept, and any between them removed. The
905
+ process table is read once more: an app that appeared just then may have
906
+ read the list before the rename, so the addition is reported unconfirmed
907
+ and `open` answers `launch-failed` with reason
908
+ `app-started-during-registration` instead of launching; the next `open`
909
+ adds the vault through the app if it missed it. A file that cannot be read
910
+ back to find the new entry is reported unconfirmed as well, with reason
911
+ `registration-not-read-back`.
912
+
913
+ Every refusal writes nothing and leaves nothing behind: a temporary file or
914
+ backup created on the way is removed again whichever later step fails.
915
+ `open` then shows the vault by the steps of `launchPlan`
916
+ (`src/runtime/obsidian/launch-plan.mjs`, carried out by `runLaunchPlan`),
917
+ naming it `obsidian://open?vault=<id>` when the id reaches it first (a path is
918
+ matched against the list by string prefix, with no separator check), and by
919
+ its own listed path otherwise (another vault's folder named like the id, an
920
+ unusual id): a running app is handed the link through its command-line tool,
921
+ which takes a link even with its command line turned off, or through the
922
+ operating system when the tool did not take it; on macOS a quit app is started
923
+ plainly (`open -b md.obsidian`), which reopens every vault the list flags open
924
+ (the entry written above is flagged open), and handed the link through its
925
+ tool once the app itself answers the tool (`appAnswered`: a version or one of
926
+ the app's own lines; the tool's "unable to find Obsidian" while the app starts
927
+ does not count). A link is never handed to the operating system for an app
928
+ this launch started, since that would reach it while it starts. An app started with a link would delete the
929
+ reopen flag of every other vault without a window (1.13.7, checked on an
930
+ isolated instance). On Linux a quit app is started with the link, a known
931
+ limit. With its command line turned off the app answers every other command
932
+ with "Command line interface is not enabled"; that is read as
933
+ `app-cli-unavailable` / `cli-turned-off`, never as a version.
934
+
935
+ **Which window answers.** The command line (1.13.7) answers a call whose first
936
+ argument is `vault=<value>` in the window of the first listed vault whose id is
937
+ the value, or whose folder's name is the value in any letter case; any other
938
+ call in the window of the first listed vault whose folder is the tool's working
939
+ directory or contains it, the folders compared as written; and otherwise in the
940
+ vault window that had focus last. It opens a vault that takes a call when it is
941
+ closed. The first match in list order wins, not the deepest folder, so a vault
942
+ listed before the view's vault at a folder above it would take every call run
943
+ in the view's folder. Calls about no vault (the version, the vault list, the
944
+ addition) run in a directory that is no vault and name none. A call about the
945
+ view's vault goes where the list says only that vault takes it
946
+ (`vaultRoute` in `vault-list.mjs`): from a directory that is no vault with
947
+ `vault=<id>` first when that id names this vault first, which does not depend
948
+ on how a working directory is spelled; otherwise in its folder when the first
949
+ listed vault that is or contains that folder is this vault; when neither
950
+ holds, no call is made. The vault root is taken in the spelling the file
951
+ system stores (`realpathSync.native` on macOS and Linux), which is how the
952
+ operating system reports the tool's working directory: on a volume that folds
953
+ letter case, a data root given in another case neither hides a vault listed
954
+ above it nor routes a call there. `open`'s check
955
+ that the app answers for the vault is routed from the list it verified the
956
+ vault in. Publication calls are routed from the file, and only while it lists
957
+ the vault open; otherwise they run in a directory that is no vault and name
958
+ none, so maintenance never reopens a vault window that was closed and never
959
+ reaches another vault. The bridge still checks that the app answered for
960
+ exactly this vault, and `open` that the app answers for it, both folders taken
961
+ as the file system stores them: an app that holds the vault under another
962
+ letter case or through a link holds this vault, and a store written under
963
+ another spelling keeps its vault.
964
+
965
+ **Retries.** The maintenance service is told what the app looks like from
966
+ the process table and the file alone (whether an Obsidian process runs, and
967
+ which vaults the file shows open; the file is read, never written, by the
968
+ service): nothing runs in the app to find out. The app is asked for its
969
+ version only when a view is about to be published, and without blocking the
970
+ service. A tick requested over the service's listener for one view (`open`
971
+ names its own) prepares and publishes that view once more, with the app's
972
+ qualification asked again; the engine takes such requests only for views the
973
+ project declared at its last tick, and at most 64 before its first. A view
974
+ whose last publication did not settle is also tried again as soon as that
975
+ picture changes, and otherwise after a delay that starts at 30 seconds and
976
+ doubles per attempt, up to the full reconciliation interval.
977
+
348
978
  Source application back into canonical files carries the same conditional-write
349
979
  obligation against other source writers; its protocol follows.
350
980
 
@@ -556,7 +1186,11 @@ list`, `show`, `run` and `recover` answer such a record as a typed refusal.
556
1186
  fragment of one left in the body, and text typed at the very end of the body
557
1187
  together with a removed region all refuse `generated-region-edited`. Some of
558
1188
  those refusals a person could have been spared; a generated byte in a source
559
- is never accepted in exchange.
1189
+ is never accepted in exchange. The identity block in the front matter is
1190
+ generated as well: it is removed before the source bytes are made, and a
1191
+ note whose front matter changed, the identity lines included, is a
1192
+ front-matter edit that becomes a proposal. A byte order prefix the editor
1193
+ dropped is taken from the source, as before.
560
1194
  - After an apply the next tick prepares the view again. When the prepared note
561
1195
  is byte for byte the note the person edited (read from the vault at the
562
1196
  moment of that decision, never taken from the engine's observation index; a