@heroiclands/package-build 17.2.0 → 18.1.0

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 (151) hide show
  1. package/CHANGELOG.md +1741 -0
  2. package/CONTENT.md +288 -72
  3. package/README.md +1 -1
  4. package/bin/content-build.mjs +218 -137
  5. package/bin/package-build.mjs +43 -0
  6. package/content-config.mjs +62 -67
  7. package/docs/content-format.md +768 -60
  8. package/engine/actor-compiler.mjs +586 -0
  9. package/engine/address-charset.mjs +11 -8
  10. package/engine/address-diff.mjs +266 -22
  11. package/engine/anchored-sections.mjs +83 -0
  12. package/engine/anchors.mjs +83 -0
  13. package/engine/base-compiler.mjs +148 -18
  14. package/engine/bundle-notes.mjs +276 -0
  15. package/engine/bundles.mjs +307 -0
  16. package/engine/code-fences.mjs +103 -0
  17. package/engine/compile-corpus.mjs +89 -0
  18. package/engine/content-address.mjs +267 -5
  19. package/engine/content-format-check.mjs +54 -11
  20. package/engine/content-format.mjs +37 -5
  21. package/engine/content-index.mjs +225 -127
  22. package/engine/content-links.mjs +185 -77
  23. package/engine/content-lint.mjs +232 -14
  24. package/engine/content-package.mjs +2 -1
  25. package/engine/content-tables.mjs +99 -3
  26. package/engine/document-subtypes.mjs +49 -6
  27. package/engine/field-reference.mjs +1 -1
  28. package/engine/field-spec.mjs +56 -6
  29. package/engine/folder-notes.mjs +470 -0
  30. package/engine/foreign-catalog.mjs +195 -4
  31. package/engine/{manifest-emit.mjs → foundry-entries.mjs} +55 -83
  32. package/engine/frontmatter-lint.mjs +245 -19
  33. package/engine/frontmatter.mjs +88 -8
  34. package/engine/generate.mjs +304 -13
  35. package/engine/helpers.mjs +293 -64
  36. package/engine/ids.mjs +146 -2
  37. package/engine/index-records.mjs +126 -0
  38. package/engine/index.mjs +23 -5
  39. package/engine/item-compiler.mjs +349 -0
  40. package/engine/item-docs.mjs +7 -1
  41. package/engine/item-registry.mjs +6 -0
  42. package/engine/journals.mjs +93 -27
  43. package/engine/macros.mjs +4 -2
  44. package/engine/metadata-index.mjs +495 -0
  45. package/engine/note-claims.mjs +146 -25
  46. package/engine/note-ids.mjs +112 -0
  47. package/engine/note-renames.mjs +134 -0
  48. package/engine/note-schemas.mjs +25 -0
  49. package/engine/note-vocabulary.mjs +107 -24
  50. package/engine/pack-config.mjs +4 -0
  51. package/engine/pack-router.mjs +23 -0
  52. package/engine/retired-fields.mjs +137 -3
  53. package/engine/scenes.mjs +66 -34
  54. package/engine/schema-check.mjs +58 -17
  55. package/engine/site-build.mjs +71 -12
  56. package/engine/site-index.mjs +20 -7
  57. package/engine/sql-tables.mjs +485 -0
  58. package/engine/subtype-registry.mjs +102 -0
  59. package/engine/system-block.mjs +96 -12
  60. package/engine/systems.mjs +201 -0
  61. package/engine/web-wikilinks.mjs +50 -20
  62. package/engine/wikilink-syntax.mjs +17 -8
  63. package/engine/wikilinks.mjs +124 -51
  64. package/engine/yaml-lint.mjs +282 -0
  65. package/hm3/actors.mjs +352 -0
  66. package/hm3/default-item-art.mjs +75 -0
  67. package/hm3/document-subtypes.mjs +134 -0
  68. package/hm3/index.mjs +56 -0
  69. package/hm3/item-builders.mjs +84 -0
  70. package/hm3/item-fields.mjs +180 -0
  71. package/hm3/items.mjs +96 -0
  72. package/hm3/template-priority.mjs +85 -0
  73. package/manifest.mjs +43 -2
  74. package/package.json +18 -4
  75. package/release.mjs +62 -7
  76. package/sohl/actors.mjs +33 -487
  77. package/sohl/being-info.mjs +13 -4
  78. package/sohl/default-item-art.mjs +14 -3
  79. package/sohl/document-subtypes.mjs +13 -7
  80. package/sohl/item-builders.mjs +14 -5
  81. package/sohl/item-fields.mjs +65 -4
  82. package/sohl/items.mjs +44 -258
  83. package/types/content-config.d.mts +21 -42
  84. package/types/engine/actor-compiler.d.mts +204 -0
  85. package/types/engine/address-charset.d.mts +11 -8
  86. package/types/engine/address-diff.d.mts +53 -5
  87. package/types/engine/anchored-sections.d.mts +21 -0
  88. package/types/engine/anchors.d.mts +20 -0
  89. package/types/engine/base-compiler.d.mts +17 -17
  90. package/types/engine/bundle-notes.d.mts +173 -0
  91. package/types/engine/bundles.d.mts +60 -0
  92. package/types/engine/code-fences.d.mts +43 -0
  93. package/types/engine/compile-corpus.d.mts +32 -0
  94. package/types/engine/content-address.d.mts +205 -5
  95. package/types/engine/content-format-check.d.mts +6 -2
  96. package/types/engine/content-format.d.mts +57 -1
  97. package/types/engine/content-index.d.mts +54 -49
  98. package/types/engine/content-links.d.mts +52 -7
  99. package/types/engine/content-lint.d.mts +10 -1
  100. package/types/engine/content-package.d.mts +2 -1
  101. package/types/engine/content-tables.d.mts +21 -39
  102. package/types/engine/document-subtypes.d.mts +37 -3
  103. package/types/engine/field-spec.d.mts +76 -5
  104. package/types/engine/folder-notes.d.mts +159 -0
  105. package/types/engine/foreign-catalog.d.mts +53 -0
  106. package/types/engine/{manifest-emit.d.mts → foundry-entries.d.mts} +2 -43
  107. package/types/engine/frontmatter-lint.d.mts +10 -2
  108. package/types/engine/frontmatter.d.mts +64 -0
  109. package/types/engine/generate.d.mts +38 -0
  110. package/types/engine/helpers.d.mts +94 -30
  111. package/types/engine/ids.d.mts +105 -0
  112. package/types/engine/index-records.d.mts +68 -0
  113. package/types/engine/index.d.mts +9 -3
  114. package/types/engine/item-compiler.d.mts +131 -0
  115. package/types/engine/journals.d.mts +47 -9
  116. package/types/engine/metadata-index.d.mts +226 -0
  117. package/types/engine/note-claims.d.mts +57 -11
  118. package/types/engine/note-ids.d.mts +38 -0
  119. package/types/engine/note-renames.d.mts +102 -0
  120. package/types/engine/note-vocabulary.d.mts +41 -5
  121. package/types/engine/retired-fields.d.mts +75 -0
  122. package/types/engine/scenes.d.mts +3 -2
  123. package/types/engine/schema-check.d.mts +25 -4
  124. package/types/engine/site-build.d.mts +4 -4
  125. package/types/engine/site-index.d.mts +1 -1
  126. package/types/engine/sql-tables.d.mts +185 -0
  127. package/types/engine/subtype-registry.d.mts +49 -0
  128. package/types/engine/system-block.d.mts +40 -1
  129. package/types/engine/systems.d.mts +106 -0
  130. package/types/engine/web-wikilinks.d.mts +4 -2
  131. package/types/engine/wikilink-syntax.d.mts +10 -3
  132. package/types/engine/wikilinks.d.mts +41 -13
  133. package/types/engine/yaml-lint.d.mts +107 -0
  134. package/types/hm3/actors.d.mts +48 -0
  135. package/types/hm3/default-item-art.d.mts +42 -0
  136. package/types/hm3/document-subtypes.d.mts +24 -0
  137. package/types/hm3/index.d.mts +7 -0
  138. package/types/hm3/item-builders.d.mts +11 -0
  139. package/types/hm3/item-fields.d.mts +12 -0
  140. package/types/hm3/items.d.mts +23 -0
  141. package/types/hm3/template-priority.d.mts +21 -0
  142. package/types/manifest.d.mts +22 -1
  143. package/types/release.d.mts +9 -4
  144. package/types/sohl/actors.d.mts +7 -74
  145. package/types/sohl/being-info.d.mts +8 -27
  146. package/types/sohl/default-item-art.d.mts +5 -3
  147. package/types/sohl/items.d.mts +17 -32
  148. package/engine/foreign-manifests.mjs +0 -126
  149. package/engine/kb-manifest.mjs +0 -490
  150. package/types/engine/foreign-manifests.d.mts +0 -43
  151. package/types/engine/kb-manifest.d.mts +0 -241
