@superbee/core 0.2.0-pre.15 → 0.2.0-pre.17

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 (228) hide show
  1. package/README.md +316 -0
  2. package/dist/backend.d.ts +3 -0
  3. package/dist/backend.d.ts.map +1 -1
  4. package/dist/backend.js +7 -0
  5. package/dist/backend.js.map +1 -1
  6. package/dist/blob-contract.d.ts +80 -0
  7. package/dist/blob-contract.d.ts.map +1 -0
  8. package/dist/blob-contract.js +131 -0
  9. package/dist/blob-contract.js.map +1 -0
  10. package/dist/blob-journal.d.ts +117 -0
  11. package/dist/blob-journal.d.ts.map +1 -0
  12. package/dist/blob-journal.js +168 -0
  13. package/dist/blob-journal.js.map +1 -0
  14. package/dist/bundle-health/index.d.ts +14 -0
  15. package/dist/bundle-health/index.d.ts.map +1 -0
  16. package/dist/bundle-health/index.js +12 -0
  17. package/dist/bundle-health/index.js.map +1 -0
  18. package/dist/bundle-health/kind-findings.d.ts +44 -0
  19. package/dist/bundle-health/kind-findings.d.ts.map +1 -0
  20. package/dist/bundle-health/kind-findings.js +68 -0
  21. package/dist/bundle-health/kind-findings.js.map +1 -0
  22. package/dist/bundle-health/legacy-page.d.ts +39 -0
  23. package/dist/bundle-health/legacy-page.d.ts.map +1 -0
  24. package/dist/bundle-health/legacy-page.js +50 -0
  25. package/dist/bundle-health/legacy-page.js.map +1 -0
  26. package/dist/bundle-health/report.d.ts +202 -0
  27. package/dist/bundle-health/report.d.ts.map +1 -0
  28. package/dist/bundle-health/report.js +438 -0
  29. package/dist/bundle-health/report.js.map +1 -0
  30. package/dist/engine.d.ts +1 -1
  31. package/dist/engine.d.ts.map +1 -1
  32. package/dist/engine.js.map +1 -1
  33. package/dist/file-journaled-backend-internal.d.ts +2 -0
  34. package/dist/file-journaled-backend-internal.d.ts.map +1 -1
  35. package/dist/file-journaled-backend-internal.js +2 -0
  36. package/dist/file-journaled-backend-internal.js.map +1 -1
  37. package/dist/file-journaled-backend.d.ts +14 -2
  38. package/dist/file-journaled-backend.d.ts.map +1 -1
  39. package/dist/file-journaled-backend.js +245 -37
  40. package/dist/file-journaled-backend.js.map +1 -1
  41. package/dist/hosted-client/binding.d.ts +7 -0
  42. package/dist/hosted-client/binding.d.ts.map +1 -0
  43. package/dist/hosted-client/binding.js +10 -0
  44. package/dist/hosted-client/binding.js.map +1 -0
  45. package/dist/hosted-client/blob-delivery.d.ts +61 -0
  46. package/dist/hosted-client/blob-delivery.d.ts.map +1 -0
  47. package/dist/hosted-client/blob-delivery.js +147 -0
  48. package/dist/hosted-client/blob-delivery.js.map +1 -0
  49. package/dist/hosted-client/bundle-listing.d.ts +46 -0
  50. package/dist/hosted-client/bundle-listing.d.ts.map +1 -0
  51. package/dist/hosted-client/bundle-listing.js +36 -0
  52. package/dist/hosted-client/bundle-listing.js.map +1 -0
  53. package/dist/hosted-client/client.d.ts +155 -0
  54. package/dist/hosted-client/client.d.ts.map +1 -0
  55. package/dist/hosted-client/client.js +278 -0
  56. package/dist/hosted-client/client.js.map +1 -0
  57. package/dist/hosted-client/errors.d.ts +16 -0
  58. package/dist/hosted-client/errors.d.ts.map +1 -0
  59. package/dist/hosted-client/errors.js +22 -0
  60. package/dist/hosted-client/errors.js.map +1 -0
  61. package/dist/hosted-client/index.d.ts +23 -0
  62. package/dist/hosted-client/index.d.ts.map +1 -0
  63. package/dist/hosted-client/index.js +25 -0
  64. package/dist/hosted-client/index.js.map +1 -0
  65. package/dist/hosted-client/node.d.ts +70 -0
  66. package/dist/hosted-client/node.d.ts.map +1 -0
  67. package/dist/hosted-client/node.js +214 -0
  68. package/dist/hosted-client/node.js.map +1 -0
  69. package/dist/hosted-client/reference.d.ts +12 -0
  70. package/dist/hosted-client/reference.d.ts.map +1 -0
  71. package/dist/hosted-client/reference.js +29 -0
  72. package/dist/hosted-client/reference.js.map +1 -0
  73. package/dist/hosted-client/write.d.ts +200 -0
  74. package/dist/hosted-client/write.d.ts.map +1 -0
  75. package/dist/hosted-client/write.js +401 -0
  76. package/dist/hosted-client/write.js.map +1 -0
  77. package/dist/hosted-transport/answer-rows.d.ts +29 -2
  78. package/dist/hosted-transport/answer-rows.d.ts.map +1 -1
  79. package/dist/hosted-transport/answer-rows.js +60 -2
  80. package/dist/hosted-transport/answer-rows.js.map +1 -1
  81. package/dist/hosted-transport/blob-reads.d.ts +35 -0
  82. package/dist/hosted-transport/blob-reads.d.ts.map +1 -0
  83. package/dist/hosted-transport/blob-reads.js +179 -0
  84. package/dist/hosted-transport/blob-reads.js.map +1 -0
  85. package/dist/hosted-transport/blob-write.d.ts +34 -0
  86. package/dist/hosted-transport/blob-write.d.ts.map +1 -0
  87. package/dist/hosted-transport/blob-write.js +115 -0
  88. package/dist/hosted-transport/blob-write.js.map +1 -0
  89. package/dist/hosted-transport/carrier.d.ts +8 -9
  90. package/dist/hosted-transport/carrier.d.ts.map +1 -1
  91. package/dist/hosted-transport/carrier.js +40 -41
  92. package/dist/hosted-transport/carrier.js.map +1 -1
  93. package/dist/hosted-transport/index.d.ts +4 -4
  94. package/dist/hosted-transport/index.d.ts.map +1 -1
  95. package/dist/hosted-transport/index.js +4 -4
  96. package/dist/hosted-transport/index.js.map +1 -1
  97. package/dist/hosted-transport/read-adapter.d.ts +6 -0
  98. package/dist/hosted-transport/read-adapter.d.ts.map +1 -1
  99. package/dist/hosted-transport/read-adapter.js +11 -0
  100. package/dist/hosted-transport/read-adapter.js.map +1 -1
  101. package/dist/hosted-transport/whole-document-transport.d.ts +1 -1
  102. package/dist/hosted-transport/whole-document-transport.d.ts.map +1 -1
  103. package/dist/hosted-transport/whole-document-transport.js +20 -12
  104. package/dist/hosted-transport/whole-document-transport.js.map +1 -1
  105. package/dist/index.d.ts +5 -2
  106. package/dist/index.d.ts.map +1 -1
  107. package/dist/index.js +5 -2
  108. package/dist/index.js.map +1 -1
  109. package/dist/journaled-backend.d.ts +10 -2
  110. package/dist/journaled-backend.d.ts.map +1 -1
  111. package/dist/journaled-backend.js +3 -3
  112. package/dist/journaled-backend.js.map +1 -1
  113. package/dist/kinds-load.js +4 -4
  114. package/dist/kinds-load.js.map +1 -1
  115. package/dist/kinds.d.ts.map +1 -1
  116. package/dist/kinds.js +3 -2
  117. package/dist/kinds.js.map +1 -1
  118. package/dist/link-types.d.ts +26 -0
  119. package/dist/link-types.d.ts.map +1 -0
  120. package/dist/link-types.js +57 -0
  121. package/dist/link-types.js.map +1 -0
  122. package/dist/paths.d.ts +16 -0
  123. package/dist/paths.d.ts.map +1 -1
  124. package/dist/paths.js +25 -0
  125. package/dist/paths.js.map +1 -1
  126. package/dist/platform.d.ts +4 -0
  127. package/dist/platform.d.ts.map +1 -1
  128. package/dist/platform.js +16 -0
  129. package/dist/platform.js.map +1 -1
  130. package/dist/query-filter.d.ts +9 -0
  131. package/dist/query-filter.d.ts.map +1 -1
  132. package/dist/query-filter.js +19 -3
  133. package/dist/query-filter.js.map +1 -1
  134. package/dist/recipe-lifecycle/errors.d.ts +77 -0
  135. package/dist/recipe-lifecycle/errors.d.ts.map +1 -0
  136. package/dist/recipe-lifecycle/errors.js +96 -0
  137. package/dist/recipe-lifecycle/errors.js.map +1 -0
  138. package/dist/recipe-lifecycle/evolution.d.ts +71 -0
  139. package/dist/recipe-lifecycle/evolution.d.ts.map +1 -0
  140. package/dist/recipe-lifecycle/evolution.js +659 -0
  141. package/dist/recipe-lifecycle/evolution.js.map +1 -0
  142. package/dist/recipe-lifecycle/index.d.ts +23 -0
  143. package/dist/recipe-lifecycle/index.d.ts.map +1 -0
  144. package/dist/recipe-lifecycle/index.js +19 -0
  145. package/dist/recipe-lifecycle/index.js.map +1 -0
  146. package/dist/recipe-lifecycle/install.d.ts +111 -0
  147. package/dist/recipe-lifecycle/install.d.ts.map +1 -0
  148. package/dist/recipe-lifecycle/install.js +412 -0
  149. package/dist/recipe-lifecycle/install.js.map +1 -0
  150. package/dist/recipe-lifecycle/legacy-aliases.d.ts +16 -0
  151. package/dist/recipe-lifecycle/legacy-aliases.d.ts.map +1 -0
  152. package/dist/recipe-lifecycle/legacy-aliases.js +51 -0
  153. package/dist/recipe-lifecycle/legacy-aliases.js.map +1 -0
  154. package/dist/recipe-lifecycle/stamp.d.ts +98 -0
  155. package/dist/recipe-lifecycle/stamp.d.ts.map +1 -0
  156. package/dist/recipe-lifecycle/stamp.js +260 -0
  157. package/dist/recipe-lifecycle/stamp.js.map +1 -0
  158. package/dist/recipes.d.ts +5 -0
  159. package/dist/recipes.d.ts.map +1 -1
  160. package/dist/recipes.js +5 -0
  161. package/dist/recipes.js.map +1 -1
  162. package/dist/record-overlay/apply.d.ts +43 -0
  163. package/dist/record-overlay/apply.d.ts.map +1 -0
  164. package/dist/record-overlay/apply.js +490 -0
  165. package/dist/record-overlay/apply.js.map +1 -0
  166. package/dist/record-overlay/canonical.d.ts +14 -0
  167. package/dist/record-overlay/canonical.d.ts.map +1 -0
  168. package/dist/record-overlay/canonical.js +31 -0
  169. package/dist/record-overlay/canonical.js.map +1 -0
  170. package/dist/record-overlay/digest.d.ts +4 -0
  171. package/dist/record-overlay/digest.d.ts.map +1 -0
  172. package/dist/record-overlay/digest.js +11 -0
  173. package/dist/record-overlay/digest.js.map +1 -0
  174. package/dist/record-overlay/errors.d.ts +40 -0
  175. package/dist/record-overlay/errors.d.ts.map +1 -0
  176. package/dist/record-overlay/errors.js +51 -0
  177. package/dist/record-overlay/errors.js.map +1 -0
  178. package/dist/record-overlay/findings.d.ts +5 -0
  179. package/dist/record-overlay/findings.d.ts.map +1 -0
  180. package/dist/record-overlay/findings.js +83 -0
  181. package/dist/record-overlay/findings.js.map +1 -0
  182. package/dist/record-overlay/index.d.ts +23 -0
  183. package/dist/record-overlay/index.d.ts.map +1 -0
  184. package/dist/record-overlay/index.js +21 -0
  185. package/dist/record-overlay/index.js.map +1 -0
  186. package/dist/record-overlay/plan.d.ts +12 -0
  187. package/dist/record-overlay/plan.d.ts.map +1 -0
  188. package/dist/record-overlay/plan.js +284 -0
  189. package/dist/record-overlay/plan.js.map +1 -0
  190. package/dist/record-overlay/policy.d.ts +60 -0
  191. package/dist/record-overlay/policy.d.ts.map +1 -0
  192. package/dist/record-overlay/policy.js +209 -0
  193. package/dist/record-overlay/policy.js.map +1 -0
  194. package/dist/record-overlay/redact.d.ts +3 -0
  195. package/dist/record-overlay/redact.d.ts.map +1 -0
  196. package/dist/record-overlay/redact.js +27 -0
  197. package/dist/record-overlay/redact.js.map +1 -0
  198. package/dist/record-overlay/signal-kind.d.ts +30 -0
  199. package/dist/record-overlay/signal-kind.d.ts.map +1 -0
  200. package/dist/record-overlay/signal-kind.js +38 -0
  201. package/dist/record-overlay/signal-kind.js.map +1 -0
  202. package/dist/record-overlay/signal-text.d.ts +45 -0
  203. package/dist/record-overlay/signal-text.d.ts.map +1 -0
  204. package/dist/record-overlay/signal-text.js +116 -0
  205. package/dist/record-overlay/signal-text.js.map +1 -0
  206. package/dist/record-overlay/source-index.d.ts +19 -0
  207. package/dist/record-overlay/source-index.d.ts.map +1 -0
  208. package/dist/record-overlay/source-index.js +102 -0
  209. package/dist/record-overlay/source-index.js.map +1 -0
  210. package/dist/record-overlay/types.d.ts +286 -0
  211. package/dist/record-overlay/types.d.ts.map +1 -0
  212. package/dist/record-overlay/types.js +2 -0
  213. package/dist/record-overlay/types.js.map +1 -0
  214. package/dist/remote-operations.js +5 -2
  215. package/dist/remote-operations.js.map +1 -1
  216. package/dist/storage.d.ts +5 -1
  217. package/dist/storage.d.ts.map +1 -1
  218. package/dist/storage.js +4 -1
  219. package/dist/storage.js.map +1 -1
  220. package/dist/uncertain-write.d.ts +107 -6
  221. package/dist/uncertain-write.d.ts.map +1 -1
  222. package/dist/uncertain-write.js +67 -32
  223. package/dist/uncertain-write.js.map +1 -1
  224. package/dist/write-identity.d.ts +5 -0
  225. package/dist/write-identity.d.ts.map +1 -0
  226. package/dist/write-identity.js +9 -0
  227. package/dist/write-identity.js.map +1 -0
  228. package/package.json +21 -1