package/CHANGELOG.md CHANGED
@@ -1,5 +1,1746 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 18.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - e9a4200: **The gear-suffixed note types are restored** — `armorgear`, `concoctiongear`
8
+ and `projectilegear` are the vocabulary again, reversing #78.
9
+
10
+ #78 renamed them to `armor`, `concoction` and `projectile`, on the argument that
11
+ the suffix named the _SoHL document subtype_ a note compiled into rather than
12
+ the thing the note is about. The argument had a cost the rename did not pay for.
13
+
14
+ **Nothing adopted the bare spellings.** Across all five content trees, every note
15
+ still authors the suffix — 331 `armorgear` and 18 `projectilegear` in `sohl`, 71
16
+ `concoctiongear` in `thalorna` — and **not one note anywhere** writes `armor`,
17
+ `concoction` or `projectile`. The rename produced **349 warnings and zero
18
+ adopters**, and it was those warnings that made `lint:addresses` noise on every
19
+ consumer that took 18.0.0.
20
+
21
+ **It also cost a property worth more than the argument.** `weapongear` and
22
+ `containergear` kept their suffix — SoHL and HM3 both call those documents that —
23
+ so the rename left three of the five gear types spelled one way and two the
24
+ other. With it reversed, every SoHL row of the note-type → document-subtype map
25
+ is the identity again, all fourteen of them, which is what lets two tests drop
26
+ their exception lists entirely.
27
+
28
+ `RENAMED_TYPES` is reversed rather than emptied, so the bare spellings are still
29
+ _read_ and _reported_ rather than refused — the same retirement window the
30
+ rename itself used, pointing the other way. `armorlocation`, the type #78 added,
31
+ is unaffected: it was new, not renamed.
32
+
33
+ ## 18.0.0
34
+
35
+ ### Major Changes
36
+
37
+ - 0d15342: **The cross-package link manifest is gone.** A package now publishes its own
38
+ content index and a consumer fetches the ones it depends on (#239).
39
+
40
+ The manifest was vendored — every repository committed a copy of every other
41
+ repository's file — and that failed three ways, none of them fixable by
42
+ tightening it.
43
+
44
+ **A copy goes stale silently, and one was.**
45
+ `Song-of-Heroic-Lands-FoundryVTT` carried 2,101 `thalorna` entries whose address
46
+ was the old name-derived form, so every cross-package link it rendered pointed
47
+ at a URL the site had stopped publishing. The format-version gate saw nothing,
48
+ because the format had not changed — only the values were wrong.
49
+
50
+ **Mutual vendoring deadlocks.** SoHL vendored thalorna and thalorna vendored
51
+ SoHL, so a format bump stopped both builds until the other had already
52
+ published: neither could go first.
53
+
54
+ **It was a second answer to a settled question.** The content index already
55
+ carried the canonical key, the name, the anchors, the Foundry `uuid` and the web
56
+ address. There was nothing in a manifest entry it did not hold.
57
+
58
+ **What replaces it**
59
+
60
+ - Every package emits `<package>-metadata.jsonl` and advertises it as
61
+ `flags.metadataUrl`, pinned to the version being built.
62
+ - The release publishes it as a third asset, named by that URL so the file
63
+ shipped and the URL advertised cannot disagree.
64
+ - `content-build deps fetch` pulls each dependency's index into
65
+ `build/cache/metadata`, reading the `manifest` URL the relationship already
66
+ declares and taking `flags.metadataUrl` from it. The whole chain is declared;
67
+ nothing holds an address of its own.
68
+ - Cross-package links resolve from that cache.
69
+
70
+ **The dependency set is every declared dependency** — everything in
71
+ `relationships.systems` and `relationships.requires` — and deliberately _not_
72
+ only those declaring `itemCatalog: true`. Citing another package's addresses and
73
+ embedding its items are separate edges: `harn-ensemble` cites no foreign address
74
+ and carries 324,016 embedded item references. `recommends` and `conflicts` are
75
+ not dependencies and are not fetched.
76
+
77
+ **Removed:** `engine/kb-manifest.mjs`, `engine/foreign-manifests.mjs`, the
78
+ `content-build manifest` command, its `--manifests` options, and the
79
+ configuration keys `paths.manifests`, `paths.manifestOut` and
80
+ `publish.manifests`. `engine/manifest-emit.mjs` becomes
81
+ `engine/foundry-entries.mjs`, which is what it always did — `manifest.mjs` and
82
+ `kb-manifest.mjs` both exported `buildManifest` meaning different things, and
83
+ that ends here.
84
+
85
+ **Kept, and moved:** the address grammar — `canonicalKey`, `readCanonicalKey`,
86
+ `CANONICAL_KEY_SEGMENTS`, `PACKAGE_BASE` and the URL helpers — now lives in
87
+ `engine/content-address.mjs` beside `addressSlug`. It was never the manifest's;
88
+ deleting the module without splitting it would have taken the address form too.
89
+
90
+ **Two behaviours moved rather than vanished.** A manifest-completeness gate no
91
+ longer exists: a declared dependency with no fetched index is a hard error
92
+ naming `deps fetch`, so past the load everything is accounted for. And whether a
93
+ package serves pages is now the consumer's `PACKAGE_BASE` rather than the
94
+ producer's entries — a package with no configured base stays citable by UUID and
95
+ simply yields no URL, which is what `kethira` needs.
96
+
97
+ **Consumers must act.** Declare your dependencies in `relationships`, run
98
+ `content-build deps fetch` before a build that resolves cross-package links, and
99
+ delete `assets/manifests/`. A configuration still naming `publish.manifests` or
100
+ `paths.manifests` will be rejected as an unknown key.
101
+ - e5f40c7: **A document's id is derived from its identity, not authored and not keyed on a
102
+ list position.** Three ids changed how they are computed (#270, #268), and every
103
+ compiled id this toolchain emits is affected.
104
+
105
+ **1. A note's document `_id` derives from its canonical address (#270).**
106
+
107
+ ```
108
+ _id = makeId("document", "<package>-<system>-<type>-<shortcode>")
109
+ ```
110
+
111
+ `id:` becomes **optional**. A note used to author one — an opaque 16-character
112
+ string, 6,343 of them across the four content trees — that said nothing its
113
+ address did not, could not be read or reviewed, and was guaranteed by nothing:
114
+ `content-lint` refuses a duplicate **address** across every pack of a document
115
+ type, which is exactly the scope a primary document's id must be unique within,
116
+ and it said nothing at all about a duplicate `id`. The derived id inherits a
117
+ guard that already exists. It is the same principle that turned
118
+ `folder: ONXsqZAIZr2qzxTb` into `packFolder: <path>` (#251, #252).
119
+
120
+ **An authored `id` always wins, and pinning one is how a document keeps its
121
+ identity across a shortcode rename.** That is the pattern the map compiler
122
+ already used (`regionDocId(sceneId, key, pinned)`), now applied to primary
123
+ documents too.
124
+
125
+ **2. An actor's embedded item is keyed on its identity, not its index (#268).**
126
+
127
+ ```
128
+ _id = makeId(<actor id>, "<subType>:<system.shortcode>")
129
+ ```
130
+
131
+ An entry's identity is its **own `system.shortcode`**. The entry's top-level
132
+ `shortcode` is a _selector_ naming the template it is written from, is never
133
+ written to the document, and may repeat. **Two entries resolving to one identity
134
+ are now a build error naming both** — the case a position key used to hide by
135
+ compiling them to two documents denoting one entity.
136
+
137
+ **3. An unanchored journal page is keyed on its heading, not its index (#268).**
138
+
139
+ ```
140
+ _id = makeId("journal-page", "<entry id>:<name>")
141
+ ```
142
+
143
+ Two sibling pages sharing a heading is likewise a build error, matching the
144
+ `MD024` lint rule that already refuses it. An anchored page is unchanged — it
145
+ never took an index, and is the shape the other two now share.
146
+
147
+ **Why position was wrong.** A Foundry id is how a world refers to a document it
148
+ imported. Keying on position meant reordering a being's item list, or inserting
149
+ a heading into a note, silently renumbered every id after the change — so a
150
+ re-import created new documents beside the old ones. Nothing about those
151
+ documents had changed; only their neighbours had.
152
+
153
+ **What this costs, measured against a real corpus** (`sohl`, 1,606 notes →
154
+ 3,094 documents):
155
+
156
+ | id | count | moved |
157
+ | ------------------------- | ----- | ----- |
158
+ | primary documents | 3,094 | **0** |
159
+ | embedded items | 1,337 | 1,337 |
160
+ | journal pages, anchored | 296 | 0 |
161
+ | journal pages, unanchored | 1,648 | 1,648 |
162
+
163
+ No primary id moved **because every note in that tree still authors one**, and a
164
+ pin wins. #270 is inert until a tree drops its authored ids; #268 lands at once.
165
+ Stripping all 1,605 authored ids compiles with no new errors, derives every id
166
+ exactly as the formula above states, and leaves all 5,712 internal `@UUID`
167
+ references as consistent as before.
168
+
169
+ **Consumers must act.**
170
+
171
+ - **Every embedded item id and every unanchored page id changes once.** A world
172
+ that imported these documents will see new ones on re-import.
173
+ - **Removing a tree's authored `id:`s changes every compendium UUID that package
174
+ publishes**, once. Internal references are regenerated in the same build and
175
+ stay consistent with each other; anything outside — a GM's world, a macro, a
176
+ module — will not. Do it deliberately, and per tree.
177
+ - **Fix any duplicate embedded identity first.** Measured across the four
178
+ corpora: 20 entries in 17 notes, every one a defect. `sohl` has 1
179
+ (`Characters/Aldrik_Harvenar.md` carries `skill/swim` twice);
180
+ `sohl-thalorna` has 19; `harn-ensemble` and `sohl-kethira-basic` have none.
181
+
182
+ **One diagnostic narrows.** `content-build addresses` tells a **rename** from a
183
+ **withdrawal** by matching document ids across releases. That rested on the id
184
+ being independent of the shortcode; it no longer is, so for a note that pins no
185
+ `id` both sides move together and a rename is reported as a withdrawal with no
186
+ successor named. It never reports a _wrong_ successor, and stays exact for a
187
+ pinned note.
188
+
189
+ **"Has an id" no longer means "has a compendium document."** Every addressable
190
+ note derives one now, so the types that compile into no _single_ document say so
191
+ themselves rather than resting on an absent `id:`. A **homepage** publishes no
192
+ UUID because it is in no pack, and a **folder** because it may be in several —
193
+ it materialises in every pack holding a document that references it (#276), and
194
+ its id is hashed under the `folder` namespace against its own address. Either
195
+ one would otherwise have published an `Item` UUID at an id no document carries.
196
+
197
+ **Also:** `assertUniqueAnchors` is renamed `assertUniquePages` (the old name
198
+ remains as a deprecated alias) and now checks page names as well as anchors;
199
+ `journalPageId(entryId, page)` no longer takes an index.
200
+ - dcff2f9: **The SoHL passes emit `system.templatePriority`, not `system.archetype`**
201
+ (#266).
202
+
203
+ The read half landed already: `templatePriority` is what a note authors, and
204
+ `archetype` is read only as the retiring spelling. The **emitted** key stayed
205
+ behind, so a compiled document still carried the old name — and that half cannot
206
+ move on its own schedule, because the receiving schema and the emitted key have
207
+ to agree. `Song-of-Heroic-Lands-FoundryVTT#1836` renames the data-model field;
208
+ this is the other side of that single change.
209
+
210
+ **Why it is breaking.** Foundry discards an undeclared `system` key at
211
+ construction _without a warning_, so a build emitting `archetype` into a system
212
+ that declares `templatePriority` reports success and ships documents whose
213
+ priority is silently gone. The emitted-versus-declared check catches exactly this
214
+ and fails the pack build, which is what makes the pairing enforced rather than
215
+ hoped for: **a consumer must take this release together with a SoHL that declares
216
+ `system.templatePriority`** (0.8.4 or later). Taking one without the other fails
217
+ the build with a message naming the field, rather than shipping quietly broken
218
+ packs.
219
+
220
+ `resolveArchetype` and `systemArchetype` are renamed `resolveTemplatePriority`
221
+ and `systemTemplatePriority`; the generated field-reference example authors
222
+ `templatePriority: null`, since the linter now refuses the old spelling in the
223
+ example it tells authors to copy.
224
+
225
+ The HM3 pass is unaffected — it already wrote `flags.hm3.templatePriority`.
226
+ - 79d9503: A top-level `traits:` block is refused (#291).
227
+
228
+ #128 moved a being's description — `gender`, `species`, `age`, `birthday`, `height`,
229
+ `weight`, `frame` and `appearance.*` — out of a top-level `traits:` block and into
230
+ the closed `data:` container the content format declares. **2,533 notes across four
231
+ repositories** have landed, and every content tree now carries zero. This is the third
232
+ and last step of that retirement.
233
+
234
+ **Refusing it matters more than refusing an ordinary dead key.** Top level is
235
+ _deliberately open_: an unrecognised key there is passed straight through to Hugo. So
236
+ a stray `traits:` would not be ignored loudly — it would arrive on the published page
237
+ as a theme parameter, checked by nothing, reading to its author as though it still
238
+ worked. The whole argument for `data:` being closed is the argument for refusing this.
239
+
240
+ The message states the **mapping**, not just the destination, because three of the
241
+ keys reshaped as well as moved: `traits.height.m` → `data.height` (metres),
242
+ `traits.weight.kg` → `data.weight` (kilograms), `traits.build.frame` → `data.frame`.
243
+ A bare _"write `data:` instead"_ would send an author to write `data.height: {m: 1.78}`
244
+ — a declared key holding an undeclared shape.
245
+
246
+ **`sohl.traits` is untouched.** It is a different field that shares the name —
247
+ `projectilegear` declares one and the theme's gear sidebar reads it — so the refusal
248
+ is anchored at column 1 and never reaches inside a system block.
249
+
250
+ Refused from the same two compile paths as `draft:`, `aliases:` and `section:`, and
251
+ reported by the frontmatter lint alongside them.
252
+ - d17dc98: **A `dataview` query that selects no notes is now a build error** (#223).
253
+
254
+ A zero-row table publishes as a bare header and a rule, so a stale query — a
255
+ renamed type, a retired category, a typo'd path — looked exactly like a category
256
+ that is legitimately empty. Both builds emitted it and neither said a word.
257
+
258
+ Where a table is meant to be empty, say so on the fence — `dataview allow-empty`
259
+ in place of `dataview`. The opt-in sits on the fence rather than in the query
260
+ because it is a statement about the directive, not part of the query language.
261
+ The table is still rendered either way: the finding is the point, not
262
+ withholding the output.
263
+
264
+ The finding names the note, the line of the block and **the clause that matched
265
+ nothing**, quoted as authored, because that is the string the author will edit.
266
+
267
+ **The site build's table findings are compiler-parseable too.** They were prose
268
+ with a timestamp where a parser reads the path, so one authored table produced a
269
+ machine-readable diagnostic from the pack build and something ungreppable from
270
+ the site. Both now emit `file:line:column: error: message`.
271
+
272
+ **Major, because a tree carrying a dead table goes red on adoption.**
273
+ `Song-of-Heroic-Lands-FoundryVTT` carries **40**, across eight notes — including
274
+ the eight `type = "creature"` tables in `Rules/Bestiary.md` that the issue was
275
+ filed about, a `sohl.kbcat = "birthsign"` query for a retired concept, and a
276
+ `contains(file.tags, "religous")` that is simply a misspelling. None of them has
277
+ published a row in months.
278
+ - 43a1f4d: **The address grammar is strict, and omission runs left to right** (#59).
279
+
280
+ ```text
281
+ [[[[<package>-]<system>-]<type>-]<shortcode>]
282
+ ```
283
+
284
+ The written forms are exactly the suffixes of the canonical address —
285
+ `type-shortcode`, `system-type-shortcode`, `package-system-type-shortcode` — and
286
+ **`package-type-shortcode` is not one of them**. A link into another package must
287
+ now be fully qualified. That is the price of positional segments, and the
288
+ alternative is a parser that needs a vocabulary to tell a package from a system.
289
+
290
+ `readQualifier` counted at most three segments and never read a system, so it
291
+ accepted a package with its system omitted and every authored target resolved
292
+ system-blind. It now counts segments and assigns each position its field, the
293
+ same rule `readCanonicalKey` follows.
294
+
295
+ **`sohl` is both a package and a system**, and counting is what makes that
296
+ harmless: three segments name a _system_ whatever the first segment could also
297
+ have meant, and four is the full form. Nothing has to guess which sense was
298
+ written.
299
+
300
+ **A stated system is now matched, not merely parsed.** A target naming a system
301
+ resolves against the segments it supplied rather than falling back to the
302
+ system-blind short key, so `[[hm3-skill-clmb]]` no longer silently resolves to
303
+ a `sohl` note.
304
+
305
+ **A hyphenated shortcode is no longer read.** The old parser split at the _first_
306
+ hyphen so a shortcode could contain one (`trauma-self-pro`); #1397 made every
307
+ segment `^[A-Za-z0-9]+$`, and the two rules cannot both hold. The charset rule
308
+ wins — it is enforced, and no tree has used the tolerance: 138,204 authored
309
+ shortcodes across four content trees, none carrying a separator.
310
+
311
+ **Verified against real content, not fixtures.** `content-build links` over
312
+ `Song-of-Heroic-Lands-FoundryVTT` (1,606 notes) and `sohl-thalorna` (1,852 notes,
313
+ which links into SoHL) reports the _same_ findings before and after — 43 and 122
314
+ respectively, identical file sets, no new failure. No authored link in any of the
315
+ four trees uses the retired form.
316
+
317
+ Two messages follow the grammar: an ambiguity now asks for the fully qualified
318
+ form rather than a package-qualified one that would not parse, and a labelled
319
+ non-address names both the local and the cross-package spelling.
320
+ - d17dc98: **A content-index record's Foundry address is keyed by the system that compiles
321
+ it.** `foundry.uuid` becomes `foundry.<system>.uuid` for a document a _game
322
+ system_ defines — an Item or an Actor — while a document the _note format_
323
+ defines keeps the unkeyed form.
324
+
325
+ ```json
326
+ "foundry": { "sohl": { "uuid": "Compendium.sohl.items.Item.…" } } // an item
327
+ "foundry": { "none": { "uuid": "Compendium.sohl.journals.JournalEntry.…" } } // a journal
328
+ ```
329
+
330
+ The vocabulary is the specification's — `sohl`, `hm3`, and **`none`** for a note
331
+ that belongs to no system, the same value the canonical address carries in its
332
+ `<system>` segment (`harnadventures-none-being-grod`, #59). Every record is
333
+ keyed, so "belongs to no system" and "nobody filled this in" are not the same
334
+ shape.
335
+
336
+ **A note may declare more than one system**, and each compiles into its own
337
+ document, of that system's type, in that system's pack: 2,497 of
338
+ `harn-ensemble`'s notes carry both a `sohl:` and an `hm3:` block, and its
339
+ `packs:` declares an `actors-sohl` _and_ an `actors-hm3`. One `uuid` on the
340
+ record cannot name two documents — it named whichever the single shipped map
341
+ produced and said nothing about the other.
342
+
343
+ Only `sohl` can appear today, because `KNOWN_DOCUMENT_SUBTYPE_MAPS` holds one
344
+ map and #139 tracks the missing `hm3/` half. **The shape changes now so that
345
+ adding it is one more key rather than a second breaking change** to an artifact
346
+ consumers have already started reading.
347
+
348
+ **A journal, a macro or a scene is `none`.** Those are the note format's own
349
+ documents rather than a system's, and the documentation journal an item
350
+ compiles beside itself is `none` too — it is one journal however many systems
351
+ the item declares. On `sohl` that is 1,474 records keyed `sohl` and 1,514
352
+ keyed `none`.
353
+
354
+ The uuid _values_ are unchanged: still equal to the link manifest's, verified
355
+ across `sohl`'s 2,988 addresses with no mismatch.
356
+
357
+ **Not synthesized onto the `sohl:`/`hm3:` blocks themselves.** Those are regions
358
+ a note authors, and `DERIVED_KEYS` — which refuses a note that writes over
359
+ derived data — reaches only the top level. A note authoring `sohl.uuid` would
360
+ collide silently, which is the failure this index exists to prevent.
361
+
362
+ ### Minor Changes
363
+
364
+ - ad813e1: **`lint:addresses` reads the content index too** (#243) — the second reader
365
+ converted, and the last of the two lints the issue names.
366
+
367
+ `addresses diff` reads the tree twice: once for the renames notes declare, once
368
+ to place its findings against the note that made them. Those were two
369
+ independent walks, each parsing every note, each answering "which files are the
370
+ corpus?" for itself. They are now **one** derivation — `indexRecordsFor`,
371
+ enumerated once by the command and handed to both — so the two halves of a
372
+ single command cannot disagree about the corpus, or about the ids in it.
373
+
374
+ **A latent defect goes with it, and the id is the reason it mattered.**
375
+ `noteFilesById` joins tree-side ids against ids read out of the _compiled
376
+ packs_. Since #270 an id is derived from the canonical address, whose first
377
+ segment is the content package — and the tree side derived it through
378
+ `resolveNoteId(fm)` with no package, which falls back to `contentPackage()` and
379
+ so to whichever configuration the working directory answers with. The compiled
380
+ side is produced by a compiler running on the configuration the _build_
381
+ resolved. Let those differ — under `PACKAGE_BUILD_CONFIG`, in a worktree, in a
382
+ test — and **every id fails to join**: every rename degrades to a withdrawal, and
383
+ every finding loses the note it should have been reported against. The
384
+ configuration is now passed in and both sides derive from the one that was
385
+ resolved.
386
+
387
+ **It is also faster, which is the shape of the win.** Two whole-tree walks
388
+ became one derivation: over `sohl`'s 1,685 notes the pair of reads goes from
389
+ about 2.8s to about 1.4s. Both maps are byte-identical to what the walks
390
+ produced — 1,685 ids, and the declared predecessors of five renames spanning
391
+ five item types, including the `projectilegear`/`missilegear` pair that a
392
+ declaration keys under both maps.
393
+
394
+ The scope requirement moves to a shared `assertStatedScope`, so a reader of the
395
+ content index refuses an unstated scope in the same words `walkMarkdownTree`
396
+ does. A pass reading the index makes the identical claim about which files it is
397
+ looking at and must be held to the identical rule; a quiet default there would
398
+ reintroduce the second answer #243 removed.
399
+
400
+ `declaredPredecessors` and `noteFilesById` take `config` and already-derived
401
+ `records`. Where the walk yielded nothing for a tree that is not there, so do
402
+ they.
403
+ - 9e4861c: An affiliation records what it answers to, where it sits, and what it holds sway
404
+ over (SoHL#1781).
405
+
406
+ **`relation` is now `relations`.** The field holds a _map_ of standings, one per
407
+ affiliation — its own description said so, and `resolveRelation` has always read it
408
+ that way. The singular named the many as one.
409
+
410
+ The retired spelling is still read, underneath the current one, and reported through
411
+ `RETIRED_FIELD_ALIASES` — the same three steps `img`/`image` and
412
+ `templatePriority`/`archetype` take. `relations` wins wherever a note writes both,
413
+ and the thrown message names whichever spelling the note actually used. Only
414
+ `affiliation` declares the field, so `relation` stays an ordinary unknown key on
415
+ every other type.
416
+
417
+ **Three fields are new**, and each was unexpressible before: the content format
418
+ specified them and no declaration could receive them.
419
+
420
+ | authored | emitted | |
421
+ | --------- | ---------------- | ---------------------------------------------------------------------------------------------------- |
422
+ | `parents` | `system.parents` | The bodies it is subordinate to. A list, because an affiliation may sit under more than one at once. |
423
+ | `seat` | `system.seat` | Where its authority sits. |
424
+ | `domains` | `system.domain` | The places it holds sway over. |
425
+
426
+ `seat` is deliberately not `capital` or `headquarters` — each fits about half the
427
+ eleven subTypes, while a seat covers a polity, a guild, an order and a faith alike.
428
+
429
+ `domains` → `domain` is **authored plural, emitted singular**, which is what the
430
+ format's own mapping row states. The declaration carries both spellings so neither
431
+ side has to guess, and a note that authored `domain` would otherwise have compiled
432
+ to nothing.
433
+
434
+ `parents` and `domains` are separate because they are different relations —
435
+ _subordinate to_ against _holds sway over_ — and an earlier `parent.regions` grouped
436
+ the geographic one under the organisational one while also naming regions where a
437
+ guild's domain may be a single town.
438
+
439
+ Both lists ship `[]` rather than null: _refers to nothing_ is a value here — a
440
+ sovereign polity answers to nobody — not an absence. Blank rows, which a cleared
441
+ property editor leaves behind, are dropped rather than emitted.
442
+
443
+ **This needs the schema half to land with it.** SoHL declares `system.relations`,
444
+ `system.parents`, `system.seat` and `system.domain` in the same wave; an emitted key
445
+ the data model does not define is discarded at construction without a warning.
446
+ - 1274ff8: **The markdown note vocabulary drops the `gear` suffix** — `armorgear` → `armor`,
447
+ `concoctiongear` → `concoction`, `projectilegear` → `projectile` (#78).
448
+
449
+ Those three named the **SoHL document subtype** a note happened to compile into rather
450
+ than the thing the note is about. A note's `type` sits outside the `sohl:` and `hm3:`
451
+ blocks precisely because it belongs to no system, and HM3 already compiles a projectile
452
+ into a `missilegear` — so the suffix was never a fact about the note. `weapongear` keeps
453
+ its name: both systems call that document a `weapongear`, so it says nothing
454
+ system-specific. `armorlocation`, which maps to an HM3 Item and to nothing in SoHL at
455
+ all, is a first-class note type: the vocabulary is system-agnostic, not a list of what
456
+ SoHL happens to define.
457
+
458
+ **Nothing in a compiled pack moves.** The emitted document subtypes are unchanged — an
459
+ `armor` note still compiles into an `armorgear` Item — so no world, no compendium and no
460
+ `_id` is affected. Verified by recompiling `Song-of-Heroic-Lands-FoundryVTT` (331
461
+ `armorgear` and 18 `projectilegear` notes) and `sohl-thalorna` (71 `concoctiongear`) with
462
+ the released toolchain and with this one, and diffing every emitted document: 3,091 and
463
+ 2,605 files respectively, byte-identical, with the content trees untouched.
464
+
465
+ **Both spellings are read, and the retired one is reported — not refused.** This is the
466
+ first of the three steps `package:` took, and the rule `RETIRED_FIELD_ALIASES` already
467
+ states for a renamed _field_: a note carrying the old spelling compiles into exactly the
468
+ document it always did, so failing a build over it would red a tree that has done nothing
469
+ wrong. There are some 31,000 references to move, overwhelmingly `(type, shortcode)`
470
+ entries inside a being's `items:` list, and a consumer must be able to adopt the new
471
+ toolchain before its content does. `content-build lint` reports each one as a **warning**
472
+ naming the file, the line and what to write instead; the sweep and the refusal come after.
473
+
474
+ **Both sides of a reference resolve.** A being's embedded `(type, shortcode)` reference
475
+ carries the note vocabulary too, and those outnumber notes' own `type:` keys by roughly a
476
+ thousand to one — so `referencedSubtype` normalises alongside the note's own type. A
477
+ window that resolved notes but not references would have dropped 30,000 embedded items in
478
+ silence.
479
+
480
+ **Two lookups where the vocabularies now genuinely differ**, and both are translated
481
+ rather than joined by name:
482
+
483
+ | lookup | keyed by | what changed |
484
+ | --------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
485
+ | `sohl/default-item-art.mjs` | document subtype | `SohlItem.getDefaultArtwork` reads it with a Foundry `Item`'s own `type`, so the map stays on the document side and `sohl/item-builders.mjs` translates before asking |
486
+ | the published-schema check | document subtype | `compareFields` gets the `subtypeOf` seam it was given for exactly this, so `armorgear`'s findings still fire |
487
+
488
+ **Planning a sweep? Two things to move with the frontmatter.** A note's canonical address
489
+ — and therefore its document `_id` — carries its `type` as authored, so renaming a note's
490
+ type moves the address it publishes at. Every wikilink into it and every content-table
491
+ query naming the type (`WHERE type = "armorgear"`) has to move in the same change, and a
492
+ package that _links into_ another has to move with it.
493
+ - d17dc98: **A `#section` link is checked by the build that emits it, not only by the
494
+ checker** (#193). `content-build links` reported a dead anchor as an error while
495
+ the pack compilers hashed _any_ slug into a `JournalEntryPage` id and emitted a
496
+ `@UUID` for it — so a link the checker refused still compiled, and dead-ended
497
+ for the reader.
498
+
499
+ A **foreign** anchor was already checked, because a vendored manifest publishes
500
+ an `anchors` map. A local one was not, for the reason #193 gives: neither index
501
+ held the set. The walk that builds the link index yields each note's body and
502
+ nothing read it.
503
+
504
+ It reads it now, through the one anchor reader, and reports `unknown-anchor`
505
+ with the message the foreign path already used — nothing new is named.
506
+
507
+ An index built without anchors still says nothing, which is deliberate: it
508
+ cannot answer the question, and answering it wrongly is what this fixes.
509
+
510
+ `collectAnchors` moves to `engine/anchors.mjs`, a module that imports nothing.
511
+ It has to: the link checker, the content index and the compilers all ask this
512
+ question, and `helpers.mjs` is imported by the compilers while the index imports
513
+ the manifest emitter, which imports them back. A leaf is what lets all three
514
+ share one reader instead of two disagreeing ones.
515
+ - b67a786: **A `bundle` note compiles into a Foundry `Adventure`** (#259).
516
+
517
+ #263 specified and declared the type and left it uncompiled — authoring one said
518
+ so, in as many words — because two decisions came first, neither answerable from
519
+ the specification. Both are settled here, and the pass exists.
520
+
521
+ **Which pack.** Not the `adventures` **companion**. The scenes pass already
522
+ writes one Adventure per pinned place into that pack, and a companion is written
523
+ by its parent pass rather than routed to — the router refuses a note that
524
+ addresses one, and is right to. So a bundle lands in an ordinary Adventure pack,
525
+ routed and defaulted exactly as items and actors are, and the place-adventures
526
+ companion is left alone. A repository that authors bundles declares an Adventure
527
+ pack of its own; one that declares none is now told so by name, because `bundle`
528
+ is in the type table the unclaimed-note check reads.
529
+
530
+ **What a `contents` address names: the note's own document.** That is already
531
+ the router's rule for `pack:`, so there is one answer and not two. A note that
532
+ compiles into _two_ documents — an item and the JournalEntry its prose became —
533
+ puts the second in a bundle only when the bundle names it by its own `doc…`
534
+ address. Nothing is inferred; an address that resolves to nothing fails the
535
+ build, and a `folder` address is refused with a message of its own, since a
536
+ folder materialises in every pack holding something filed in it and so has no
537
+ single copy to take.
538
+
539
+ **An Adventure holds copies, not references**, so `contents` resolves against
540
+ _compiled output_ rather than against the content tree. The pass therefore reads
541
+ every other one, and **says so** — `Bundles.readsPackOutputOf` names Item,
542
+ Actor, JournalEntry, Macro and Scene, from which the generator derives the
543
+ compile order. An Adventure pack declared first in `packs:` still compiles last.
544
+
545
+ **A pack's `system:` constrains what its Adventures may hold**, and the
546
+ constraint is read from what the pack can see rather than computed from a
547
+ member's type — which has no single answer for the types both systems map, most
548
+ of them. A pack declaring `system: hm3` reads the HM3 packs and the neutral
549
+ ones, so a member publishing no HM3 document is **left out rather than
550
+ failing**, with a warning naming it. A pack declaring no system scopes nothing
551
+ away, so a member it cannot find is a dead address and fails.
552
+
553
+ **The note's prose becomes the Adventure's `description`** — the `HTMLField`
554
+ Foundry renders on the import card. A bundle is something you hand someone, so
555
+ its prose belongs on the document itself, which is why it earns no separate
556
+ documentation journal the way an item does. That was #263's third open question.
557
+
558
+ **A prebuilt pack is passed over rather than compiled.** Its per-document JSON
559
+ is checked in, so it has no pass and no note is routed into it — which
560
+ `content-config.mjs` already said by refusing `default: true` beside `prebuilt`.
561
+ It could not matter before: the only prebuilt pack in the wild holds Adventures,
562
+ and no compiler was registered for that document type, so it failed with "no
563
+ compiler for document type" whatever it was asked. Now one is registered, and
564
+ running a pass over it would wipe its generated JSON directory, write nothing,
565
+ and then report the empty pass as an error.
566
+ - 458fd89: **A note declares the shortcode it used to be published under, so a rename stops
567
+ reading as a withdrawal (#278).**
568
+
569
+ `(type, shortcode)` is a published interface — every satellite declaring
570
+ `itemCatalog: true` assembles its beings out of those addresses — and
571
+ `content-build addresses diff` exists to report what a build stopped publishing
572
+ before a release does. To be useful it has to name where an address _went_, and
573
+ it told a **rename** from a **withdrawal** by matching document ids across two
574
+ releases.
575
+
576
+ #270 removed the property that rested on. An id is now derived from the
577
+ canonical address, which carries the shortcode, so renaming a shortcode moves the
578
+ id too: both sides of the join move together, the match finds nothing, and the
579
+ rename is reported as a withdrawal with no successor named. It stayed exact for a
580
+ note that **pins** an `id` — but a pin has to be written _before_ the rename, by
581
+ an author who does not yet know they will make one.
582
+
583
+ An author who has just renamed a shortcode does know, so they say so:
584
+
585
+ ```yaml
586
+ type: weapongear
587
+ shortcode: Taburi
588
+ renamedFrom: Tabri
589
+ ```
590
+
591
+ **One shortcode or a list**, because renames chain and a released baseline may
592
+ know an address by a name two renames ago. **Transient**: once every baseline a
593
+ build is compared against post-dates the rename, the declaration may be deleted —
594
+ which is what separates it from an `id:` pin, which is permanent. **One key per
595
+ note, at the top level**, however many systems the note compiles into, since a
596
+ shortcode is the note's rather than a system block's.
597
+
598
+ **The diagnostic reports which join it had**, because the two are not equally
599
+ checkable — a matched id is a fact a reader can verify in both artefacts, while a
600
+ declaration is the author's word:
601
+
602
+ ```text
603
+ since sohl@0.8.2, weapongear:Tabri is no longer published; the note now
604
+ published as weapongear:Taburi declares it was renamed from Tabri. Every
605
+ package that resolves weapongear:Tabri breaks when it moves past sohl@0.8.2
606
+ ```
607
+
608
+ Three joins are tried, in that order of authority: the document id, then a
609
+ declaration, then nothing — which remains **withdrawn**. Nothing infers a
610
+ successor from a similar-looking string; a wrong one sends the reader to the
611
+ wrong fix.
612
+
613
+ **`content-lint` holds a declaration to the rules a current address is held to.**
614
+ An entry must be a well-formed shortcode, must not be the note's own, and must
615
+ name an address the package actually vacated: an entry naming an address some
616
+ note still publishes is refused, as are two notes claiming one predecessor, since
617
+ an address had one holder and so has one successor. A repeated entry is a
618
+ warning — the declaration still works.
619
+
620
+ **Also fixed: `addresses diff` threw whenever it had a finding to place.** It
621
+ called `noteFilesById` without `skipDirectories`, which `walkMarkdownTree`
622
+ refuses (#243), so the command worked only when it had nothing to report — the
623
+ one path nobody notices. Both of its tree reads now state the scope from the
624
+ resolved configuration.
625
+ - 4926fb8: **The `bundle` note type is specified and declared** (#259).
626
+
627
+ A bundle is a set of documents taken as a unit — Foundry's `Adventure`, named for
628
+ what it is rather than what Foundry calls it. It declares one property of its
629
+ own, `contents`: the documents it holds.
630
+
631
+ **How many Adventures a bundle makes is decided by its system blocks**, as for
632
+ every other type, rather than by a property of its own. With no system block it
633
+ is one Adventure holding only the `none` documents; with one or more it is one
634
+ Adventure per system, each holding every `none` document plus that system's own,
635
+ and a document of neither is silently left out.
636
+
637
+ Each Adventure is written to the pack the note's `pack` names — **the shared
638
+ routing field, not a property of the bundle**, so there is one spelling and not
639
+ two. It differs only in its default, `adventures`; `<system>.pack` overrides it
640
+ per system exactly as it does everywhere else.
641
+
642
+ That follows from Foundry rather than from taste: **an `Adventure` has no
643
+ `system` field**. A bundle spanning two systems cannot be one document that knows
644
+ it spans them, so it is one document per system and the pack each is written to
645
+ is what carries the system.
646
+
647
+ **It is not a folder**, and the difference is the whole point: an Adventure
648
+ carries **copies**, and importing one creates or updates each document in the
649
+ world, after which they live independently. A folder is a live grouping, by
650
+ reference, that persists in the pack.
651
+
652
+ **Nothing compiles a bundle yet**, and authoring one says so. Two decisions come
653
+ first: the scenes pass already writes an Adventure per place into a _companion_
654
+ pack and the router refuses a note naming a companion in `pack:`, so the
655
+ `adventures` default cannot be that pack as things stand; and since an Adventure
656
+ holds compiled documents rather than references, `contents` has to resolve after
657
+ the passes that produce them.
658
+ - 0df2870: **Every declared note type is routed, or excused for a stated reason** (#243,
659
+ #241) — asserted statically, of the toolchain, so it no longer depends on some
660
+ repository happening to author the type.
661
+
662
+ This is the check #241 needed and nobody had. `place`, `lore` and `scenario`
663
+ were declared, validated, and claimed by no pass; every gate reported success,
664
+ and the only thing that noticed was a downstream repository failing to compile
665
+ 450 notes. The claim table was already cross-checked against each pass's
666
+ `selects`, but that agreement holds just as well when **both** say nobody claims
667
+ a type — which was exactly the broken state. The missing property is not
668
+ agreement, it is **coverage**.
669
+
670
+ A declared type must now be one of four things, and the four name different
671
+ reasons rather than being interchangeable: claimed by a pass; **never packed**
672
+ (compiles to no document — `homepage`); **derived packed** (materialises by
673
+ reference in every pack that references it, so no one pass owns it — `folder`);
674
+ or declared by a shipped **system map**, so a configuration carrying that
675
+ system's packs claims it (`armorlocation`, which is HM3's).
676
+
677
+ **`UNIMPLEMENTED_TYPES` is new, and it is stated rather than inferred.** An
678
+ unimplemented type and a forgotten one look identical from outside: documented,
679
+ validating, reaching no pass. Only intent separates them, so intent is written
680
+ down. The obvious inference — "declared, but absent from the configured
681
+ vocabulary" — reads correctly and is worthless, because that vocabulary is
682
+ _derived from the routing_: take a type's route away and it leaves the
683
+ vocabulary too, so the inference excuses precisely the mistake it was meant to
684
+ catch. That was verified by putting `place` back into its #241 state, where the
685
+ inferred form passed and the stated form fails.
686
+
687
+ `vehicle` is its one member, and `unclaimedNoteFindings` now chooses its
688
+ "specified, not implemented" wording from the same set rather than from a second
689
+ reading of the same fact.
690
+
691
+ _No behaviour change: over `sohl`'s tree the compile emits the same 3,085
692
+ documents with identical diagnostics, and `lint` reports the same 354 findings._
693
+ - adb2e90: **A folder is a note** (#256), and `packFolder:` names one by **address** (#255).
694
+
695
+ ```yaml
696
+ ---
697
+ type: folder
698
+ shortcode: possessionscooking
699
+ name:
700
+ full: Cooking
701
+ data:
702
+ parent: possessionsmiscgear
703
+ color: "#7a4b2a"
704
+ ---
705
+ ```
706
+
707
+ ```yaml
708
+ packFolder: possessionscooking # on any note that files itself there
709
+ ```
710
+
711
+ A `Folder` was the last document this package compiled from bespoke
712
+ configuration — `*-folders.yaml`, five files per tree — rather than from a note.
713
+ That was the one hole in the rule #243 establishes, _the compiler follows the
714
+ index_: a pass cannot follow the index for things the index does not contain.
715
+
716
+ **`parent` is an address**, so a dangling one is an ordinary dead-address finding
717
+ rather than a special-cased `Unknown folder id`, and a cycle is refused. Both are
718
+ reported when the tree is read, not when something happens to reference the
719
+ folder that carries them. A folder is addressed `<package>-none-folder-<shortcode>`
720
+ — `none`, because a `Folder` is a core Foundry document like a `JournalEntry`,
721
+ not a system's.
722
+
723
+ **`parent` may be a map keyed by pack.** A folder's identity is one thing and its
724
+ hierarchy is another: the same folder is deliberately filed under different
725
+ parents in different packs, and both large trees rely on it. This repository
726
+ files its three item roots one level deeper in the journals pack (under
727
+ `Rules/Descriptions`, beside `Rules/Combat`); `sohl-thalorna` groups the items
728
+ pack by document kind and the journals pack by setting geography, and 46 of its
729
+ 75 shared folders differ. A scalar — the everyday spelling — is exactly
730
+ `{ default: <value> }`, and the folder keeps one id across every pack whatever
731
+ its parent there.
732
+
733
+ **Where a folder materialises is derived from what references it** (#257). Every
734
+ pack holding a document that names a folder gets that folder, and its ancestors
735
+ with it; a folder nothing references materialises nowhere.
736
+
737
+ That removes a live defect rather than reporting it. A documentation journal is
738
+ filed beside the item it describes by putting the item's folder id into the
739
+ _journals_ pack — which only worked where a second folder file mirrored the
740
+ first, and it mirrored in one tree of three:
741
+
742
+ | tree | item folders | in journal folders | missing |
743
+ | --------------------------------- | -----------: | -----------------: | -------------: |
744
+ | `Song-of-Heroic-Lands-FoundryVTT` | 57 | 57 | 0 |
745
+ | `sohl-thalorna` | 132 | 75 | **57** |
746
+ | `sohl-kethira-basic` | 6 | — | **6**, no file |
747
+
748
+ Both emitted documentation journals into folders their own pack never declared,
749
+ silently. With one folder note and one address there is no second file to
750
+ disagree with the first, so the failure is unrepresentable rather than merely
751
+ caught.
752
+
753
+ **A folder's Foundry `_id` is derived from its address** (#258), stable across
754
+ runs, so a new folder needs no invented id. An **authored `id` is kept** where
755
+ one is present — which is what lets a tree sweep its folder YAML into notes
756
+ without a world that already holds those folders losing them, making this a build
757
+ change rather than a world migration. Two folders claiming one id is a build
758
+ error.
759
+
760
+ **`packFolder` was a path for one release and never shipped as one.** #252 landed
761
+ `Possessions/Misc_Gear/Cooking` and its changeset is still pending, so no
762
+ released version ever read a path. A path encoded the hierarchy _in the value_,
763
+ so reparenting a folder rewrote every note naming it; an address is stable under
764
+ reparenting, which is why a note is addressed by `(type, shortcode)` and never by
765
+ `file.path`. The path form is removed rather than deprecated — it had no authors
766
+ to migrate, which is the whole reason the change was cheap enough to make.
767
+
768
+ **`folder:` is untouched**, and every tree still compiles from its
769
+ `*-folders.yaml` exactly as before: the SoHL tree's 3,094 compiled documents are
770
+ byte-identical across this change. Retiring the id spelling and the YAML schema
771
+ is #260, after each tree has swept.
772
+ - 72ce6c2: **The HM3 pass reads the template priority wherever a note states it** (#266).
773
+
774
+ `flags.hm3.templatePriority` was resolved as an ordinary declared field, whose
775
+ shared source is a **single** position — so only a bare top-level
776
+ `templatePriority` ever answered. The one position that did not work was
777
+ `data.templatePriority`: the specified home, the target of the settled mapping
778
+ table, and the home this pass's own docstring already claimed to read.
779
+
780
+ **It failed silently, and could only fail silently.** A note that is not a
781
+ template writes no flag, so an omitted flag is how "not a template" is spelled —
782
+ which makes a priority that was lost and a priority that was deliberately
783
+ withheld the same output. There is no tri-state left for a diagnostic to notice,
784
+ and nothing downstream can tell the two apart.
785
+
786
+ The priority is a **shared, note-level fact** — one statement both systems
787
+ record, SoHL as `system.templatePriority` and HM3 as
788
+ `flags.hm3.templatePriority` — so it is now read through the same resolver the
789
+ SoHL passes use, against the block being compiled: `data:`, this system's block,
790
+ the top level, and the retiring `archetype` spelling in the latter two. A note
791
+ carrying both spellings with different values is refused here exactly as it is
792
+ for SoHL, so the two systems cannot disagree about what a note said.
793
+
794
+ **It is read against _this_ block, not the `sohl:` one.** A tree still stating
795
+ the priority in `sohl:` writes no HM3 flag — `harn-ensemble` is that tree, on
796
+ 2,502 notes — and gets one when it sweeps to `data:`, which is step 2 of #266's
797
+ migration. This change is the prerequisite for that sweep rather than a
798
+ substitute for it: without it, a tree that swept to the specified home would
799
+ have gone from a flag that worked by accident to no flag at all.
800
+
801
+ `resolveTemplatePriority` takes the block as an option, and
802
+ `statedTemplatePriority` is its tolerant sibling for a system that treats an
803
+ unstated priority as "not a template" rather than as an authoring error.
804
+ - 209e633: **The `hm3/` half of the toolchain** — a note can now compile an HM3 document (#139).
805
+
806
+ `sohl/` was the only system half this package had, so `itemBuilders: [sohl, hm3]` — an
807
+ arrangement `CONTENT.md` already documented — named a registry that did not exist, and no
808
+ note could produce an HM3 Actor or Item however its frontmatter was written. `hm3/` is now
809
+ a sibling of `sohl/`: its own item vocabulary and builders, its own default art, its own
810
+ note-type → document-subtype map declared through the same `defineDocumentSubtypes`, and
811
+ its own Item and Actor compilers. The two halves import nothing from each other; the only
812
+ thing they share is the engine between them.
813
+
814
+ **A pack's `system:` now selects the compiler.** It already selected the `_stats` stamp, the
815
+ item catalogue a being resolves against and the `itemBuilders` lookup; the Item and Actor
816
+ passes were still SoHL's whatever a pack declared. So a note carrying both a `sohl:` and an
817
+ `hm3:` block compiles **one document in each system**, each shaped by its own builders and
818
+ stamped with its own system version — and a note carrying only one block is passed over by
819
+ the other system's pass rather than failed for a block it was never going to have.
820
+
821
+ **Four HM3 rows are one-to-many, and the note says which.** `mysticalability` becomes a
822
+ `psionic`, a `spell` or an `invocation`; `trauma` an `injury` or a `trait`; `weapongear` a
823
+ `weapongear` or a `missilegear`; `being` a `character` or a `creature`. The note writes
824
+ `hm3.type`; nothing is inferred from its `subType`, and a note that says nothing is an error
825
+ naming the note and listing the permitted values. `docs/content-format.md` is corrected to
826
+ match — it described the `mysticalability` split as derived from a `subType` vocabulary that
827
+ no longer contains the values the derivation named.
828
+
829
+ **The five shared names cannot cross over.** `skill`, `weapongear`, `armorgear`,
830
+ `containergear` and `miscgear` exist in both systems with different data models, so each
831
+ resolves through its own system's map, is built by its own system's registry, and is
832
+ field-checked against **its own** system's published `schema.json`. That last one is new:
833
+ `resolveSchemaArtifact` took the package-wide `stats.systemId`, which is deliberately unset
834
+ in a two-system build — so every schema check in such a build was skipped in silence.
835
+
836
+ **Shared machinery moved to `engine/`, unchanged.** `engine/item-compiler.mjs` and
837
+ `engine/actor-compiler.mjs` now hold what is note-format knowledge rather than game-system
838
+ knowledge, and `engine/anchored-sections.mjs` holds the `{#appearance}` / `{#dossier}`
839
+ convention. `sohl/items.mjs` and `sohl/actors.mjs` are what is left: SoHL's map, and the
840
+ `system` block SoHL's data model wants. Compiled output is byte-identical — verified by
841
+ recompiling `Song-of-Heroic-Lands-FoundryVTT`, `sohl-thalorna` and `sohl-kethira-basic`
842
+ before and after and diffing every emitted document.
843
+
844
+ `npm run lint` now also checks HM3's column of the content format: the specification's
845
+ `→ hm3` mapping claims against HM3's published `schema.json`, and its per-type tables
846
+ against the new field declarations.
847
+ - f2e6e45: **The link check reads the content index instead of walking the tree itself**
848
+ (#243) — the first reader converted, and the one #243 nominated.
849
+
850
+ `buildLinkIndex` answered "which files are the content?" for itself: its own
851
+ walk, its own frontmatter parse, its own address derivation. That is the shape
852
+ #243 is closing — ten passes each deriving the corpus independently, agreeing
853
+ only by inspection. It now reads
854
+ {@link module:engine/content-index.indexRecordsFor}, the same derivation the
855
+ published artifact, the `sql` tables and the compilers already run on.
856
+
857
+ **It still opens each note — for its prose, and nothing else.** The index
858
+ deliberately carries no body, and a link lives in the body. Everything _about_
859
+ the note is in the record. That is one read per note rather than two: the walk
860
+ read the file, and this module then read it again for the raw text.
861
+
862
+ **Two defects go with it.**
863
+
864
+ - _The package a local address carries came from the ambient configuration._
865
+ `buildLinkIndex` was handed a `config` and then called `contentPackage()`,
866
+ which resolves `loadPackConfig()` from the working directory. The two are the
867
+ same object in an ordinary build and different ones under
868
+ `PACKAGE_BUILD_CONFIG`, in a worktree, or in a test — so the checker could
869
+ build canonical addresses for one package while the manifest it was checking
870
+ built them for another, and every cross-package link would resolve nowhere for
871
+ no visible reason. The package now comes from the configuration the caller
872
+ passed.
873
+ - _Each command derived the corpus twice._ `lint`, `links` and `reachability`
874
+ each built a link index _and_ prepared their `sql` tables, and both walked. The
875
+ records are now derived once per command and handed to both, so a table and a
876
+ wikilink cannot disagree about which notes exist. `reachability` also resolved
877
+ its configuration twice and passed neither to the passes below it; it resolves
878
+ one and passes it on.
879
+
880
+ **Findings are unchanged, and their order is now stable.** Over `sohl`'s 1,685
881
+ notes the `links` findings are identical as a set and `lint`'s output is
882
+ identical byte for byte; what moved is that diagnostics now come out in content
883
+ path order rather than directory-read order, which was never a fact about the
884
+ content.
885
+
886
+ `indexRecordsFor` takes the walk's scope as an argument, so a caller that was
887
+ handed one passes it on rather than having it replaced by whichever its
888
+ configuration carries; `prepareTreeSqlTables` and `buildLinkIndex` take
889
+ already-derived `records`. `authoredFrontmatter` and `isNoteRecord` are exported
890
+ beside `DERIVED_KEYS`: a record is a note's frontmatter _plus_ the derived keys,
891
+ and a note authoring one of them fails the walk, so recovering what the author
892
+ wrote is exact rather than best-effort — which is what lets a pass read the
893
+ corpus from the index and still lint what was typed.
894
+ - d17dc98: **`package-build yaml` lints note frontmatter and every YAML file** (#248).
895
+
896
+ Frontmatter carries a note's type, shortcode, address and system blocks, and
897
+ nothing checked it _as YAML_. Worse than unchecked: `parseMarkdownFile` caught a
898
+ parse failure, logged it at `warn`, and returned `{frontmatter: null}` — which is
899
+ not a note with bad frontmatter but, to every pass downstream, a file with no
900
+ frontmatter. A duplicate key did not fail a build; it removed a note from the
901
+ corpus while the build reported success. The parser had detected it all along.
902
+
903
+ **Frontmatter reaches ESLint through a processor**, the mechanism
904
+ `eslint-plugin-markdown` uses for fenced code blocks. Frontmatter is its easy
905
+ case — the block is always at the top of the file, so a finding maps back with a
906
+ constant `+1` for the opening `---` and no offset table.
907
+
908
+ **The rule set is deliberately narrow**, as the markdown and stylesheet ones are.
909
+ Prettier already owns YAML's whitespace, quoting and line breaks, including
910
+ inside a fence, so what is left is the class a formatter cannot see: text that
911
+ parses to something other than what it looks like. Parse errors, plus
912
+ `no-empty-mapping-value`, `no-irregular-whitespace`, `no-empty-key` and
913
+ `no-empty-document`. `folder:` and `folder: null` are one value and two opposite
914
+ statements — a decision, or a key somebody began and did not finish — and a key
915
+ with a block under it is not empty.
916
+
917
+ **GitHub workflows are exempt from the empty-value rule.** `on:` `push:` carries
918
+ its meaning by being present; `push: null` would be worse YAML, not better.
919
+
920
+ **A consumer changes one line** — `"lint:yaml": "package-build yaml"` — and needs
921
+ no `eslint` dependency, no `eslint.config.js` and no rule configuration.
922
+ package-build owns the tool and the config exactly as it owns markdownlint's, and
923
+ `overrideConfigFile: true` leaves a repository's own ESLint unconsulted.
924
+
925
+ Adoption costs 92 findings in total: 65 in `sohl-thalorna`, 20 in
926
+ `sohl-kethira-basic`, 5 in `harn-ensemble`, 2 in
927
+ `Song-of-Heroic-Lands-FoundryVTT`, none in `harn-adventures`. No live content
928
+ tree carries a parse error — every one found was in a `nogit/` archive — so this
929
+ is a missing guard rather than an overdue one.
930
+ - 6368283: **The template priority is read as `templatePriority`, and `archetype` is
931
+ retiring** (#266).
932
+
933
+ The number deciding which of several competing templates the Create dialog
934
+ offers was called `archetype` — one letter from `archetypes`, which is a list of
935
+ what _sort_ a character is. A priority and a taxonomy cannot be told apart by a
936
+ plural `s`.
937
+
938
+ Both spellings are read, `templatePriority` winning, and **the retiring one is a
939
+ lint error**. Unlike the other retired alias, `archetype` is not a field of its
940
+ own — it is `templatePriority` under its prior name, and both sit one letter from
941
+ `archetypes`, which means something else entirely. A tree still on it is one
942
+ where a priority and a taxonomy are told apart by a plural `s`, which is worth
943
+ stopping rather than mentioning.
944
+
945
+ The compile is unaffected — both spellings are read, so every tree keeps
946
+ compiling — but `content-build lint` refuses a tree until it is swept, and
947
+ **5,727 notes across four trees** author the old key.
948
+
949
+ It is read from `data.templatePriority` first, which is where the specification
950
+ puts it and where `sohl-thalorna` already writes it on 941 notes — so a tree that
951
+ has authored forward is read from the key it authored.
952
+
953
+ **A note declaring both spellings with different values is refused.** That is not
954
+ hypothetical: **145 of those 941 say `templatePriority: null` where `archetype:
955
+ 0` says the opposite** — "not a template" against "a template at priority 0".
956
+ Preferring either silently would decide that for the author, so the build names
957
+ both values and asks. `0` and `null` are distinct and both valid, which is
958
+ exactly why a note cannot claim both.
959
+
960
+ The emitted field is unchanged: SoHL's data model still declares
961
+ `system.archetype`, and `Song-of-Heroic-Lands-FoundryVTT#1836` renames it there.
962
+ - 18bb812: **The walk's scope is stated by its caller, never resolved from the working
963
+ directory** (#243).
964
+
965
+ `walkMarkdownTree` defaulted `skipDirectories` to `loadPackConfig()`'s — so an
966
+ unscoped caller read whichever configuration resolved from the working directory
967
+ rather than the one it was working under. **Six of its twelve callers were on
968
+ that default**, which means two passes over one tree could disagree about which
969
+ files they were reading. In an ordinary build those are the same object and
970
+ nothing shows; they are not the same when a test injects a configuration, when
971
+ `PACKAGE_BUILD_CONFIG` names one, or when a command runs from a worktree.
972
+
973
+ This is the defect class #240 fixed for `entriesForNote` reading `docEntryTypes`
974
+ from the ambient config — found only because a fixture had been passing on the
975
+ leak for as long as it existed.
976
+
977
+ The parameter is now **required**, so the omission is an error rather than a
978
+ quiet second answer, and `BasePackCompiler` requires it too: a pass that omitted
979
+ it would walk whatever the working directory said, including — in a tree
980
+ configured to skip `Templates`, as `Song-of-Heroic-Lands-FoundryVTT` is — the
981
+ template notes that configuration exists to keep out of the packs.
982
+
983
+ `Scenes` was dropping it between its own constructor and `super`, which is
984
+ exactly the kind of silent gap a default hides and a requirement does not.
985
+
986
+ _No consumer calls `walkMarkdownTree`, so this is internal despite the signature
987
+ change._
988
+ - a5922b7: **The five declarations that re-read the note now see `<system>.system`**
989
+ (#126) — the last mechanism gap before the corpus can move.
990
+
991
+ Most fields take the value `resolveFieldValue` hands them, so they already
992
+ resolve at `<system>.system.<to>` first. Five do not. `subType`, `charges`, a
993
+ mystery's `skillAptitudes`, an affiliation's `relations` and a projectile's
994
+ impact die each validate a **shape spread over several keys**, so their `read`
995
+ re-reads the frontmatter — through `sohlField`, which sees `sohl.<key>` and the
996
+ note's top level and **never inside `sohl.system`**.
997
+
998
+ That was equivalent while every note authored in the block. The moment a note
999
+ authors at the destination instead, those five read as unset: a missing
1000
+ `subType` is a thrown build error, and `charges`, `skillAptitudes` and
1001
+ `relations` ship empty — in silence, which is the failure class the passthrough
1002
+ exists to prevent.
1003
+
1004
+ `sohlSystemField` reads the destination first and falls back to the legacy
1005
+ position, so both spellings work while the corpus moves. It takes an optional
1006
+ `legacyKey` for the one pair spelled differently at the two positions — a
1007
+ projectile authors `impact.die` and stores `impactBase.die` — which is the same
1008
+ split `FieldSpec.name`/`legacyKey` makes, for the same reason: one name cannot
1009
+ key two positions.
1010
+
1011
+ The retired-alias probe in `resolveRelation` moves with it. It asked
1012
+ `sohlField` whether `relations` was authored in order to choose between the
1013
+ current and retired spelling; a note that had moved to `sohl.system.relations`
1014
+ carried the current name where the probe could not see it, so it fell through to
1015
+ `relation` and read `{}`.
1016
+
1017
+ Nothing else changes: every note authoring in the block reads exactly as before,
1018
+ which the suite pins alongside the new position.
1019
+ - f2bf737: **A note the content index cannot record is reported, not thrown** (#243) —
1020
+ with a line, a column, and the right correction.
1021
+
1022
+ Converting the link check (#290) and the address diff (#292) to read the index
1023
+ handed those passes the index's one hard failure. A note authoring a key the
1024
+ index derives — {@link DERIVED_KEYS} — aborted the whole pass, so **one
1025
+ malformed note took every other finding in the tree with it**, and the reader
1026
+ got a bare `Error` where the project's other diagnostics give
1027
+ `file:line:column: severity: message`. This is precisely what #243 lists as the
1028
+ thing that must not regress, and it did.
1029
+
1030
+ Concretely, over a tree holding a legacy `package:` note and an unrelated dead
1031
+ link:
1032
+
1033
+ | | reported |
1034
+ | ----------------------- | -------------------------------------------------------------------- |
1035
+ | _before the conversion_ | the dead link; nothing about `package:` |
1036
+ | _after it_ | ``Skills/Legacy.md: `package:` is derived …`` — and **nothing else** |
1037
+ | _now_ | both, each with its position |
1038
+
1039
+ **A reader collects; the emitter still refuses.** `collectContentIndex` and
1040
+ `indexRecordsFor` take a `problems` array: given one, a note that cannot be
1041
+ recorded is pushed as a diagnostic and skipped, and the derivation continues.
1042
+ Given none they throw exactly as before — which is the contract
1043
+ `emitContentIndex` needs, since an index quietly missing a note asserts that the
1044
+ note does not exist. `buildLinkIndex`, `declaredPredecessors` and
1045
+ `noteFilesById` pass the array through, and `lint`, `links`, `reachability` and
1046
+ `addresses diff` emit what it collects and exit non-zero — an error whatever the
1047
+ command's own strictness flag says, because the note is absent from every answer
1048
+ those commands give.
1049
+
1050
+ **`package:` gets its own words back.** It is on the derived list, but it is not
1051
+ a name collision — it is a **retired field** (#56), and the fix is to delete it,
1052
+ not to rename it. `assertNoDeclaredPackage` has said so, correctly and with a
1053
+ position, since the field was retired, and had **no caller**: the generic
1054
+ "rename the frontmatter field" was the only message anyone saw, and it was
1055
+ wrong. The index now defers to it, so one mistake has one message rather than
1056
+ two that disagree about the fix.
1057
+
1058
+ _No change to any tree that has no such note: `sohl`'s 1,685 notes produce
1059
+ byte-identical `links`, `lint` and address-map output._
1060
+ - d17dc98: **`place`, `lore` and `scenario` compile into JournalEntries.** They are in the
1061
+ published content format and #233 declared them for validation, but nothing
1062
+ routed them: `PACK_BY_TYPE` did not name them, so the open-set default sent
1063
+ them to the items pack, and the journals pass did not select them. A note of
1064
+ one lint-ed clean and then compiled into nothing (#241).
1065
+
1066
+ `sohl-thalorna` could not compile a single pack for exactly this reason — 450
1067
+ errors, and _the same 450_ the linter had reported before it learned the types.
1068
+ The count being identical is the tell: nothing about the content changed, only
1069
+ which gate noticed. It now compiles 642 actors, and what remains are unrelated
1070
+ content faults.
1071
+
1072
+ The three are declared once, as `JOURNAL_TYPES` in `engine/ids.mjs`, and read
1073
+ from there by the pack router, the journals pass and the claim table — so the
1074
+ three cannot disagree about what a journal type is. The drift guard that
1075
+ asserts the claim table and each pass's `selects` agree is what caught them
1076
+ disagreeing while this was written.
1077
+
1078
+ **They carry no synthesized `doc<type>` entry**, which is the distinction the
1079
+ new name makes explicit: a journal type's whole document _is_ the journal, so
1080
+ there is no second document to address and nothing spells `docplace`. That is
1081
+ different from an item, a macro or a map, whose prose becomes a journal
1082
+ _beside_ another document and is addressed as `doc<type>`.
1083
+ - 10522bd: **State the shared mapping rows, and let the checker reach them (#275).**
1084
+
1085
+ `docs/content-format.md` § _Mappings every type shares_ promised eight rows that
1086
+ every one of the sixteen per-type tables omits "on the stated grounds that they
1087
+ appear here", and then stated none: both tables were header-only, from the commit
1088
+ that first committed the specification. The fields they cover were therefore
1089
+ specified nowhere, and the note pointing at them pointed at nothing.
1090
+
1091
+ Both tables now carry their rows. The shared table states `name.full`, `img`,
1092
+ `id`, `packFolder` / `folder`, `shortcode`, `data.templatePriority`, `actionDefs`
1093
+ and `notes`; the actor-types table states `data.portrait`. `being` and `vehicle`
1094
+ had restated `data.portrait` and `data.templatePriority` in their own tables, and
1095
+ no longer do — that duplication is what the section exists to remove.
1096
+
1097
+ **The rows are confirmed rather than asserted.** `parseContentFormat` read
1098
+ mapping tables only inside a `### type:` section, so a table standing before the
1099
+ first one was invisible and its rows were checked by nothing. A mapping table in
1100
+ that position is now the shared one — position is the whole distinction, since
1101
+ the document's own argument for stating these once is that they belong to no type
1102
+ in particular — and its rows become claims like any other.
1103
+ `npm run lint:content-format:schema` confirms 86 where it confirmed 84, the seven
1104
+ new ones being the shared tables' `system.*` targets.
1105
+
1106
+ A shared claim carries `shared: true` and is scoped to `the shared mappings`
1107
+ rather than to a type, which keeps it out of the per-type field-drift check (it
1108
+ has no field declaration to drift from) and reads as prose in a diagnostic:
1109
+ _the format maps `shortcode` in the shared mappings to `system.notAField` in
1110
+ sohl_.
1111
+
1112
+ Two asymmetries the rows exposed are stated beside them rather than smoothed
1113
+ over: `actionDefs` and `notes` are declared on every SoHL Item subtype and on no
1114
+ SoHL Actor, and SoHL's `system.docHtml` is an Item mapping with no shared source
1115
+ to be a row of. A third — HM3 carrying `flags.hm3.templatePriority` on an Actor
1116
+ and not on an Item — is a gap in the pass rather than in the table, and is
1117
+ tracked as #283.
1118
+ - 941b672: **A field's shared source and its legacy in-block key are two declarations**
1119
+ (#305) — so a field can move into `data:` without a flag day.
1120
+
1121
+ `FieldSpec.name` carried both jobs, and they came apart the moment `data:`
1122
+ (#128) put every type-specific fact under a container. `resolveFieldValue` reads
1123
+ four positions, and steps 2 and 3 were **both keyed on `name`**:
1124
+
1125
+ | step | position |
1126
+ | ---- | --------------------------- |
1127
+ | 1 | `<block>.system.<to>` |
1128
+ | 2 | `<block>.<name>` — in-block |
1129
+ | 3 | the shared source `<name>` |
1130
+ | 4 | the field's default |
1131
+
1132
+ So `name: "species"` reached `hm3.species` and could not see `data.species`,
1133
+ while `name: "data.species"` reached the shared source and could not see
1134
+ `hm3.species` — and each yielded the field's **default** wherever only the other
1135
+ position was authored. Silently: the field compiles, the document is emitted,
1136
+ and the value is simply gone.
1137
+
1138
+ Two things followed. The specification's shared→system mapping was implemented
1139
+ _nowhere_ — `grep 'name: "data\.'` returned zero hits across all 58 declarations,
1140
+ so rows the format states (`data.species`, `data.gender`, `data.occupation`)
1141
+ were read by nothing. And no safe transition existed: every other retirement here
1142
+ — `package:`, `image`, `archetype`, `relation` — works because **both spellings
1143
+ are read while the corpus moves**, and one property could not offer that.
1144
+
1145
+ **`legacyKey` separates them.** `name` is the shared source; `legacyKey` is the
1146
+ key the system block still carries, and step 2 keys on it. Absent, it falls back
1147
+ to `name`, so every declaration written before this resolves unchanged.
1148
+
1149
+ - **Both are read, the current position wins.** A note that has not been swept
1150
+ is still saying what it means.
1151
+ - **The legacy read is _reported_**, as a warning — at compile through
1152
+ `legacyKeyMessage`, and in the frontmatter lint — so a sweep has a progress
1153
+ signal, matching `RETIRED_FIELD_ALIASES`. A field that declares no `legacyKey`
1154
+ is not mid-sweep and is never reported; that would put a finding on every
1155
+ field of every note.
1156
+ - The lint accepts the legacy key as a key of the block, rather than reporting
1157
+ `sohl.species` as a property no `being` has against exactly the notes the
1158
+ sweep has not reached.
1159
+ - The format check normalizes `data.` on **both** sides, so a moved declaration
1160
+ still pairs with the specification row that states it.
1161
+
1162
+ **HM3's three actor rows are declared as the sources the format names** —
1163
+ `data.species`, `data.gender`, `data.occupation` — while keeping the in-block
1164
+ key every note writes. Measured over the four content trees: 2,512 `being` notes
1165
+ compile byte-identical to before, and all 2,512 read the legacy position, which
1166
+ is the count #126 has to take to zero.
1167
+
1168
+ This is the mechanism the `data:` half of #126 and all of #129 were blocked on.
1169
+ - 4926fb8: **A note whose type the format specifies but nothing compiles gets a finding of
1170
+ its own.**
1171
+
1172
+ The unclaimed-type check had two messages, and a third thing can be wrong — the
1173
+ only one that is not the author's fault. `docs/content-format.md` documents the
1174
+ type and the vocabulary declares its properties, so a note written against the
1175
+ published specification is correct; this toolchain simply has not implemented it
1176
+ yet.
1177
+
1178
+ It earns its own wording because the other two both mislead there. Naming a
1179
+ missing pack or registry sends an author to `package-build.config.yaml`, where
1180
+ nothing they can write will help; saying the type is unknown flatly contradicts
1181
+ the specification they read it in.
1182
+
1183
+ The message is chosen from the vocabulary rather than from a list of types, so
1184
+ each specified-but-unimplemented type is covered as it is declared.
1185
+ - 28ae4b3: **A content table can be written in SQL, queried over the content index** (#246).
1186
+
1187
+ Tables were written in Dataview's query language, chosen when the corpus lived in
1188
+ an Obsidian vault so a table rendered live while authoring. The vault is gone, and
1189
+ what remained was a hand-written parser and evaluator for someone else's language,
1190
+ kept faithful to semantics nothing checked it against.
1191
+
1192
+ The query is **real SQL, run by DuckDB** — not a dialect maintained here. That is
1193
+ the point: a partial reimplementation would accept some valid SQL and silently
1194
+ misread the rest, which is worse than an unfamiliar language because the boundary
1195
+ is invisible.
1196
+
1197
+ ```sql
1198
+ SELECT address.slug AS _ref,
1199
+ sohl.kbcat AS _section,
1200
+ name.full AS "Name",
1201
+ sohl.weight AS "Weight"
1202
+ FROM notes
1203
+ WHERE type = 'miscgear'
1204
+ ORDER BY sohl.kbcat, name.full
1205
+ ```
1206
+
1207
+ **`sohl.weight` and `name.full` read in a query exactly as a note authors them.**
1208
+ DuckDB reads the index as JSON and infers a `STRUCT` per nested object; a
1209
+ column-per-path table would force `"sohl.weight"` in quotes and a JSON column
1210
+ `sohl->>'weight'`. `union_by_name` is what makes it work across a corpus where
1211
+ every note type's system block differs.
1212
+
1213
+ **What SQL cannot say, the projection says.** Which column links, and where a
1214
+ section breaks, are decisions about output rather than relational operations, so
1215
+ they ride as underscore-prefixed aliases — `_ref` and `_section` — which are
1216
+ ordinary SQL, need no fence options, and sit where the author is already looking.
1217
+ `_section` is why one query replaces the forty near-identical blocks
1218
+ `Rules/Gear.md` needs today: it emits a headed table per distinct value, in the
1219
+ order the authored `ORDER BY` produced.
1220
+
1221
+ `sql allow-empty` and `sql section-level=3` ride on the fence, as `dataview
1222
+ allow-empty` already does, because both are statements about the directive rather
1223
+ than part of the query.
1224
+
1225
+ **Both spellings work.** Every one of the 177 tables in the corpus is still
1226
+ `dataview`; those now also report a warning naming this issue, and nothing else
1227
+ changes for them. Nothing is opened for a tree with no `sql` directive, so this
1228
+ costs a tree that has not converted one walk and no database.
1229
+
1230
+ _DuckDB is a **build** dependency, not merely a development one — every
1231
+ consumer's CI runs a compile. It adds about 114MB to an install._
1232
+ - 9d89f31: **A `sql` content table takes org-babel header arguments, and a dependency's
1233
+ notes are a schema** (#246).
1234
+
1235
+ **Header arguments.** Statements _about the directive_ — as opposed to the query
1236
+ — were an ad-hoc bare word (`allow-empty`) and a `key=value`
1237
+ (`section-level=3`), each matched by its own regex: a grammar only in the sense
1238
+ that two regexes are one, with no room for a third property that did not also
1239
+ invent a third spelling. They are now org-babel header args, written after the
1240
+ language:
1241
+
1242
+ ````
1243
+ ​```sql :section-level 3 :allow-empty
1244
+ ````
1245
+
1246
+ **The language word stays first and stays plain**, so GitHub, Prettier and every
1247
+ other markdown reader still highlight the block as SQL and ignore what follows.
1248
+
1249
+ The grammar is org's, which is a real one with a specification and an editor
1250
+ that completes it: a key is `:name` starting a word (so `:caption Gear: the
1251
+ tables` is one argument); a value runs to the next key, spaces included; a
1252
+ valueless key is `true`; a value may be `"quoted"` to hold a key-like word; a
1253
+ repeated key takes its last value. `parseHeaderArgs` lives in `code-fences.mjs`
1254
+ and every argument reaches the caller, so a property this module makes no use of
1255
+ is still readable — the point of taking a grammar rather than a regex per
1256
+ property.
1257
+
1258
+ The `sql` fence has never shipped, so both old spellings are simply gone rather
1259
+ than retired. `dataview` keeps its bare `allow-empty`: it is the retiring
1260
+ language and its grammar is frozen.
1261
+
1262
+ **A dependency is a schema.** Each package this one depends on is attached as a
1263
+ schema named after it, so a satellite can tabulate what it builds on:
1264
+
1265
+ ```sql
1266
+ SELECT name.full AS "Name" FROM sohl.notes WHERE type = 'skill'
1267
+ ```
1268
+
1269
+ This package's own notes stay at the unqualified `notes`, and one query may read
1270
+ both — joining your beings against the skills they cite is a `FROM` clause. It
1271
+ costs no fetch and no configuration: every dependency's published index is
1272
+ already in the metadata cache when a compile starts, because resolving addresses
1273
+ across packages needs it.
1274
+
1275
+ Which dataset a query reads is `FROM`'s job, not a fence property naming a file.
1276
+ A path in authored content writes a build artifact's name into the corpus, so
1277
+ renaming the artifact would mean sweeping every note that cited it — and _which
1278
+ dataset_ is exactly what SQL already has a clause for, the same rule that keeps
1279
+ `_ref` and `_section` ordinary SQL rather than fence options.
1280
+ - 3bf9020: **The address lint reads the content index too** (#243) — so
1281
+ `content-build lint` is now **one** derivation of the corpus rather than two.
1282
+
1283
+ The command already derived the index: its link check is built from it and its
1284
+ `sql` tables select over it. Then it walked the whole tree a second time to
1285
+ reach `lintContentTree`. One command, two answers to "which files are the
1286
+ corpus?", and findings reported side by side that were drawn from different
1287
+ ones. The records are now derived once by the command and handed to every pass
1288
+ below it, this one included.
1289
+
1290
+ **It lints what the author wrote.** A record is the note's frontmatter plus the
1291
+ keys the index derives, so the frontmatter is recovered with
1292
+ `authoredFrontmatter` — handing a lint the derived keys would have it reasoning
1293
+ about `address:` and `anchors:` as though someone had typed them.
1294
+
1295
+ **Faster, for the same reason it is more correct.** Over `sohl`'s 1,685 notes
1296
+ `content-build lint` goes from about 1.9s to about 1.3s: the second whole-tree
1297
+ walk is gone. Findings are byte-identical.
1298
+
1299
+ `lintContentTree` takes `config`, already-derived `records`, and the `problems`
1300
+ collector, so a note the index cannot record is reported with its position and
1301
+ the rest of the tree is still linted — the contract every converted reader now
1302
+ shares. It refuses an unstated scope in the same words the others do.
1303
+ - 9972484: **A compile read every note twenty times. Now it reads it twelve** (#243) —
1304
+ and the whole compile runs on one corpus rather than one per pass.
1305
+
1306
+ Measured over `sohl`'s 1,685 notes: **33,700 note reads**, exactly twenty each.
1307
+ Four per pass — the content-wide link index, the table-search corpus, the `sql`
1308
+ directive scan, and the pass's own walk — across five passes that convert
1309
+ wikilinks. Every one of those four is a pure function of the same three things:
1310
+ the tree, the scope, and the pack router. **None of the three varies between the
1311
+ passes of a single compile**, because `generatePacksJson` resolves one router
1312
+ and hands it to all of them. So the passes were computing the same answers over
1313
+ and over, and — the part that matters — each was free to compute a _different_
1314
+ one.
1315
+
1316
+ They are derived once now, in `buildCompileCorpus`, and every pass is handed the
1317
+ result. The compile loop enumerates the index rather than walking, then reads
1318
+ each note for its **prose**: the index deliberately carries no body, and none of
1319
+ the `bodyLine`/`bodyColumn` a diagnostic needs, so that read stays — it is a
1320
+ read the pass was already making. What it no longer does is decide for itself
1321
+ which files to make it over.
1322
+
1323
+ | over `sohl`'s tree | before | after |
1324
+ | ------------------ | -------------------- | ------------------------- |
1325
+ | note reads | 33,700 (20 per note) | **20,220 (12 per note)** |
1326
+ | compile wall time | ~10.5s | **~6.7s** |
1327
+ | emitted documents | 3,091 | **3,091, byte-identical** |
1328
+ | diagnostics | 33 | **33, identical** |
1329
+
1330
+ **The record accessors move to `engine/index-records.mjs`.** Deriving the index
1331
+ reaches the pack router and the manifest emitter, and those reach the compilers
1332
+ — so `engine/helpers.mjs`, which the compilers load, cannot import
1333
+ `content-index.mjs` without closing a cycle. Nothing about _reading_ a record
1334
+ needs that machinery: `noteFile`, `authoredFrontmatter`, `isNoteRecord` and
1335
+ `DERIVED_KEYS` are pure functions over a plain object. `content-index.mjs`
1336
+ re-exports them, so the split is an implementation detail of the import graph
1337
+ rather than a second place to look.
1338
+
1339
+ `buildContentLinkIndex` and `collectContentDocs` now **require** the corpus
1340
+ their caller holds, refused by `assertSuppliedCorpus` the way an unstated scope
1341
+ is refused by `assertStatedScope`. That is not a workaround for the cycle: a
1342
+ compile runs several passes over one tree, and requiring the answer to be handed
1343
+ in makes the sharing structural rather than remembered.
1344
+
1345
+ **Two ambient-configuration reads go with it**, the same class as every other
1346
+ one #243 has turned up: `buildContentLinkIndex` derived each note's id through
1347
+ `resolveNoteId(fm)` with no package, and `collectContentDocs` synthesised each
1348
+ row's `package` through `searchableFrontmatter(fm)` with none — both falling
1349
+ back to whichever configuration the working directory answers with, rather than
1350
+ the one the build resolved.
1351
+
1352
+ A note the index cannot record is reported and counted by the compile exactly as
1353
+ the compile loop reported it when the loop was the first to see it — it is the
1354
+ same refusal, deferring to the same `assertNoDeclaredPackage`; only which pass
1355
+ meets the note first has changed.
1356
+ - 5a476cd: **The rest of the compile reads the index too** (#243). With #300 this takes a
1357
+ compile from **20 note reads each to 9**, and from ~10.5s to ~5.4s over `sohl`'s
1358
+ 1,685 notes — with all **3,091 emitted documents byte-identical** and the 33
1359
+ diagnostics unchanged.
1360
+
1361
+ Four whole-tree walks go:
1362
+
1363
+ - **The scenes pass** collected every note to find map notes and item effects.
1364
+ It reads the shared corpus, and opens a file only for a map note's prose —
1365
+ three files in `sohl` rather than 1,685.
1366
+ - **The unclaimed-type check** (`note-claims`) walked before any pass ran, so
1367
+ the check that reports "no pack claims this type" was answering about a
1368
+ different corpus from the one the passes then compiled.
1369
+ - **The `sql` directive scan** discovered which notes carry a directive by
1370
+ walking. It still reads each body — the index carries no note text — but
1371
+ _which files_ is no longer a second answer.
1372
+ - **The folder-note index**, which is the interesting one; see below.
1373
+
1374
+ **Three more ambient-configuration reads**, the same class as every one #243 has
1375
+ turned up: the scenes pass built a fresh `packRouter()` twice rather than using
1376
+ the compile's, and the folder-note index took its package from
1377
+ `contentPackage()` rather than from the configuration the build resolved.
1378
+
1379
+ **A trap worth naming, because the next conversion will meet it.** A record's
1380
+ `id` is not "the id the author wrote". The index fills one in for every
1381
+ addressable note (#270), and `collectFolderNotes` treats `fm.id` as an
1382
+ **authored pin** that wins over the id it derives under the folder namespace —
1383
+ so handing it records would make every folder look pinned and file each one
1384
+ under an id the packs do not address it by. `pack-folder` caught it. The index
1385
+ cannot tell a pin from a derivation, so the folder notes are read from disk:
1386
+ selected by the index, frontmatter from the file. That is the settled rule of
1387
+ #298 doing real work — the file carries what the index deliberately normalises
1388
+ away — and it is 79 reads, not 1,685.
1389
+
1390
+ `unclaimedNoteFindings` now requires the corpus its caller holds, like
1391
+ `buildContentLinkIndex` and `collectContentDocs`.
1392
+
1393
+ _`collectFoundryEntries` keeps its walk deliberately: it has no production
1394
+ caller — the link manifest it served was deleted in #271 — so converting it
1395
+ would change an exported signature for no compile it takes part in._
1396
+ - e0228f5: **The site build reads the content index, which completes #243's conversion.**
1397
+ Every pass that reads this repository's content tree — four checks, the whole
1398
+ compile, and now the site — runs on one derivation of the corpus.
1399
+
1400
+ The site walked the tree **twice**, once for homepages and once for content
1401
+ pages, and each answered "which files are the content?" for itself. It derives
1402
+ the corpus once and hands it to both. The note is still read for its
1403
+ `{fm, body}`: the index carries no note text, and a page _is_ its text.
1404
+
1405
+ **The reordering everyone was warned about does not exist.** This module kept
1406
+ directory order deliberately — "a site's emitted pages should not reorder for no
1407
+ reason" — and index records are content-path ordered, so converting it looked
1408
+ like a change to shipped output. It is not, and this was measured rather than
1409
+ argued: over `sohl`'s tree the emitted mount is **byte-identical, all 1,749
1410
+ files**, and the build's 82 diagnostics are identical **including their order**.
1411
+ Emission order never reaches the mount — each page is written to its own file at
1412
+ an address derived from its frontmatter, and the first-writer-wins fallbacks
1413
+ that once made order load-bearing went with the bare `[[Name]]` form (#180).
1414
+
1415
+ A content-path order is also the better of the two: directory-read order is a
1416
+ fact about the filesystem rather than about the content, so it can differ
1417
+ between two checkouts of one tree.
1418
+
1419
+ **`collectTreePages` is deliberately not converted.** It walks an auxiliary tree
1420
+ (`site.trees`, the developer documentation) which is not the content tree and
1421
+ appears in no record — converting it would be reading the wrong index.
1422
+
1423
+ _The two content-tree walks that remain are both right where they are: the
1424
+ content index's own derivation, and `collectFoundryEntries`, which has had no
1425
+ production caller since the link manifest was deleted in #271._
1426
+ - d17dc98: **`title: ""` is now a warning** (#218).
1427
+
1428
+ The two art fields follow a rule — `null` falls back, `""` is blank on purpose —
1429
+ and `title` was kept off it because the top-level key simultaneously fed an
1430
+ affiliation's `system.title`, so `title: null` compiled the literal `"null"`.
1431
+ That collision is gone: the field declares `topLevelMeans`, and the two spellings
1432
+ no longer meet.
1433
+
1434
+ What is left is the page heading. The emitter is `fm.title ?? name`, so `""`
1435
+ survives, the page publishes with no heading, and it sorts to the front of its
1436
+ section landing ahead of every named page. Fifteen notes in `sohl-thalorna` are
1437
+ in exactly that state.
1438
+
1439
+ A _warning_ rather than an error: the value is legal under the rule, and a page
1440
+ that genuinely wants no heading may keep it — it just has to mean it.
1441
+
1442
+ ### Patch Changes
1443
+
1444
+ - 72ce6c2: **`CONTENT.md` names the template priority by its settled name** (#266).
1445
+
1446
+ The shipped specification still described the field as `archetype`, written to
1447
+ `system.archetype`, and its worked example authored `archetype: 1` inside the
1448
+ `sohl:` block. All three were wrong in the same direction, and the example was
1449
+ the worst of them: it told an author to write the exact key the frontmatter
1450
+ linter now refuses, in a position that is no longer the field's home.
1451
+
1452
+ It now describes what the build does — `data.templatePriority` is the shared
1453
+ home, reaching `system.templatePriority` in SoHL and `flags.hm3.templatePriority`
1454
+ in HM3, with the legacy in-block and top-level positions still read and
1455
+ `archetype` read last and refused by the linter. The emitted-keys list names
1456
+ `templatePriority`, and the example authors it under `data:`.
1457
+
1458
+ The specification's one remaining use of `archetype` to mean the priority — an
1459
+ aside comparing a map's misplaced fields to it — says "the template priority"
1460
+ instead, since `archetypes` now means a different thing one letter away.
1461
+
1462
+ The schema fixture's comment no longer reads as though
1463
+ `Song-of-Heroic-Lands-FoundryVTT#1836` were outstanding. It is merged; the gap it
1464
+ describes closes when SoHL 0.8.4 publishes, since the newest published artifact
1465
+ (0.8.3) still declares `archetype`.
1466
+ - f97036b: **`kbcat` is documented** (#264), the last of the five universal keys the
1467
+ specification had never mentioned.
1468
+
1469
+ `pack` and the template priority were described in the previous release; `kbcat`
1470
+ was the sharpest of the three gaps and the one left. It is read 51 times across
1471
+ SoHL's knowledgebase layouts and authored on more than 1,300 notes, and
1472
+ `docs/content-format.md` said nothing about it at all — while already using
1473
+ `sohl.kbcat AS _section` as the worked example of a content table, so the
1474
+ specification demonstrated the key without ever defining it.
1475
+
1476
+ It is now described beside the compendium folder, as the one key in that section
1477
+ that answers _where does this appear_ for the web rather than for Foundry:
1478
+ `pack` and `packFolder` place a document in a compendium, `kbcat` places a page
1479
+ in a list. Nothing compiles it — it reaches a published page because frontmatter
1480
+ is copied onto that page, where a layout groups by it.
1481
+
1482
+ Three things an author cannot infer from the corpus are stated:
1483
+
1484
+ - **It is editorial, and independent of `subType`.** Neither is derived from the
1485
+ other, and `kbcat` both subdivides a subtype — `trauma`/`physcond` lists as
1486
+ `physdisability`, `physfeature` or `physprivations` — and renames one for
1487
+ display, `trauma`/`fear` listing under `phobias`. Most notes carrying a `kbcat`
1488
+ declare no `subType` at all, so a disagreement between the two is not an error
1489
+ to correct.
1490
+ - **The value is free-form and nothing validates it.** There is no configured
1491
+ list of categories, and the frontmatter check knows only that `kbcat` is a key
1492
+ every type may write. So a misspelled category is not a build error and is not
1493
+ dropped — it silently becomes a group of one.
1494
+ - **A note that writes none is dropped from the list entirely.** Grouping is by
1495
+ the key, so a page with no value falls in no group and is simply absent from
1496
+ the list page — not last, not under a fallback heading — with nothing reported
1497
+ at either build. Every note of a listed type in SoHL's tree carries one today
1498
+ and nothing here enforces that, which is one of the questions the content index
1499
+ exists to answer.
1500
+ - 09a2112: **`pack` and the template priority are documented** (#264).
1501
+
1502
+ `docs/content-format.md` is the published statement of what a note may write, and
1503
+ `pack` — one of the five universal keys, which every note type may write and the
1504
+ router has always read — appeared in it only as one incidental sentence inside
1505
+ another type's section.
1506
+
1507
+ It is now described where it belongs, beside the compendium folder: what it
1508
+ names, that `<system>.pack` overrides it for one system, that an unstated one
1509
+ falls back to the pack of its type marked `default: true`, and the three
1510
+ declarations that are refused — a companion pack, a pack nothing answers to, and
1511
+ a pack of another document type.
1512
+
1513
+ The **template priority** is documented too, and it needed more than a field
1514
+ description: it is a priority, and priorities only mean something against the
1515
+ ranges that divide them. Opening a Create dialog gathers candidates from the
1516
+ world and every matching compendium — other modules' included — filters them to
1517
+ the `(type, subType)` being created, dedups by `shortcode`, and takes the highest
1518
+ priority, breaking ties by nearest source and then a stable UUID. So the
1519
+ specification now states the reserved ranges: `0`–`98` for SoHL and HM3, `99`–`999`
1520
+ for other HeroicLands packages, `1000`+ for everyone else. Since the highest wins,
1521
+ anyone else's template always beats content shipped from here — which is the point.
1522
+
1523
+ It also records what the tri-state costs: the field is **required** on every note
1524
+ SoHL compiles into an Item or an Actor, because "not a template" has to be said
1525
+ rather than left out, and `0` is a real priority — the one SoHL's own templates
1526
+ ship at — rather than an absence. And that HM3 keeps it in `flags.hm3`, its data
1527
+ model having no field for it, where SoHL keeps it in `system`.
1528
+
1529
+ **The name is now `templatePriority` on all three sides** — the authored key,
1530
+ `system.templatePriority` and `flags.hm3.templatePriority`. It had been three
1531
+ different things: a note writes `archetype`, the mapping table said
1532
+ `data.templatePriority`, and the SoHL target said `system.template`. The
1533
+ specification and the schema fixture are normalized here; the build reads both
1534
+ spellings through the transition (#266), and the system's own rename is
1535
+ `Song-of-Heroic-Lands-FoundryVTT#1836`. That is more than a rename — it frees the
1536
+ word, because `archetype` is being repurposed for a different idea entirely: the
1537
+ _sort_ a character is, which is a kind and not a priority.
1538
+
1539
+ `kbcat` remains, and #264 tracks it: read 51 times across SoHL's knowledgebase
1540
+ layouts, and the specification has never mentioned it.
1541
+ - 0e8b414: **A folder's `parent` may be a map keyed by pack, and the lint now agrees**
1542
+ (#288). `folderFields()` has read both forms since #276 — a folder's _identity_
1543
+ is one thing and its _hierarchy_ another, and both large trees file the same
1544
+ folder under a different parent in the items pack and the journals pack. The
1545
+ vocabulary typed the field as a bare `LINK`, so `content-build lint` required a
1546
+ scalar and rejected every note using the form the specification prescribes: 46
1547
+ findings against `sohl-thalorna` and 3 here, exactly the notes #276 documents as
1548
+ its motivating cases and no others. Every note using the form was a finding, and
1549
+ no note using it was not.
1550
+
1551
+ `parent` is declared `scalar-or-map` now, and a map written in that form is
1552
+ checked **entry by entry** rather than as one value — the correction an author
1553
+ has to make is one pack's address, not the whole map, and quoting the map back
1554
+ named every entry that was right alongside the one that was not. An explicit `~`
1555
+ under a pack key still means _at the root there_, which is a different statement
1556
+ from saying nothing.
1557
+
1558
+ **A pack key naming no declared pack is a finding of its own.** Nothing checked
1559
+ it before, because the whole value was rejected before it was read. It is not a
1560
+ harmless surplus: the compile asks the map for the pack it is writing and falls
1561
+ back to `default` when there is no such key, so a mistyped `journal:` filed the
1562
+ folder wherever the default put it — exactly the hierarchy the key was written
1563
+ to override, and silently. `content-build lint` passes the configured pack names
1564
+ (companions included) for the check; a caller that supplies none makes no claim
1565
+ about the keys, as it already does for the vocabulary itself.
1566
+
1567
+ The specification's `### type: folder` table types `parent` as the scalar-or-map
1568
+ it is, so the two cannot disagree again from opposite sides of one field.
1569
+ - 655d901: **`content-format notes` reads the content index, and with it every check
1570
+ does** (#243).
1571
+
1572
+ It was the last one measuring the tree for itself, and it is the check whose
1573
+ whole output is a _count_ — 3,812 findings across `sohl`'s 1,685 notes. A report
1574
+ that measures the corpus against the declared vocabulary has to be looking at
1575
+ the corpus the compile will build, or its counts describe a tree nobody ships.
1576
+
1577
+ It measures what the author wrote, recovered with `authoredFrontmatter`: this
1578
+ compares a note's fields against a declared vocabulary, and `address:` is in no
1579
+ vocabulary. It also joins the shared contract the other checks now have — a note
1580
+ the index cannot record is reported with its position and the rest of the tree
1581
+ is still measured.
1582
+
1583
+ `bin/content-build.mjs` no longer walks a content tree at all.
1584
+
1585
+ _Findings are unchanged: 3,812 across 1,685 notes, identical._
1586
+ - 0651b1a: **An HM3 item records its template priority, as an HM3 actor already did**
1587
+ (#283).
1588
+
1589
+ `data.templatePriority` is the shared statement that a note is a starting
1590
+ template, and the specification states it as a row every type maps:
1591
+ `system.templatePriority` in SoHL, `flags.hm3.templatePriority` in HM3. Only
1592
+ HM3's **Actor** pass made that mapping. Its Item pass emitted whatever `flags`
1593
+ the note itself authored and nothing more, so an item note declaring the
1594
+ priority compiled into a SoHL item that knew it was a template and an HM3 item
1595
+ that did not.
1596
+
1597
+ **It was silent on both sides of the build.** The note is well-formed and the
1598
+ pack compiles; an omitted flag is exactly how this system says _not a template_,
1599
+ so a lost priority and a deliberate one are the same output. Nor could the
1600
+ emitted-key check see it — that compares what a pass writes against the
1601
+ receiving **schema**, and a flag is declared by no schema. Every gear, skill and
1602
+ trauma note in a tree carrying an `hm3:` block was affected, which is most of
1603
+ them.
1604
+
1605
+ **The rule now lives in one place.** `hm3/template-priority.mjs` holds it and
1606
+ both passes call it, rather than each carrying a copy — two copies being two
1607
+ chances to diverge again, which is the failure being fixed. `SystemItemCompiler`
1608
+ gains a `commonFlags()` hook alongside `commonSystem()`, defaulting to the
1609
+ authored flags alone, so a system that keeps a shared fact in flags has a seam
1610
+ to say so at.
1611
+ - e4c16ad: **The item catalogue picks the newest cached version numerically, not by string
1612
+ sort** (#272).
1613
+
1614
+ `foreignItemCatalogDirs` chose among several cached versions of a dependency
1615
+ with a plain `sort()` over directory names of the form `<id>@<version>`. That is
1616
+ a **string** comparison, so `sohl@0.8.10` sorts _before_ `sohl@0.8.2` and the
1617
+ build resolved its embedded item references against the older catalogue.
1618
+
1619
+ Nothing reported it, and nothing could: both caches are complete and stamped,
1620
+ and the older one is a perfectly valid catalogue — it simply answers for the
1621
+ wrong version. Several versions coexist whenever a pinned version is raised
1622
+ without clearing `build/cache/foreign`, which is the ordinary case, since a
1623
+ fetch writes the newly declared version beside the old one rather than replacing
1624
+ it.
1625
+
1626
+ The content-index cache already compared version segments numerically for
1627
+ exactly this reason. That comparison is now shared as `newestVersionDir` rather
1628
+ than written once per cache: two copies were two chances to get it wrong, and
1629
+ this is the copy that was wrong.
1630
+ - d17dc98: **One reader for a note's anchors.** `engine/content-links.mjs` kept its own,
1631
+ and it disagreed with the content index's: it matched `{#([a-z0-9-]+)}` where
1632
+ `collectAnchors` matches `{#([^}]+)}`. So an anchor with a capital in it —
1633
+ `{#CalendarFormat}`, three of them in `sohl`'s own content — existed for the
1634
+ index and for the compilers, and did not exist for the check of them.
1635
+
1636
+ Nothing links to one today, so the disagreement was latent. The first link to
1637
+ one would have been reported dead against a heading plainly present in the
1638
+ file, which is the worst shape a finding can take.
1639
+
1640
+ The specification puts no charset on the id — "`#id` represents an id anchor
1641
+ named `id`" — so the narrower pattern was this module's invention rather than a
1642
+ rule it was enforcing. That is the argument for one reader rather than a
1643
+ well-chosen one, and the first thing #243 asks for: the corpus and everything
1644
+ derived from it answered in one place.
1645
+ - e242701: **#241's closing note, checked and pinned.** It asked whether `vehicle` and
1646
+ `armorlocation` were second instances of the trap it reported — a type declared
1647
+ and validated that no pack can route. Neither is, and they are not the same case
1648
+ as each other:
1649
+
1650
+ - **`armorlocation` is HM3's.** The specification says "HM3 only", it has no SoHL
1651
+ form, and `hm3/document-subtypes.mjs` maps it. A SoHL configuration claiming
1652
+ it would be wrong, so its absence from `SOHL_DOCUMENT_SUBTYPES` is the answer
1653
+ rather than a gap. Nothing asserted that, so nothing would have noticed a row
1654
+ appearing there by mistake; now something does.
1655
+ - **`vehicle` is specified but not yet implemented, and says so.** A note of that
1656
+ type is already reported as _"the content format specifies `vehicle`, so the
1657
+ note is not wrong — this toolchain has not implemented the type yet … do not
1658
+ author the type until a release compiles it"_. That is the opposite of #241,
1659
+ where the failure was silent and misattributed to the note.
1660
+
1661
+ No behaviour change: this adds the two assertions and the reasoning, so the
1662
+ next reader does not have to re-derive it from four files.
1663
+ - dadbec3: **`content-build lint` and `content-build site` reach the content again** — they
1664
+ threw on the first note for every consumer.
1665
+
1666
+ #243 made `walkMarkdownTree`'s scope a required argument, which was the point:
1667
+ the default it removed read whichever configuration resolved from the working
1668
+ directory rather than the one the caller was working under. Two CLI callers had
1669
+ been living on that default and were not converted with the rest — so both
1670
+ commands failed immediately, reporting nothing about the tree.
1671
+
1672
+ This repository ships no content, so nothing in its suite had ever _run_ those
1673
+ commands over a tree; every test called the engine directly with arguments it
1674
+ supplied. A test now builds a small content tree and runs `lint`, `links` and
1675
+ `site` against it, asserting they reach the notes at all — and that they honour
1676
+ the configured `skipDirectories` rather than reading a directory the tree said
1677
+ to skip.
1678
+ - ae9ef89: **#243's two open questions, settled** — one by measurement, one by finding it
1679
+ had already been answered.
1680
+
1681
+ **1. The index does not record frontmatter-key positions, and should not.** The
1682
+ question was whether a pass reading the index could report a field defect
1683
+ without opening the note. The numbers are not close: over `sohl`'s 1,685 notes
1684
+ the index is **3.0 MB** and holds **50,598 leaf values**, so a `{line, column}`
1685
+ on each would add roughly **1.6 MB — a 54% larger artifact** — for data read
1686
+ only on the _failing_ path.
1687
+
1688
+ The rule that replaces it is the one the module was already built on: _the index
1689
+ carries what is **about** a note; the file carries the note's text and every
1690
+ position within it._ A pass needing either opens the file the record already
1691
+ names, which costs nothing it was not already paying — a check reads each note
1692
+ once for its body, and a compiler must read the prose regardless, so while it
1693
+ holds the bytes a position is free. An anchor's `line` is the exception that
1694
+ proves it: an anchor is structure a consumer addresses, not a locator for a
1695
+ diagnostic.
1696
+
1697
+ `noteFile(contentBase, record)` is the one composition of a record's absolute
1698
+ path. There were **four** copies of it — one in each reader converted by #290,
1699
+ #292, #294 and #296 — which is the duplication #243 exists to remove, arriving
1700
+ by the back door. The index records the path relatively on purpose (an absolute
1701
+ one is a fact about the build machine, and would put a home directory in a
1702
+ published artifact), so composing it is a real step and belongs in one place.
1703
+
1704
+ **2. "Not everything is a note" no longer holds.** The issue lists folder
1705
+ documents (`item-folders.yaml`) and the adventures that bundle scenes as
1706
+ configuration appearing in no record. Both became notes after it was written —
1707
+ folders in #260/#276, bundles in #263/#286 — and both are indexed today, a
1708
+ folder with its address and id, a bundle with the `Adventure` UUID it compiles
1709
+ into.
1710
+
1711
+ What a folder record does not carry is a Foundry address, and that is the right
1712
+ answer rather than a gap: a folder materialises in **every** pack holding a
1713
+ document that references it, so no one UUID identifies it, and emitting one
1714
+ would publish an `Item` UUID for a `Folder` at an id no document carries. A
1715
+ homepage carries none for the opposite reason — it compiles into no document at
1716
+ all. Both are now pinned at the **record**, which is the level a pass driven by
1717
+ the index reads, with a bundle as the positive control.
1718
+
1719
+ _No behaviour change: `lint`, `links`, `content-format notes` and both address
1720
+ maps are identical over `sohl`'s tree._
1721
+ - 2669b57: **`undeclaredPaths` stops descending at a declared leaf** — a declared path with
1722
+ no children holds _values_, not fields (#126).
1723
+
1724
+ A schema declares a path that has nothing beneath it for two ordinary reasons: a
1725
+ map with **dynamic keys** (a mystery's `skillAptitudes` is skill selector →
1726
+ modifier, an affiliation's `relations` is shortcode → standing) and a
1727
+ **TypedSchemaField** (`strikeModes`, discriminated by `type`). In both, what sits
1728
+ under the path is data an author wrote, not paths the schema names — so walking
1729
+ into one reports every entry as an undeclared `system` key.
1730
+
1731
+ It stayed invisible because those maps are authored _outside_ `<system>.system`
1732
+ today, where nothing walks them. The moment a note authors one at the
1733
+ destination — which is what #126's corpus move does — each entry becomes a
1734
+ finding: `sohl.system.skillAptitudes.zepharis`, `…strikeModes.impale`, one per
1735
+ key. Measured on a migrated `sohl-thalorna`: **324 findings, none of them a
1736
+ defect**, and they would have made the migration look like it had broken 62
1737
+ notes.
1738
+
1739
+ Descent is now conditional on the schema declaring something _beneath_ the path.
1740
+ `body.structure` declares `parts` and `zones`, so it is a real container and an
1741
+ undeclared `adjacent` under it is still reported; `skillAptitudes` declares
1742
+ nothing beneath it, so its contents are a value.
1743
+
3
1744
  ## 17.2.0
4
1745
 
5
1746
  ### Minor Changes