@@ -0,0 +1,659 @@
1
+ // Explicit, state-bound evolution of recipe-installed conventions.
2
+ //
3
+ // Recipe installation is create-only and intentionally never overwrites bundle-authored content.
4
+ // This module owns the distinct upgrade authority: a read-only compatibility plan followed by an
5
+ // exact-token preflight and exact-version CAS apply over each target whose content or recipe stamp
6
+ // needs a write. The stamp travels in the same write as the convention's content. Since the bundle
7
+ // has no cross-document transaction, a postcondition reports concurrent non-target changes honestly.
8
+ import { query, readBlob, readBundleOkfVersion, readDocVersioned } from "../bundle-ops.js";
9
+ import { resolveContentType } from "../content-type.js";
10
+ import { DocumentNotFoundError, mutateDocument } from "../document-mutation.js";
11
+ import { applyV02MutationMetadata } from "../document-write-policy.js";
12
+ import { InvalidInputError, OkfActorError } from "../errors.js";
13
+ import { CONVENTIONS_PREFIX, buildKindRegistry, parseConventionDoc, projectKindValidationWarnings, validateAgainstKind, } from "../kinds.js";
14
+ import { collectLinkDeclarations } from "../link-types.js";
15
+ import { parseLinksFromDoc } from "../links.js";
16
+ import { okfValuesEqual } from "../okf-authored-values.js";
17
+ import { VersionConflict, blobVersion, contentVersion, sha256Hex } from "../versioning.js";
18
+ import { RecipeEditionChanged, RecipeEvolutionBlocked, RecipeEvolutionInterrupted, RecipeEvolutionPlanChanged, RecipeEvolutionPostcondition, } from "./errors.js";
19
+ import { materializeRecipeForEdition, recipeDocumentForApply, sameInstalledDoc, } from "./install.js";
20
+ import { RECIPE_STAMP_KEY, conventionStampState, definitionDigest, recipeStampIdentity, recipeStampState, withRecipeStamp, } from "./stamp.js";
21
+ const RECIPE_EVOLUTION_WRITE_REGISTRY = { kinds: new Map(), warnings: [] };
22
+ function plainRecord(value) {
23
+ return value !== null && typeof value === "object" && !Array.isArray(value)
24
+ ? value
25
+ : undefined;
26
+ }
27
+ function comparableRecipeDoc(doc, okfVersion) {
28
+ const { timestamp: _timestamp, [RECIPE_STAMP_KEY]: _stamp, ...frontmatter } = doc.frontmatter;
29
+ const generated = plainRecord(frontmatter.generated);
30
+ if (okfVersion === "0.2" && generated) {
31
+ const { at: _at, ...rest } = generated;
32
+ if (Object.keys(rest).length > 0)
33
+ frontmatter.generated = rest;
34
+ else
35
+ delete frontmatter.generated;
36
+ }
37
+ return { id: doc.id, frontmatter, body: doc.body };
38
+ }
39
+ /** Root keys kept from the installed document, never read from the source; the stamp is one. */
40
+ const PRESERVED_ROOT_METADATA = new Set(["timestamp", "actor", "generated", "verified", "superbee_updated_by", RECIPE_STAMP_KEY]);
41
+ /**
42
+ * Bundle-local placement: a reading-order `order` an author put on an installed convention is the
43
+ * bundle's, not the recipe's. Evolution keeps it when the recipe declares none, so ordering the
44
+ * seeded Kinds never blocks a recipe upgrade; a recipe that declares its own order merges as any
45
+ * other key.
46
+ */
47
+ const BUNDLE_LOCAL_PLACEMENT = new Set(["order"]);
48
+ function pointerSegment(value) {
49
+ return value.replaceAll("~", "~0").replaceAll("/", "~1");
50
+ }
51
+ function setOwn(record, key, value) {
52
+ Object.defineProperty(record, key, {
53
+ value,
54
+ enumerable: true,
55
+ configurable: true,
56
+ writable: true,
57
+ });
58
+ }
59
+ /**
60
+ * Merge recipe declarations into a user-owned convention without deleting or replacing any
61
+ * existing semantic value. Arrays are declaration sets: additions append, while omissions are
62
+ * blockers. Root mutation/provenance metadata is preserved by the shared write policy instead of
63
+ * being treated as recipe-owned schema.
64
+ */
65
+ function additiveConventionCandidate(existing, desired, blockers) {
66
+ const addedPaths = [];
67
+ const merge = (current, next, pointer) => {
68
+ if (current === undefined) {
69
+ addedPaths.push(pointer);
70
+ return next;
71
+ }
72
+ if (next === undefined) {
73
+ evolutionBlocker(blockers, "RECIPE_EVOLUTION_REMOVAL_UNSUPPORTED", desired.id, `automatic evolution preserves the existing declaration at '${pointer}'; remove it manually after reviewing dependent content`, pointer);
74
+ return current;
75
+ }
76
+ if (okfValuesEqual(current, next))
77
+ return current;
78
+ if (Array.isArray(current) && Array.isArray(next)) {
79
+ const merged = [...current];
80
+ for (const value of current) {
81
+ if (!next.some((candidate) => okfValuesEqual(candidate, value))) {
82
+ evolutionBlocker(blockers, "RECIPE_EVOLUTION_REMOVAL_UNSUPPORTED", desired.id, `automatic evolution preserves the existing declaration value at '${pointer}'`, pointer);
83
+ }
84
+ }
85
+ for (const value of next) {
86
+ if (merged.some((candidate) => okfValuesEqual(candidate, value)))
87
+ continue;
88
+ merged.push(value);
89
+ addedPaths.push(`${pointer}/${pointerSegment(String(value))}`);
90
+ }
91
+ return merged;
92
+ }
93
+ const currentRecord = plainRecord(current);
94
+ const nextRecord = plainRecord(next);
95
+ if (currentRecord && nextRecord) {
96
+ const merged = { ...currentRecord };
97
+ for (const key of new Set([...Object.keys(currentRecord), ...Object.keys(nextRecord)])) {
98
+ setOwn(merged, key, merge(currentRecord[key], nextRecord[key], `${pointer}/${pointerSegment(key)}`));
99
+ }
100
+ return merged;
101
+ }
102
+ evolutionBlocker(blockers, "RECIPE_EVOLUTION_REPLACEMENT_UNSUPPORTED", desired.id, `automatic evolution will not replace the existing declaration at '${pointer}'`, pointer);
103
+ return current;
104
+ };
105
+ const currentFrontmatter = existing.frontmatter;
106
+ const desiredFrontmatter = desired.frontmatter;
107
+ const candidateFrontmatter = { ...currentFrontmatter };
108
+ for (const key of new Set([...Object.keys(currentFrontmatter), ...Object.keys(desiredFrontmatter)])) {
109
+ // Evolution never imports recipe-authored provenance, verification, or mutation attribution.
110
+ // These coordinates come exclusively from the installed document and the shared write policy.
111
+ if (PRESERVED_ROOT_METADATA.has(key))
112
+ continue;
113
+ if (BUNDLE_LOCAL_PLACEMENT.has(key) && desiredFrontmatter[key] === undefined)
114
+ continue;
115
+ setOwn(candidateFrontmatter, key, merge(currentFrontmatter[key], desiredFrontmatter[key], `/frontmatter/${pointerSegment(key)}`));
116
+ }
117
+ let body = existing.body;
118
+ if (existing.body === "" && desired.body !== "") {
119
+ body = desired.body;
120
+ addedPaths.push("/body");
121
+ }
122
+ else if (existing.body !== desired.body) {
123
+ evolutionBlocker(blockers, "RECIPE_EVOLUTION_BODY_REPLACEMENT_UNSUPPORTED", desired.id, "automatic evolution will not replace an existing convention body; update it manually after review", "/body");
124
+ }
125
+ return {
126
+ candidate: { id: desired.id, frontmatter: candidateFrontmatter, body },
127
+ addedPaths: [...new Set(addedPaths)].sort(),
128
+ };
129
+ }
130
+ async function readRecipeDocIfPresent(bundle, id) {
131
+ try {
132
+ return await readDocVersioned(bundle, id);
133
+ }
134
+ catch (error) {
135
+ if (error instanceof DocumentNotFoundError || error?.code === "ENOENT")
136
+ return null;
137
+ throw error;
138
+ }
139
+ }
140
+ function evolutionBlocker(blockers, code, id, message, field) {
141
+ blockers.push({ code, id, message, ...(field ? { field } : {}) });
142
+ }
143
+ function monotonicEvolutionBlockers(current, desired, blockers) {
144
+ const currentRequired = new Set(current.fields.required);
145
+ for (const field of desired.fields.required) {
146
+ if (!currentRequired.has(field)) {
147
+ evolutionBlocker(blockers, "RECIPE_EVOLUTION_NON_MONOTONIC", desired.id, `automatic evolution cannot newly require '${field}' for '${desired.governs}'; migrate instances explicitly first`, field);
148
+ }
149
+ }
150
+ // An enum on a declared field is admitted, installed or newly declared alike: the instance scan
151
+ // against the desired kind blocks the plan when any existing document carries a disallowed value.
152
+ // Adding a `fields.types` entry needs no rule here for the same reason; changing or removing one
153
+ // is refused by the additive merge before this classifier runs.
154
+ const desiredDeclared = new Set([...desired.fields.required, ...desired.fields.optional]);
155
+ for (const [field, desiredValues] of Object.entries(desired.fields.values)) {
156
+ const currentValues = current.fields.values[field];
157
+ if (!currentValues) {
158
+ if (desiredDeclared.has(field))
159
+ continue;
160
+ evolutionBlocker(blockers, "RECIPE_EVOLUTION_NON_MONOTONIC", desired.id, `automatic evolution cannot add an enum restriction to '${field}' for '${desired.governs}'; declare '${field}' in fields.required or fields.optional first`, field);
161
+ continue;
162
+ }
163
+ const allowed = new Set(desiredValues);
164
+ const removed = currentValues.filter((value) => !allowed.has(value));
165
+ if (removed.length > 0) {
166
+ evolutionBlocker(blockers, "RECIPE_EVOLUTION_NON_MONOTONIC", desired.id, `automatic evolution cannot remove allowed '${field}' value(s) for '${desired.governs}': ${removed.join(", ")}`, field);
167
+ }
168
+ }
169
+ const currentSections = new Set(current.sections ?? []);
170
+ for (const section of desired.sections ?? []) {
171
+ if (!currentSections.has(section)) {
172
+ evolutionBlocker(blockers, "RECIPE_EVOLUTION_NON_MONOTONIC", desired.id, `automatic evolution cannot newly require the '# ${section}' section for '${desired.governs}'`, section);
173
+ }
174
+ }
175
+ // A new outbound link type is additive (its existing edges are revalidated by
176
+ // linkTypeAdditionBlockers). Removing or retargeting an installed one is not. Compare the PARSED
177
+ // maps: the parser trims keys, so a raw key such as "at " can redefine "at" through the merge.
178
+ const desiredLinks = desired.links ?? {};
179
+ for (const [linkType, target] of Object.entries(current.links ?? {})) {
180
+ if (Object.hasOwn(desiredLinks, linkType) && desiredLinks[linkType] === target)
181
+ continue;
182
+ evolutionBlocker(blockers, "RECIPE_EVOLUTION_RELATIONSHIP_CHANGE", desired.id, `automatic evolution does not remove or retarget the outbound link type '${linkType}' for '${desired.governs}'`, `links.${linkType}`);
183
+ }
184
+ if (!okfValuesEqual(current.expectsInbound ?? {}, desired.expectsInbound ?? {})) {
185
+ evolutionBlocker(blockers, "RECIPE_EVOLUTION_RELATIONSHIP_CHANGE", desired.id, `automatic evolution does not change inbound relationship expectations for '${desired.governs}'`, "expects_inbound");
186
+ }
187
+ if (!okfValuesEqual(current.fields.terminal, desired.fields.terminal)) {
188
+ evolutionBlocker(blockers, "RECIPE_EVOLUTION_TERMINAL_CHANGE", desired.id, `automatic evolution does not change terminal workflow values for '${desired.governs}' because open-work visibility can change`, "fields.terminal");
189
+ }
190
+ }
191
+ function addedFieldRestrictions(current, desired) {
192
+ const types = new Map();
193
+ for (const [field, type] of Object.entries(desired.fields.types ?? {})) {
194
+ if (!Object.hasOwn(current.fields.types ?? {}, field))
195
+ types.set(field, type);
196
+ }
197
+ const currentDeclared = new Set([...current.fields.required, ...current.fields.optional]);
198
+ const values = new Map();
199
+ for (const [field, allowed] of Object.entries(desired.fields.values)) {
200
+ if (!Object.hasOwn(current.fields.values, field) && currentDeclared.has(field)) {
201
+ values.set(field, allowed.map((value) => String(value)));
202
+ }
203
+ }
204
+ return { types, values };
205
+ }
206
+ /** Violating documents named in one restriction blocker; the rest are counted. */
207
+ const RESTRICTION_BLOCKER_DOC_CAP = 10;
208
+ /** Outbound link types a changing kind declares that its installed convention did not. */
209
+ function addedLinkTypes(current, desired) {
210
+ const currentLinks = current.links ?? {};
211
+ return Object.keys(desired.links ?? {}).filter((linkType) => !Object.hasOwn(currentLinks, linkType));
212
+ }
213
+ /**
214
+ * Declaring a link type turns every existing edge with that exact text into a typed edge, across
215
+ * the whole bundle (the vocabulary is global; see `collectLinkDeclarations`). Apply the same rule
216
+ * `status` uses for `link_type_violations` to those edges under the prospective registry, so an
217
+ * evolution never introduces a violation. Returns the docs whose edges were checked.
218
+ */
219
+ function linkTypeAdditionBlockers(docs, addedTexts, currentRegistry, registry, blockers) {
220
+ const declarations = collectLinkDeclarations(registry);
221
+ const currentDeclarations = collectLinkDeclarations(currentRegistry);
222
+ const docsById = new Map(docs.map((doc) => [doc.id, doc]));
223
+ const docType = (doc) => typeof doc.frontmatter.type === "string" ? doc.frontmatter.type : "";
224
+ const checked = [];
225
+ for (const doc of docs) {
226
+ let carries = false;
227
+ for (const link of parseLinksFromDoc(doc)) {
228
+ if (!addedTexts.has(link.text))
229
+ continue;
230
+ carries = true;
231
+ const target = docsById.get(link.to);
232
+ if (!target)
233
+ continue; // unresolved edges are their own `status` finding
234
+ const sourceType = docType(doc);
235
+ const violates = (declared) => {
236
+ if (declared.length === 0)
237
+ return null;
238
+ const matched = declared.find((declaration) => declaration.governs === sourceType);
239
+ return matched && docType(target) === matched.target ? null : matched ?? declared[0];
240
+ };
241
+ const expected = violates(declarations.get(link.text) ?? []);
242
+ if (!expected)
243
+ continue;
244
+ // Already a violation before this evolution (another Kind declared the text): status
245
+ // reports it today; this plan neither creates nor fixes it.
246
+ if (violates(currentDeclarations.get(link.text) ?? []))
247
+ continue;
248
+ evolutionBlocker(blockers, "RECIPE_EVOLUTION_INSTANCE_INVALID", doc.id, `declaring link type '${link.text}' would make the edge to '${link.to}' a violation (expected ${expected.governs} -> ${expected.target}, found ${sourceType || "untyped"} -> ${docType(target) || "untyped"})`, `links.${link.text}`);
249
+ }
250
+ if (carries)
251
+ checked.push(doc);
252
+ }
253
+ return checked;
254
+ }
255
+ function evolutionPlanToken(input) {
256
+ return `sha256:${sha256Hex(JSON.stringify(input))}`;
257
+ }
258
+ async function prepareRecipeEvolution(bundle, sourceRecipe) {
259
+ const okfVersion = await readBundleOkfVersion(bundle) ?? "0.1";
260
+ const recipe = materializeRecipeForEdition(sourceRecipe, okfVersion);
261
+ const identity = recipeStampIdentity(sourceRecipe, recipe);
262
+ const blockers = recipe.warnings.map((warning) => ({
263
+ code: warning.code,
264
+ id: recipe.id,
265
+ message: warning.message,
266
+ ...(warning.field ? { field: warning.field } : {}),
267
+ }));
268
+ const definitions = [];
269
+ const writes = new Map();
270
+ const proofs = [];
271
+ const desiredKinds = new Map();
272
+ const addedLinkTexts = new Set();
273
+ const addedRestrictions = new Map();
274
+ const skippedConventions = [];
275
+ const installedConventions = await query(bundle, { prefix: CONVENTIONS_PREFIX, type: "Convention" }, {
276
+ onSkip: (skipped) => skippedConventions.push(skipped),
277
+ });
278
+ for (const skipped of skippedConventions) {
279
+ evolutionBlocker(blockers, "RECIPE_EVOLUTION_CONVENTION_UNREADABLE", skipped.id, `cannot prove the prospective registry while convention '${skipped.id}' is unreadable: ${skipped.reason}`);
280
+ }
281
+ const prospectiveById = new Map(installedConventions.map((doc) => [doc.id, doc]));
282
+ const currentRegistry = buildKindRegistry(installedConventions, [], { okfVersion });
283
+ for (const authored of recipe.docs) {
284
+ const target = recipeDocumentForApply(authored, okfVersion, "1970-01-01T00:00:00.000Z");
285
+ const parsedDesired = parseConventionDoc(target, { okfVersion });
286
+ if (!parsedDesired.ok) {
287
+ evolutionBlocker(blockers, "RECIPE_EVOLUTION_DEFINITION_INVALID", target.id, `recipe convention '${target.id}' is malformed: ${parsedDesired.reason}`);
288
+ continue;
289
+ }
290
+ for (const warning of parsedDesired.warnings) {
291
+ evolutionBlocker(blockers, warning.code, target.id, warning.message, warning.field);
292
+ }
293
+ if (parsedDesired.reservedFieldsIgnored.length > 0) {
294
+ evolutionBlocker(blockers, "KIND_RESERVED_FIELD", target.id, `recipe convention declares reserved field name(s): ${parsedDesired.reservedFieldsIgnored.join(", ")}`, parsedDesired.reservedFieldPaths.join(","));
295
+ }
296
+ if (okfVersion === "0.2") {
297
+ try {
298
+ applyV02MutationMetadata({
299
+ candidate: { frontmatter: target.frontmatter, body: target.body },
300
+ meaningfulChangeAt: "1970-01-01T00:00:00.000Z",
301
+ allowGeneratedProvenanceSeed: false,
302
+ });
303
+ }
304
+ catch (error) {
305
+ if (!(error instanceof InvalidInputError))
306
+ throw error;
307
+ evolutionBlocker(blockers, "RECIPE_EVOLUTION_WRITE_POLICY_INVALID", target.id, `recipe convention cannot satisfy the OKF v0.2 write policy: ${error.message}`);
308
+ }
309
+ }
310
+ const existing = await readRecipeDocIfPresent(bundle, target.id);
311
+ if (!existing) {
312
+ prospectiveById.set(target.id, target);
313
+ desiredKinds.set(parsedDesired.kind.governs, parsedDesired.kind);
314
+ const desiredVersion = contentVersion(comparableRecipeDoc(target, okfVersion));
315
+ definitions.push({
316
+ id: target.id,
317
+ governs: parsedDesired.kind.governs,
318
+ action: "missing",
319
+ stamp: "missing",
320
+ expected_version: null,
321
+ desired_version: desiredVersion,
322
+ added_paths: [],
323
+ });
324
+ proofs.push({ kind: "definition", id: target.id, current_version: null, desired_version: desiredVersion });
325
+ evolutionBlocker(blockers, "RECIPE_EVOLUTION_DEFINITION_MISSING", target.id, "recipe evolution only updates installed conventions; install the complete recipe with 'recipe add' first");
326
+ continue;
327
+ }
328
+ const parsedCurrent = parseConventionDoc(existing.doc, { okfVersion });
329
+ if (existing.doc.frontmatter.type !== "Convention"
330
+ || !parsedCurrent.ok
331
+ || parsedCurrent.kind.governs !== parsedDesired.kind.governs) {
332
+ evolutionBlocker(blockers, "RECIPE_EVOLUTION_IDENTITY_CONFLICT", target.id, existing.doc.frontmatter.type !== "Convention"
333
+ ? `existing '${target.id}' has type '${String(existing.doc.frontmatter.type)}', not 'Convention'`
334
+ : !parsedCurrent.ok
335
+ ? `existing '${target.id}' is not a valid convention: ${parsedCurrent.reason}`
336
+ : `existing '${target.id}' governs '${parsedCurrent.kind.governs}', not '${parsedDesired.kind.governs}'`, "governs");
337
+ }
338
+ const merged = additiveConventionCandidate(existing.doc, target, blockers);
339
+ let candidate = merged.candidate;
340
+ if (okfVersion === "0.2") {
341
+ try {
342
+ const withMetadata = applyV02MutationMetadata({
343
+ existing: existing.doc,
344
+ candidate: { frontmatter: candidate.frontmatter, body: candidate.body },
345
+ meaningfulChangeAt: "1970-01-01T00:00:00.000Z",
346
+ allowGeneratedProvenanceSeed: false,
347
+ });
348
+ candidate = { id: candidate.id, ...withMetadata };
349
+ }
350
+ catch (error) {
351
+ if (!(error instanceof InvalidInputError))
352
+ throw error;
353
+ evolutionBlocker(blockers, "RECIPE_EVOLUTION_WRITE_POLICY_INVALID", target.id, `prospective convention cannot satisfy the OKF v0.2 write policy: ${error.message}`);
354
+ }
355
+ }
356
+ prospectiveById.set(target.id, candidate);
357
+ const parsedCandidate = parseConventionDoc(candidate, { okfVersion });
358
+ if (!parsedCandidate.ok) {
359
+ evolutionBlocker(blockers, "RECIPE_EVOLUTION_DEFINITION_INVALID", target.id, `prospective convention '${target.id}' is malformed: ${parsedCandidate.reason}`);
360
+ }
361
+ else {
362
+ desiredKinds.set(parsedCandidate.kind.governs, parsedCandidate.kind);
363
+ // Additive evolution preserves local declarations that are absent from the recipe.
364
+ for (const warning of parsedCandidate.warnings) {
365
+ if (warning.code !== "OKF_WORKFLOW_STATUS_COLLISION" || blockers.some(blocker => blocker.id === target.id && blocker.code === warning.code))
366
+ continue;
367
+ evolutionBlocker(blockers, warning.code, target.id, warning.message, warning.field);
368
+ }
369
+ }
370
+ const desiredVersion = contentVersion(comparableRecipeDoc(candidate, okfVersion));
371
+ const unchanged = sameInstalledDoc(existing.doc, candidate, okfVersion);
372
+ // A current identity whose definition disagrees with the stored content is re-stamped only when
373
+ // that content's definition digest equals the digest of the recipe's own convention: a fresh
374
+ // stamp is then the honest record. The merge treats lists as sets, so `unchanged` alone would
375
+ // also re-baseline a local reorder or duplicate.
376
+ const identityState = recipeStampState(existing.doc, identity);
377
+ const stamp = unchanged &&
378
+ identityState === "current" &&
379
+ conventionStampState(existing.doc) === "diverged" &&
380
+ definitionDigest(existing.doc) === definitionDigest(target)
381
+ ? "stale"
382
+ : identityState;
383
+ definitions.push({
384
+ id: target.id,
385
+ governs: parsedDesired.kind.governs,
386
+ action: unchanged ? "unchanged" : "update",
387
+ stamp,
388
+ expected_version: existing.version,
389
+ desired_version: desiredVersion,
390
+ added_paths: unchanged ? [] : merged.addedPaths,
391
+ });
392
+ // The write set: a content update (which takes ownership of a foreign stamp), or a stale or
393
+ // missing stamp on unchanged content. A foreign stamp alone is another recipe's to keep.
394
+ if (!unchanged)
395
+ writes.set(target.id, withRecipeStamp(candidate, identity));
396
+ else if (stamp === "stale" || stamp === "missing")
397
+ writes.set(target.id, withRecipeStamp(existing.doc, identity));
398
+ proofs.push({
399
+ kind: "definition",
400
+ id: target.id,
401
+ current_version: existing.version,
402
+ desired_version: desiredVersion,
403
+ });
404
+ if (!unchanged
405
+ && existing.doc.frontmatter.type === "Convention"
406
+ && parsedCurrent.ok
407
+ && parsedCandidate.ok
408
+ && parsedCurrent.kind.governs === parsedCandidate.kind.governs) {
409
+ monotonicEvolutionBlockers(parsedCurrent.kind, parsedCandidate.kind, blockers);
410
+ addedRestrictions.set(parsedCandidate.kind.governs, addedFieldRestrictions(parsedCurrent.kind, parsedCandidate.kind));
411
+ for (const linkType of addedLinkTypes(parsedCurrent.kind, parsedCandidate.kind))
412
+ addedLinkTexts.add(linkType);
413
+ }
414
+ }
415
+ const prospectiveRegistry = buildKindRegistry([...prospectiveById.values()], [], { okfVersion });
416
+ for (const warning of prospectiveRegistry.warnings) {
417
+ if (warning.code !== "KIND_DUPLICATE_GOVERNS" || !warning.field || !desiredKinds.has(warning.field))
418
+ continue;
419
+ evolutionBlocker(blockers, "RECIPE_EVOLUTION_DUPLICATE_GOVERNS", desiredKinds.get(warning.field).id, warning.message, warning.field);
420
+ }
421
+ for (const [governs, kind] of desiredKinds) {
422
+ if (prospectiveRegistry.kinds.get(governs)?.id !== kind.id) {
423
+ evolutionBlocker(blockers, "RECIPE_EVOLUTION_DUPLICATE_GOVERNS", kind.id, `prospective registry keeps '${prospectiveRegistry.kinds.get(governs)?.id ?? "none"}' as the authority for '${governs}', not '${kind.id}'`, governs);
424
+ }
425
+ }
426
+ const assetDocProof = async (kind, authored) => {
427
+ const target = recipeDocumentForApply(authored, okfVersion, "1970-01-01T00:00:00.000Z");
428
+ const wanted = contentVersion(comparableRecipeDoc(target, okfVersion));
429
+ const existing = await readRecipeDocIfPresent(bundle, target.id);
430
+ proofs.push({ kind, id: target.id, current_version: existing?.version ?? null, desired_version: wanted });
431
+ if (!existing) {
432
+ evolutionBlocker(blockers, "RECIPE_EVOLUTION_ASSET_MISSING", target.id, `recipe asset '${target.id}' is not installed; install the complete recipe with 'recipe add' first`);
433
+ }
434
+ else if (!sameInstalledDoc(existing.doc, target, okfVersion)) {
435
+ evolutionBlocker(blockers, "RECIPE_EVOLUTION_ASSET_CHANGE_UNSUPPORTED", target.id, `recipe evolution does not replace an existing ${kind === "reference" ? "Reference" : "View registry"} asset`);
436
+ }
437
+ };
438
+ for (const reference of recipe.references)
439
+ await assetDocProof("reference", reference.doc);
440
+ for (const page of recipe.pages) {
441
+ await assetDocProof("view-registry", page.registry);
442
+ const existing = await readBlob(bundle, page.entry);
443
+ const wantedBytes = new TextEncoder().encode(page.html);
444
+ const wanted = blobVersion(wantedBytes);
445
+ proofs.push({ kind: "view-entry", id: page.entry, current_version: existing?.version ?? null, desired_version: wanted });
446
+ if (!existing) {
447
+ evolutionBlocker(blockers, "RECIPE_EVOLUTION_ASSET_MISSING", page.entry, `recipe View entry '${page.entry}' is not installed; install the complete recipe with 'recipe add' first`);
448
+ }
449
+ else if (existing.version !== wanted || existing.contentType !== resolveContentType(page.entry)) {
450
+ evolutionBlocker(blockers, "RECIPE_EVOLUTION_ASSET_CHANGE_UNSUPPORTED", page.entry, "recipe evolution does not replace an existing active View entry without an atomic pair-update authority");
451
+ }
452
+ }
453
+ const changingKinds = definitions
454
+ .filter((definition) => definition.action === "update")
455
+ .map((definition) => desiredKinds.get(definition.governs))
456
+ .filter((kind) => kind !== undefined);
457
+ let instancesChecked = 0;
458
+ const instanceProofs = [];
459
+ if (changingKinds.length > 0) {
460
+ const skippedInstances = [];
461
+ const docs = await query(bundle, {}, { onSkip: (skipped) => skippedInstances.push(skipped) });
462
+ for (const skipped of skippedInstances) {
463
+ evolutionBlocker(blockers, "RECIPE_EVOLUTION_INSTANCE_UNREADABLE", skipped.id, `cannot prove instance compatibility while '${skipped.id}' is unreadable: ${skipped.reason}`);
464
+ }
465
+ for (const kind of changingKinds) {
466
+ const added = addedRestrictions.get(kind.governs);
467
+ // One blocker per added restriction, naming its violating documents, keeps a large bundle's
468
+ // plan bounded; every scanned instance is still bound into the token by its proof.
469
+ const violations = new Map();
470
+ for (const doc of docs) {
471
+ if (String(doc.frontmatter.type) !== kind.governs)
472
+ continue;
473
+ instancesChecked += 1;
474
+ instanceProofs.push({
475
+ kind: "instance",
476
+ id: doc.id,
477
+ current_version: contentVersion(doc),
478
+ });
479
+ const stored = validateAgainstKind(doc, kind);
480
+ const projected = projectKindValidationWarnings(okfVersion, kind, stored);
481
+ for (const [index, warning] of projected.entries()) {
482
+ // Route on the stored field name, which is how the convention declares the restriction.
483
+ const field = stored[index].field ?? "";
484
+ const restriction = warning.code === "KIND_FIELD_TYPE" && added?.types.has(field)
485
+ ? `fields.types.${field}`
486
+ : (warning.code === "KIND_FIELD_VALUE" || warning.code === "KIND_FIELD_ARITY") && added?.values.has(field)
487
+ ? `fields.values.${field}`
488
+ : undefined;
489
+ if (restriction === undefined) {
490
+ evolutionBlocker(blockers, "RECIPE_EVOLUTION_INSTANCE_INVALID", doc.id, warning.message, warning.field);
491
+ continue;
492
+ }
493
+ const entry = violations.get(restriction) ?? { field, docs: new Map() };
494
+ if (!entry.docs.has(doc.id))
495
+ entry.docs.set(doc.id, warning.message);
496
+ violations.set(restriction, entry);
497
+ }
498
+ }
499
+ for (const [restriction, { field, docs: violating }] of violations) {
500
+ const ids = [...violating.keys()].sort();
501
+ const named = ids.slice(0, RESTRICTION_BLOCKER_DOC_CAP).join(", ");
502
+ const more = ids.length > RESTRICTION_BLOCKER_DOC_CAP ? `, and ${ids.length - RESTRICTION_BLOCKER_DOC_CAP} more` : "";
503
+ const declared = restriction.startsWith("fields.types.")
504
+ ? `type '${added.types.get(field)}'`
505
+ : `allowed values [${added.values.get(field).join(", ")}]`;
506
+ evolutionBlocker(blockers, "RECIPE_EVOLUTION_RESTRICTION_VIOLATED", kind.id, `adding ${declared} to '${field}' for '${kind.governs}' would make ${ids.length} existing document(s) invalid: ${named}${more}; fix them first (e.g. ${ids[0]}: ${violating.get(ids[0])})`, restriction);
507
+ }
508
+ }
509
+ if (addedLinkTexts.size > 0) {
510
+ const proven = new Set(instanceProofs.map((proof) => proof.id));
511
+ for (const doc of linkTypeAdditionBlockers(docs, addedLinkTexts, currentRegistry, prospectiveRegistry, blockers)) {
512
+ if (proven.has(doc.id))
513
+ continue;
514
+ proven.add(doc.id);
515
+ instancesChecked += 1;
516
+ instanceProofs.push({ kind: "instance", id: doc.id, current_version: contentVersion(doc) });
517
+ }
518
+ }
519
+ }
520
+ proofs.push(...instanceProofs.sort((a, b) => a.id.localeCompare(b.id)));
521
+ definitions.sort((a, b) => a.id.localeCompare(b.id));
522
+ proofs.sort((a, b) => `${a.kind}:${a.id}`.localeCompare(`${b.kind}:${b.id}`));
523
+ blockers.sort((a, b) => `${a.code}:${a.id}:${a.field ?? ""}`.localeCompare(`${b.code}:${b.id}:${b.field ?? ""}`));
524
+ // Each definition's stamp state and the stamp the apply writes (the recipe's source digest)
525
+ // are bound into the token, so a stamp that changes after the plan invalidates it.
526
+ const token = evolutionPlanToken({
527
+ recipe: { id: recipe.id, version: recipe.version, source: recipe.source, source_digest: identity.source_digest },
528
+ okfVersion,
529
+ definitions,
530
+ proofs,
531
+ blockers,
532
+ });
533
+ const updates = definitions.filter((definition) => definition.action === "update").length;
534
+ const unchanged = definitions.filter((definition) => definition.action === "unchanged").length;
535
+ const missing = definitions.filter((definition) => definition.action === "missing").length;
536
+ const stampWrites = definitions.filter((definition) => writes.has(definition.id) && definition.action !== "update").length;
537
+ return {
538
+ plan: {
539
+ recipe: "evolution plan",
540
+ id: recipe.id,
541
+ version: recipe.version,
542
+ source: recipe.source,
543
+ okf_version: okfVersion,
544
+ ready: blockers.length === 0,
545
+ changed: updates > 0,
546
+ writes: writes.size,
547
+ plan_token: token,
548
+ definitions,
549
+ counts: {
550
+ definitions: definitions.length,
551
+ updates,
552
+ stamp_writes: stampWrites,
553
+ unchanged,
554
+ missing,
555
+ instances_checked: instancesChecked,
556
+ blockers: blockers.length,
557
+ },
558
+ blockers,
559
+ },
560
+ writes,
561
+ };
562
+ }
563
+ /** Build a read-only, state-bound preflight for installed recipe convention evolution. */
564
+ export async function planRecipeEvolution(bundle, recipe) {
565
+ return (await prepareRecipeEvolution(bundle, recipe)).plan;
566
+ }
567
+ /** Apply only the exact ready plan the caller previously observed. */
568
+ export async function applyRecipeEvolution(bundle, recipe, expectedPlanToken, options = {}) {
569
+ const { actor } = options;
570
+ const prepared = await prepareRecipeEvolution(bundle, recipe);
571
+ const { plan } = prepared;
572
+ if (plan.plan_token !== expectedPlanToken) {
573
+ throw new RecipeEvolutionPlanChanged(expectedPlanToken, plan.plan_token);
574
+ }
575
+ if (!plan.ready) {
576
+ throw new RecipeEvolutionBlocked(plan.id, plan.blockers);
577
+ }
578
+ const completed = [];
579
+ const pending = plan.definitions.filter((definition) => prepared.writes.has(definition.id)).map((definition) => definition.id);
580
+ for (const definition of plan.definitions) {
581
+ const planned = prepared.writes.get(definition.id);
582
+ if (planned === undefined) {
583
+ completed.push({ id: definition.id, changed: false, stamped: false, version: definition.expected_version });
584
+ continue;
585
+ }
586
+ const now = new Date().toISOString();
587
+ const desired = recipeDocumentForApply(planned, plan.okf_version, now);
588
+ try {
589
+ const result = await mutateDocument({
590
+ bundle,
591
+ id: definition.id,
592
+ mode: "replace-document",
593
+ onAbsent: "fail",
594
+ expectedVersion: definition.expected_version,
595
+ maxAttempts: 1,
596
+ registry: RECIPE_EVOLUTION_WRITE_REGISTRY,
597
+ strict: false,
598
+ // Recipe definitions remain source-comparable; like initial recipe installation, evolution
599
+ // explicitly opts out of seeding provenance when the installed definition has none.
600
+ seedGenerationClock: false,
601
+ // Evolution preserves source-comparable attribution; the backend still records its writer.
602
+ persistActor: false,
603
+ actor,
604
+ now: () => now,
605
+ buildCandidate: (_existing, context) => {
606
+ if (context.okfVersion !== plan.okf_version) {
607
+ throw new RecipeEditionChanged(plan.okf_version, context.okfVersion, "evolution");
608
+ }
609
+ return { frontmatter: { ...desired.frontmatter }, body: desired.body };
610
+ },
611
+ });
612
+ completed.push({
613
+ id: definition.id,
614
+ changed: definition.action === "update" && result.changed,
615
+ stamped: true,
616
+ version: result.version,
617
+ });
618
+ pending.shift();
619
+ }
620
+ catch (error) {
621
+ if (error instanceof RecipeEditionChanged) {
622
+ throw new RecipeEvolutionInterrupted("edition_changed", error.message, completed, pending, error);
623
+ }
624
+ if (error instanceof VersionConflict || error instanceof DocumentNotFoundError) {
625
+ throw new RecipeEvolutionInterrupted("race", `recipe evolution lost its exact-version race at '${definition.id}'; inspect a fresh plan and resume`, completed, pending, error);
626
+ }
627
+ // An actor rejection keeps its own typed error for the presenting boundary (a corrected
628
+ // spelling), not a post-preflight policy refusal; every other typed input rejection is one.
629
+ if (error instanceof InvalidInputError && !(error instanceof OkfActorError)) {
630
+ throw new RecipeEvolutionInterrupted("write_policy", `recipe evolution write policy rejected '${definition.id}' after preflight: ${error.message}`, completed, pending, error);
631
+ }
632
+ throw new RecipeEvolutionInterrupted("other", error instanceof Error ? error.message : String(error), completed, pending, error);
633
+ }
634
+ }
635
+ const updated = completed.filter((definition) => definition.changed).length;
636
+ const stamped = completed.filter((definition) => definition.stamped && !definition.changed).length;
637
+ const written = updated + stamped;
638
+ let postcondition;
639
+ try {
640
+ postcondition = await prepareRecipeEvolution(bundle, recipe);
641
+ }
642
+ catch (error) {
643
+ throw new RecipeEvolutionPostcondition("unverifiable", `recipe evolution wrote ${written} definition(s), but its postcondition could not be verified: ${error instanceof Error ? error.message : String(error)}`, completed, { cause: error });
644
+ }
645
+ if (!postcondition.plan.ready || postcondition.plan.changed || postcondition.plan.writes > 0) {
646
+ throw new RecipeEvolutionPostcondition("changed", `recipe evolution wrote ${written} definition(s), but the bundle changed (a definition or its stamp) before the postcondition check; a fresh recipe evolve plan shows the current state`, completed, { blockers: postcondition.plan.blockers, postcondition_plan: postcondition.plan.plan_token });
647
+ }
648
+ return {
649
+ recipe: updated > 0 ? "evolved" : stamped > 0 ? "stamped" : "already current",
650
+ id: plan.id,
651
+ version: plan.version,
652
+ source: plan.source,
653
+ changed: written > 0,
654
+ plan_token: plan.plan_token,
655
+ definitions: completed,
656
+ counts: { updated, stamped, unchanged: completed.length - written },
657
+ };
658
+ }
659
+ //# sourceMappingURL=evolution.js.map