@smthrs/plan 0.0.0-stage → 1.0.0-rc.13

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 (209) hide show
  1. package/CHANGELOG.md +189 -0
  2. package/LICENSE +21 -0
  3. package/README.md +230 -2
  4. package/THIRD_PARTY_NOTICES.md +48 -0
  5. package/dist/cjs/CachePolicy.d.ts +131 -0
  6. package/dist/cjs/CachePolicy.d.ts.map +1 -0
  7. package/dist/cjs/CachePolicy.js +50 -0
  8. package/dist/cjs/CachePolicy.js.map +7 -0
  9. package/dist/cjs/Effects.d.ts +258 -0
  10. package/dist/cjs/Effects.d.ts.map +1 -0
  11. package/dist/cjs/Effects.js +76 -0
  12. package/dist/cjs/Effects.js.map +7 -0
  13. package/dist/cjs/FileSet.d.ts +366 -0
  14. package/dist/cjs/FileSet.d.ts.map +1 -0
  15. package/dist/cjs/FileSet.js +196 -0
  16. package/dist/cjs/FileSet.js.map +7 -0
  17. package/dist/cjs/GraphBuildError.d.ts +120 -0
  18. package/dist/cjs/GraphBuildError.d.ts.map +1 -0
  19. package/dist/cjs/GraphBuildError.js +71 -0
  20. package/dist/cjs/GraphBuildError.js.map +7 -0
  21. package/dist/cjs/KeyMaterial.d.ts +107 -0
  22. package/dist/cjs/KeyMaterial.d.ts.map +1 -0
  23. package/dist/cjs/KeyMaterial.js +64 -0
  24. package/dist/cjs/KeyMaterial.js.map +7 -0
  25. package/dist/cjs/Node.d.ts +685 -0
  26. package/dist/cjs/Node.d.ts.map +1 -0
  27. package/dist/cjs/Node.js +230 -0
  28. package/dist/cjs/Node.js.map +7 -0
  29. package/dist/cjs/Placement.d.ts +117 -0
  30. package/dist/cjs/Placement.d.ts.map +1 -0
  31. package/dist/cjs/Placement.js +46 -0
  32. package/dist/cjs/Placement.js.map +7 -0
  33. package/dist/cjs/Plan.d.ts +489 -0
  34. package/dist/cjs/Plan.d.ts.map +1 -0
  35. package/dist/cjs/Plan.js +511 -0
  36. package/dist/cjs/Plan.js.map +7 -0
  37. package/dist/cjs/PlanDiff.d.ts +77 -0
  38. package/dist/cjs/PlanDiff.d.ts.map +1 -0
  39. package/dist/cjs/PlanDiff.js +149 -0
  40. package/dist/cjs/PlanDiff.js.map +7 -0
  41. package/dist/cjs/Planned.d.ts +121 -0
  42. package/dist/cjs/Planned.d.ts.map +1 -0
  43. package/dist/cjs/Planned.js +72 -0
  44. package/dist/cjs/Planned.js.map +7 -0
  45. package/dist/cjs/Repetition.d.ts +31 -0
  46. package/dist/cjs/Repetition.d.ts.map +1 -0
  47. package/dist/cjs/Repetition.js +36 -0
  48. package/dist/cjs/Repetition.js.map +7 -0
  49. package/dist/cjs/Scheduling.d.ts +89 -0
  50. package/dist/cjs/Scheduling.d.ts.map +1 -0
  51. package/dist/cjs/Scheduling.js +76 -0
  52. package/dist/cjs/Scheduling.js.map +7 -0
  53. package/dist/cjs/StepKey.d.ts +354 -0
  54. package/dist/cjs/StepKey.d.ts.map +1 -0
  55. package/dist/cjs/StepKey.js +313 -0
  56. package/dist/cjs/StepKey.js.map +7 -0
  57. package/dist/cjs/index.d.ts +91 -0
  58. package/dist/cjs/index.d.ts.map +1 -0
  59. package/dist/cjs/index.js +59 -0
  60. package/dist/cjs/index.js.map +7 -0
  61. package/dist/cjs/internal/ConflictAnnotation.d.ts +59 -0
  62. package/dist/cjs/internal/ConflictAnnotation.d.ts.map +1 -0
  63. package/dist/cjs/internal/ConflictAnnotation.js +247 -0
  64. package/dist/cjs/internal/ConflictAnnotation.js.map +7 -0
  65. package/dist/cjs/internal/EffectCandidates.d.ts +19 -0
  66. package/dist/cjs/internal/EffectCandidates.d.ts.map +1 -0
  67. package/dist/cjs/internal/EffectCandidates.js +92 -0
  68. package/dist/cjs/internal/EffectCandidates.js.map +7 -0
  69. package/dist/cjs/internal/JsonMirror.d.ts +34 -0
  70. package/dist/cjs/internal/JsonMirror.d.ts.map +1 -0
  71. package/dist/cjs/internal/JsonMirror.js +195 -0
  72. package/dist/cjs/internal/JsonMirror.js.map +7 -0
  73. package/dist/cjs/internal/SchemaIdentity.d.ts +140 -0
  74. package/dist/cjs/internal/SchemaIdentity.d.ts.map +1 -0
  75. package/dist/cjs/internal/SchemaIdentity.js +718 -0
  76. package/dist/cjs/internal/SchemaIdentity.js.map +7 -0
  77. package/dist/cjs/internal/effects.d.ts +245 -0
  78. package/dist/cjs/internal/effects.d.ts.map +1 -0
  79. package/dist/cjs/internal/effects.js +290 -0
  80. package/dist/cjs/internal/effects.js.map +7 -0
  81. package/dist/cjs/internal/node.d.ts +526 -0
  82. package/dist/cjs/internal/node.d.ts.map +1 -0
  83. package/dist/cjs/internal/node.js +313 -0
  84. package/dist/cjs/internal/node.js.map +7 -0
  85. package/dist/cjs/package.json +1 -0
  86. package/dist/cjs/test/PlanFixtures.d.ts +127 -0
  87. package/dist/cjs/test/PlanFixtures.d.ts.map +1 -0
  88. package/dist/cjs/test/PlanFixtures.js +59 -0
  89. package/dist/cjs/test/PlanFixtures.js.map +7 -0
  90. package/dist/esm/CachePolicy.d.ts +131 -0
  91. package/dist/esm/CachePolicy.d.ts.map +1 -0
  92. package/dist/esm/CachePolicy.js +117 -0
  93. package/dist/esm/CachePolicy.js.map +1 -0
  94. package/dist/esm/Effects.d.ts +258 -0
  95. package/dist/esm/Effects.d.ts.map +1 -0
  96. package/dist/esm/Effects.js +178 -0
  97. package/dist/esm/Effects.js.map +1 -0
  98. package/dist/esm/FileSet.d.ts +366 -0
  99. package/dist/esm/FileSet.d.ts.map +1 -0
  100. package/dist/esm/FileSet.js +398 -0
  101. package/dist/esm/FileSet.js.map +1 -0
  102. package/dist/esm/GraphBuildError.d.ts +120 -0
  103. package/dist/esm/GraphBuildError.d.ts.map +1 -0
  104. package/dist/esm/GraphBuildError.js +145 -0
  105. package/dist/esm/GraphBuildError.js.map +1 -0
  106. package/dist/esm/KeyMaterial.d.ts +107 -0
  107. package/dist/esm/KeyMaterial.d.ts.map +1 -0
  108. package/dist/esm/KeyMaterial.js +92 -0
  109. package/dist/esm/KeyMaterial.js.map +1 -0
  110. package/dist/esm/Node.d.ts +685 -0
  111. package/dist/esm/Node.d.ts.map +1 -0
  112. package/dist/esm/Node.js +591 -0
  113. package/dist/esm/Node.js.map +1 -0
  114. package/dist/esm/Placement.d.ts +117 -0
  115. package/dist/esm/Placement.d.ts.map +1 -0
  116. package/dist/esm/Placement.js +65 -0
  117. package/dist/esm/Placement.js.map +1 -0
  118. package/dist/esm/Plan.d.ts +489 -0
  119. package/dist/esm/Plan.d.ts.map +1 -0
  120. package/dist/esm/Plan.js +718 -0
  121. package/dist/esm/Plan.js.map +1 -0
  122. package/dist/esm/PlanDiff.d.ts +77 -0
  123. package/dist/esm/PlanDiff.d.ts.map +1 -0
  124. package/dist/esm/PlanDiff.js +205 -0
  125. package/dist/esm/PlanDiff.js.map +1 -0
  126. package/dist/esm/Planned.d.ts +121 -0
  127. package/dist/esm/Planned.d.ts.map +1 -0
  128. package/dist/esm/Planned.js +147 -0
  129. package/dist/esm/Planned.js.map +1 -0
  130. package/dist/esm/Repetition.d.ts +31 -0
  131. package/dist/esm/Repetition.d.ts.map +1 -0
  132. package/dist/esm/Repetition.js +24 -0
  133. package/dist/esm/Repetition.js.map +1 -0
  134. package/dist/esm/Scheduling.d.ts +89 -0
  135. package/dist/esm/Scheduling.d.ts.map +1 -0
  136. package/dist/esm/Scheduling.js +82 -0
  137. package/dist/esm/Scheduling.js.map +1 -0
  138. package/dist/esm/StepKey.d.ts +354 -0
  139. package/dist/esm/StepKey.d.ts.map +1 -0
  140. package/dist/esm/StepKey.js +503 -0
  141. package/dist/esm/StepKey.js.map +1 -0
  142. package/dist/esm/index.d.ts +91 -0
  143. package/dist/esm/index.d.ts.map +1 -0
  144. package/dist/esm/index.js +91 -0
  145. package/dist/esm/index.js.map +1 -0
  146. package/dist/esm/internal/ConflictAnnotation.d.ts +59 -0
  147. package/dist/esm/internal/ConflictAnnotation.d.ts.map +1 -0
  148. package/dist/esm/internal/ConflictAnnotation.js +332 -0
  149. package/dist/esm/internal/ConflictAnnotation.js.map +1 -0
  150. package/dist/esm/internal/EffectCandidates.d.ts +19 -0
  151. package/dist/esm/internal/EffectCandidates.d.ts.map +1 -0
  152. package/dist/esm/internal/EffectCandidates.js +86 -0
  153. package/dist/esm/internal/EffectCandidates.js.map +1 -0
  154. package/dist/esm/internal/JsonMirror.d.ts +34 -0
  155. package/dist/esm/internal/JsonMirror.d.ts.map +1 -0
  156. package/dist/esm/internal/JsonMirror.js +204 -0
  157. package/dist/esm/internal/JsonMirror.js.map +1 -0
  158. package/dist/esm/internal/SchemaIdentity.d.ts +140 -0
  159. package/dist/esm/internal/SchemaIdentity.d.ts.map +1 -0
  160. package/dist/esm/internal/SchemaIdentity.js +1002 -0
  161. package/dist/esm/internal/SchemaIdentity.js.map +1 -0
  162. package/dist/esm/internal/effects.d.ts +245 -0
  163. package/dist/esm/internal/effects.d.ts.map +1 -0
  164. package/dist/esm/internal/effects.js +459 -0
  165. package/dist/esm/internal/effects.js.map +1 -0
  166. package/dist/esm/internal/node.d.ts +526 -0
  167. package/dist/esm/internal/node.d.ts.map +1 -0
  168. package/dist/esm/internal/node.js +565 -0
  169. package/dist/esm/internal/node.js.map +1 -0
  170. package/dist/esm/test/PlanFixtures.d.ts +127 -0
  171. package/dist/esm/test/PlanFixtures.d.ts.map +1 -0
  172. package/dist/esm/test/PlanFixtures.js +60 -0
  173. package/dist/esm/test/PlanFixtures.js.map +1 -0
  174. package/docs/README.md +167 -0
  175. package/docs/api.md +1485 -0
  176. package/docs/concepts/authoring-ast.md +204 -0
  177. package/docs/concepts/effects-and-conflicts.md +135 -0
  178. package/docs/concepts/plan-value.md +168 -0
  179. package/docs/concepts/step-keys.md +179 -0
  180. package/docs/guides/author-a-node-graph.md +182 -0
  181. package/docs/guides/compile-a-plan.md +146 -0
  182. package/docs/guides/declare-file-effects.md +143 -0
  183. package/docs/guides/diff-two-plans.md +310 -0
  184. package/docs/installation.md +75 -0
  185. package/docs/quickstart.md +128 -0
  186. package/docs/testing.md +73 -0
  187. package/docs/troubleshooting.md +314 -0
  188. package/package.json +255 -3
  189. package/src/CachePolicy.ts +143 -0
  190. package/src/Effects.ts +302 -0
  191. package/src/FileSet.ts +493 -0
  192. package/src/GraphBuildError.ts +156 -0
  193. package/src/KeyMaterial.ts +111 -0
  194. package/src/Node.ts +974 -0
  195. package/src/Placement.ts +99 -0
  196. package/src/Plan.ts +911 -0
  197. package/src/PlanDiff.ts +244 -0
  198. package/src/Planned.ts +208 -0
  199. package/src/Repetition.ts +33 -0
  200. package/src/Scheduling.ts +154 -0
  201. package/src/StepKey.ts +736 -0
  202. package/src/index.ts +103 -0
  203. package/src/internal/ConflictAnnotation.ts +374 -0
  204. package/src/internal/EffectCandidates.ts +89 -0
  205. package/src/internal/JsonMirror.ts +255 -0
  206. package/src/internal/SchemaIdentity.ts +1096 -0
  207. package/src/internal/effects.ts +543 -0
  208. package/src/internal/node.ts +859 -0
  209. package/src/test/PlanFixtures.ts +76 -0
package/src/Plan.ts ADDED
@@ -0,0 +1,911 @@
1
+ /**
2
+ * The plan: a node graph with every step key computed, inert until run.
3
+ *
4
+ * The way a plan is produced is a law: **planning performs no I/O**.
5
+ * Everything in this module is therefore a pure function of declarations plus
6
+ * the injected `Crypto` service: no filesystem, no clock, no network. A node's
7
+ * key is a function of what it consumes, so an edited declaration re-keys that
8
+ * node and everything downstream of it, and nothing else. That is the entire
9
+ * invalidation mechanism. Invalidation is re-keying, which **rejects**
10
+ * Skyframe's reverse-dependency index and invalidating node visitor outright
11
+ * because content addressing subsumes them. Key invalidation never retains a
12
+ * reverse-dependency index; annotation uses only a temporary reachability cache.
13
+ *
14
+ * Growth is append-only: {@link append} adds a pre-keyed subgraph to the same
15
+ * plan and never rewrites a node already in it.
16
+ *
17
+ * @since 0.1.0
18
+ */
19
+
20
+ import { DerivedKey, StoredKey } from "@smthrs/keys"
21
+ import type * as Crypto from "effect/Crypto"
22
+ import * as Effect from "effect/Effect"
23
+ import * as Inspectable from "effect/Inspectable"
24
+ import * as Schema from "effect/Schema"
25
+ import * as Effects from "./Effects.ts"
26
+ import * as FileSet from "./FileSet.ts"
27
+ import { GraphBuildError } from "./GraphBuildError.ts"
28
+ import * as Conflicts from "./internal/ConflictAnnotation.ts"
29
+ import { jsonMirror } from "./internal/JsonMirror.ts"
30
+ import * as KeyMaterial from "./KeyMaterial.ts"
31
+ import * as StepKey from "./StepKey.ts"
32
+
33
+ /**
34
+ * What a node does to the world, declared. Paths only — measuring them is
35
+ * run-time work, so a digest here would break the no-I/O law.
36
+ *
37
+ * Decoding fails with a `SchemaError` when a path is not workspace-relative.
38
+ * {@link compile} also refuses a node whose `removes` overlaps its `writes`
39
+ * with an `invalid_effects` {@link PlanError}.
40
+ *
41
+ * @since 0.1.0
42
+ * @category schemas
43
+ */
44
+ export const NodeEffects = Schema.Struct({
45
+ /** Paths the node reads. A read orders the node after every writer of an overlapping path. */
46
+ reads: Schema.Array(FileSet.ReadDeclaration),
47
+ /** Paths the node writes. Two writers of an overlapping path form a conflict. */
48
+ writes: Schema.Array(FileSet.Declaration),
49
+ /**
50
+ * Paths the node declares it will DELETE. A removal mutates the world exactly
51
+ * as a write does, so {@link produces} folds the two together and both the
52
+ * conflict pass and the reader-after-writer pass see them as one set.
53
+ */
54
+ removes: Schema.optional(Schema.Array(FileSet.Pattern)),
55
+ /** How strictly the sandbox enforces these declarations. */
56
+ boundaryMode: FileSet.BoundaryMode
57
+ })
58
+
59
+ /**
60
+ * The value form of {@link NodeEffects}.
61
+ *
62
+ * @since 0.1.0
63
+ * @category models
64
+ */
65
+ export type NodeEffects = typeof NodeEffects.Type
66
+
67
+ /**
68
+ * The plan-time verdict for two writers of an overlapping path:
69
+ * `serialize` gives the later writer an ordering edge, `lane` records a lane
70
+ * annotation on both writers and adds no edge, and `fail` refuses the plan
71
+ * with `overlap_forbidden`. For a pair, `fail` dominates `lane`, which
72
+ * dominates `serialize`. The same schema as `Effects.ConflictStrategy`.
73
+ *
74
+ * @since 0.1.0
75
+ * @category schemas
76
+ */
77
+ export const PairStrategy = Effects.ConflictStrategy
78
+
79
+ /**
80
+ * The value form of {@link PairStrategy}.
81
+ *
82
+ * @since 0.1.0
83
+ * @category models
84
+ */
85
+ export type PairStrategy = typeof PairStrategy.Type
86
+
87
+ /**
88
+ * The runtime half of a conflict annotation: what the scheduler
89
+ * does when the overlap the plan predicted actually bites, or when a
90
+ * scheduled node's inputs are invalidated under it. For a pair,
91
+ * `stop-merge` dominates `delay-rebase`.
92
+ *
93
+ * @since 0.1.0
94
+ * @category schemas
95
+ */
96
+ export const RuntimeStrategy = Schema.Literals(["delay-rebase", "stop-merge"])
97
+
98
+ /**
99
+ * The value form of {@link RuntimeStrategy}.
100
+ *
101
+ * @since 0.1.0
102
+ * @category models
103
+ */
104
+ export type RuntimeStrategy = typeof RuntimeStrategy.Type
105
+
106
+ /** @private */
107
+ const NodeKind = Schema.Literals(["step", "agent", "merge"])
108
+
109
+ /**
110
+ * One resolved overlap between two writers that no dependency path already
111
+ * orders. Conflict is a property of the PAIR, not of one declaration.
112
+ *
113
+ * `with` names the other node, `paths` lists the overlapping declarations,
114
+ * and `strategy` and `runtime` are the verdicts resolved for the pair.
115
+ *
116
+ * @since 0.1.0
117
+ * @category schemas
118
+ */
119
+ export const ConflictAnnotation = Schema.Struct({
120
+ with: Schema.NonEmptyString,
121
+ paths: Schema.Array(Schema.String),
122
+ strategy: PairStrategy,
123
+ runtime: RuntimeStrategy
124
+ })
125
+
126
+ /**
127
+ * The value form of {@link ConflictAnnotation}.
128
+ *
129
+ * @since 0.1.0
130
+ * @category models
131
+ */
132
+ export type ConflictAnnotation = typeof ConflictAnnotation.Type
133
+
134
+ /**
135
+ * A keyed node of the plan.
136
+ *
137
+ * `dependsOn` is the *edge* set: material references, any ordering edge a
138
+ * `serialize` verdict added, and the reader-after-writer edges that put a
139
+ * node behind whoever produces the paths it reads. Ordering edges are
140
+ * deliberately NOT part of the key — a node ordered behind another still
141
+ * computes the same result, so re-keying it would throw away a legitimate
142
+ * cache hit.
143
+ *
144
+ * `strategy` and `runtime` are this declaration's own preferences, recorded so
145
+ * a later elaboration can resolve a pair against them without re-reading the
146
+ * flow source.
147
+ *
148
+ * `generation` is 0 for nodes from {@link compile} and grows by one with each
149
+ * {@link append}.
150
+ *
151
+ * @since 0.1.0
152
+ * @category schemas
153
+ */
154
+ export const PlanNode = Schema.Struct({
155
+ id: Schema.NonEmptyString,
156
+ kind: NodeKind,
157
+ key: StoredKey,
158
+ material: KeyMaterial.KeyMaterial,
159
+ effects: NodeEffects,
160
+ dependsOn: Schema.Array(Schema.NonEmptyString),
161
+ conflicts: Schema.Array(ConflictAnnotation),
162
+ strategy: PairStrategy,
163
+ runtime: RuntimeStrategy,
164
+ priority: Schema.Int,
165
+ generation: Schema.Int
166
+ })
167
+
168
+ /**
169
+ * The value form of {@link PlanNode}.
170
+ *
171
+ * @since 0.1.0
172
+ * @category models
173
+ */
174
+ export type PlanNode = typeof PlanNode.Type
175
+
176
+ /**
177
+ * A plan: the whole keyed graph plus the digest an approval binds to.
178
+ *
179
+ * `nodes` follows material-dependency order within each generation. Inferred
180
+ * edges can point to later entries; schedule from the complete `dependsOn`
181
+ * graph, never by iterating the array alone.
182
+ *
183
+ * `baseDigest` is the digest at generation 0: what a human approved and what
184
+ * a `RUNNING` run pins. `digest` advances with every appended elaboration.
185
+ *
186
+ * Decoding checks only the shape. Use {@link verify} to check that keys,
187
+ * edges, and digests match the declarations.
188
+ *
189
+ * @since 0.1.0
190
+ * @category schemas
191
+ */
192
+ export const Plan = Schema.Struct({
193
+ planId: Schema.NonEmptyString,
194
+ flow: Schema.NonEmptyString,
195
+ generation: Schema.Int,
196
+ baseDigest: StoredKey,
197
+ digest: StoredKey,
198
+ nodes: Schema.Array(PlanNode)
199
+ })
200
+
201
+ /**
202
+ * The value form of {@link Plan}.
203
+ *
204
+ * @since 0.1.0
205
+ * @category models
206
+ */
207
+ export type Plan = typeof Plan.Type
208
+
209
+ /**
210
+ * What a planner hands {@link compile}: a node without its key.
211
+ *
212
+ * @since 0.1.0
213
+ * @category models
214
+ */
215
+ export interface NodeDraft {
216
+ /** A non-empty id, unique within the plan. */
217
+ readonly id: string
218
+ /** The key inputs. Its `Ref` inputs name the node's material dependencies. */
219
+ readonly material: KeyMaterial.KeyMaterial
220
+ /** The declared reads, writes, and removals. They replace `material.effects`. */
221
+ readonly effects: NodeEffects
222
+ /** `step`, `agent`, or `merge`. Defaults to `step`. */
223
+ readonly kind?: PlanNode["kind"] | undefined
224
+ /** A safe integer scheduling priority. Defaults to 0. */
225
+ readonly priority?: number | undefined
226
+ /** This declaration's preferred plan-time verdict for a detected overlap. */
227
+ readonly conflictStrategy?: PairStrategy | undefined
228
+ /** This declaration's preferred runtime response. */
229
+ readonly runtimeStrategy?: RuntimeStrategy | undefined
230
+ }
231
+
232
+ /**
233
+ * A graph the compiler refuses. `code` names the rule:
234
+ *
235
+ * - `cycle`: material or inferred edges form a cycle.
236
+ * - `unknown_dependency`: a `Ref` names a node that is not in the plan.
237
+ * - `duplicate_node`: two nodes share an id.
238
+ * - `overlap_forbidden`: two unordered writers overlap and the pair's
239
+ * strategy is `fail`.
240
+ * - `invalid_effects`: a node both writes and removes one path.
241
+ * - `invalid_node`: a draft field, the plan id, or the flow name is malformed.
242
+ * - `invalid_plan`: {@link verify} found a plan that does not match its
243
+ * declarations.
244
+ * - `graph_too_large`: the plan exceeds {@link maximumPlanNodes} or the
245
+ * effect-analysis work budget.
246
+ *
247
+ * @since 0.1.0
248
+ * @category errors
249
+ */
250
+ export class PlanError extends Schema.TaggedError<PlanError>()("@smthrs/plan/PlanError", {
251
+ code: Schema.Literals([
252
+ "cycle",
253
+ "unknown_dependency",
254
+ "duplicate_node",
255
+ "overlap_forbidden",
256
+ "invalid_effects",
257
+ "invalid_node",
258
+ "invalid_plan",
259
+ "graph_too_large"
260
+ ]),
261
+ message: Schema.String
262
+ }) {}
263
+
264
+ /**
265
+ * Maximum number of nodes retained by one compiled plan.
266
+ *
267
+ * Effect analysis also has a yielding work budget, since a node ceiling alone
268
+ * does not bound dense conflicts to practical work.
269
+ *
270
+ * @since 1.0.0
271
+ * @category limits
272
+ */
273
+ export const maximumPlanNodes = 10_000
274
+
275
+ /** @private */
276
+ const graphSizeError = (size: number): PlanError =>
277
+ new PlanError({
278
+ code: "graph_too_large",
279
+ message: `A plan may contain at most ${maximumPlanNodes} nodes, received ${size}`
280
+ })
281
+
282
+ /** @private */
283
+ type Ordered =
284
+ | { readonly ok: true; readonly drafts: ReadonlyArray<NodeDraft> }
285
+ | { readonly ok: false; readonly error: PlanError }
286
+
287
+ /**
288
+ * Topologically orders drafts by their MATERIAL dependencies — the only edges
289
+ * that exist before keys do.
290
+ *
291
+ * @private
292
+ */
293
+ const topological = (drafts: ReadonlyArray<NodeDraft>, known: ReadonlySet<string>): Ordered => {
294
+ const byId = new Map(drafts.map((draft) => [draft.id, draft]))
295
+ const ordered: Array<NodeDraft> = []
296
+ const state = new Map<string, "visiting" | "done">()
297
+ interface Frame {
298
+ readonly draft: NodeDraft
299
+ readonly dependencies: ReadonlyArray<string>
300
+ next: number
301
+ }
302
+ for (const draft of drafts) {
303
+ if (state.get(draft.id) === "done") continue
304
+ state.set(draft.id, "visiting")
305
+ const stack: Array<Frame> = [{ draft, dependencies: KeyMaterial.dependencies(draft.material), next: 0 }]
306
+ while (stack.length > 0) {
307
+ const frame = stack[stack.length - 1]!
308
+ if (frame.next === frame.dependencies.length) {
309
+ state.set(frame.draft.id, "done")
310
+ ordered.push(frame.draft)
311
+ stack.pop()
312
+ continue
313
+ }
314
+ const dependency = frame.dependencies[frame.next++]!
315
+ if (known.has(dependency)) continue
316
+ const next = byId.get(dependency)
317
+ if (next === undefined) {
318
+ return {
319
+ ok: false,
320
+ error: new PlanError({
321
+ code: "unknown_dependency",
322
+ message: `Node ${frame.draft.id} depends on unknown node ${dependency}`
323
+ })
324
+ }
325
+ }
326
+ const mark = state.get(next.id)
327
+ if (mark === "done") continue
328
+ if (mark === "visiting") {
329
+ return {
330
+ ok: false,
331
+ error: new PlanError({ code: "cycle", message: `Plan cycle through node ${next.id}` })
332
+ }
333
+ }
334
+ state.set(next.id, "visiting")
335
+ stack.push({ draft: next, dependencies: KeyMaterial.dependencies(next.material), next: 0 })
336
+ }
337
+ }
338
+ return { ok: true, drafts: ordered }
339
+ }
340
+
341
+ /**
342
+ * Runs the conflict and reader-after-writer passes and records what they found
343
+ * on every node outside `frozen`, whose rows an append never rewrites. A
344
+ * refused graph fails as a `PlanError` carrying the pass's code and message.
345
+ *
346
+ * @private
347
+ */
348
+ const annotate = (
349
+ nodes: ReadonlyArray<PlanNode>,
350
+ frozen: ReadonlySet<string>,
351
+ replayGenerations = false
352
+ ): Effect.Effect<ReadonlyArray<PlanNode>, PlanError> =>
353
+ Conflicts.annotate(nodes, frozen.size, replayGenerations).pipe(
354
+ Effect.mapError((refusal) => new PlanError(refusal)),
355
+ Effect.map(({ conflicts, ordering }) =>
356
+ nodes.map((node) => {
357
+ if (frozen.has(node.id)) return node
358
+ const added = ordering.get(node.id) ?? []
359
+ return freezeNode({
360
+ ...node,
361
+ conflicts: conflicts.get(node.id) ?? [],
362
+ dependsOn: added.length === 0 ? node.dependsOn : [...node.dependsOn, ...added]
363
+ })
364
+ })
365
+ )
366
+ )
367
+
368
+ /**
369
+ * The single approval projection. Its node list is the complete reviewable and
370
+ * behavior-bearing contract: id, kind, key, edges, conflict annotations,
371
+ * declared effects, conflict strategy, runtime strategy, and priority.
372
+ *
373
+ * @private
374
+ */
375
+ const approvalPayload = (
376
+ planId: string,
377
+ flow: string,
378
+ nodes: ReadonlyArray<PlanNode>
379
+ ) => ({
380
+ body: { kind: "plan", planId, flow },
381
+ inputs: {
382
+ nodes: nodes.map((node) => ({
383
+ id: node.id,
384
+ kind: node.kind,
385
+ key: node.key,
386
+ dependsOn: node.dependsOn,
387
+ conflicts: node.conflicts,
388
+ effects: node.effects,
389
+ strategy: node.strategy,
390
+ runtime: node.runtime,
391
+ priority: node.priority
392
+ }))
393
+ },
394
+ layers: [],
395
+ capabilities: {}
396
+ })
397
+
398
+ /**
399
+ * The plan's digest: what an approval binds to. Deriving it only from
400
+ * {@link approvalPayload} keeps the approved field list in one place.
401
+ *
402
+ * @private
403
+ */
404
+ const digestOf = (
405
+ planId: string,
406
+ flow: string,
407
+ nodes: ReadonlyArray<PlanNode>
408
+ ): Effect.Effect<StepKey.StepKey, StepKey.KeyMaterialError | Schema.SchemaError, Crypto.Crypto> =>
409
+ StepKey.content(approvalPayload(planId, flow, nodes))
410
+
411
+ /** @private */
412
+ const maximumDescriptionLength = 256
413
+
414
+ /** @private */
415
+ const describeValue = (value: unknown): string => {
416
+ const rendered = typeof value === "string" ? JSON.stringify(value) : Inspectable.toStringUnknown(value, 0)
417
+ return rendered.length > maximumDescriptionLength
418
+ ? `${rendered.slice(0, maximumDescriptionLength - 3)}...`
419
+ : rendered
420
+ }
421
+
422
+ /** @private */
423
+ const describeNodeId = (id: string): string => id.length > maximumDescriptionLength ? describeValue(id) : id
424
+
425
+ /** @private */
426
+ const validateIdentity = (planId: unknown, flow: unknown): PlanError | undefined => {
427
+ if (typeof planId !== "string" || planId.length === 0) {
428
+ return new PlanError({
429
+ code: "invalid_node",
430
+ message: `Plan option planId must be a non-empty string, received ${describeValue(planId)}`
431
+ })
432
+ }
433
+ if (typeof flow !== "string" || flow.length === 0) {
434
+ return new PlanError({
435
+ code: "invalid_node",
436
+ message: `Plan option flow must be a non-empty string, received ${describeValue(flow)}`
437
+ })
438
+ }
439
+ return undefined
440
+ }
441
+
442
+ /** Whether `value` is a container this module may safely copy. */
443
+ const isSnapshotContainer = (value: unknown): value is object => {
444
+ if (typeof value !== "object" || value === null) return false
445
+ if (Array.isArray(value)) return true
446
+ const prototype = Object.getPrototypeOf(value)
447
+ return prototype === Object.prototype || prototype === null
448
+ }
449
+
450
+ /** Creates an empty container with the source container's supported prototype. */
451
+ const emptySnapshot = (source: object): object =>
452
+ Array.isArray(source) ? [] : Object.create(Object.getPrototypeOf(source)) as object
453
+
454
+ /**
455
+ * Copies and freezes plain structural containers without interpreting them as
456
+ * JSON. Shared references and cycles point at one cloned container through
457
+ * `memo`; every other value passes through by reference and remains unfrozen.
458
+ * In particular, this does not probe a `Planned` proxy. `validateDrafts`
459
+ * creates the inert material mirror inside the Effect while retaining that
460
+ * proxy, so the canonical serializer still reaches and refuses it.
461
+ */
462
+ const snapshot = <A extends object>(value: A): A => {
463
+ const root = emptySnapshot(value)
464
+ const memo = new WeakMap<object, object>([[value, root]])
465
+ const pending: Array<{ readonly source: object; readonly target: object }> = [{ source: value, target: root }]
466
+ const created = [root]
467
+
468
+ while (pending.length > 0) {
469
+ const { source, target } = pending.pop()!
470
+ for (const key of Reflect.ownKeys(source)) {
471
+ const descriptor = Object.getOwnPropertyDescriptor(source, key)!
472
+ if ("value" in descriptor && isSnapshotContainer(descriptor.value)) {
473
+ let child = memo.get(descriptor.value)
474
+ if (child === undefined) {
475
+ child = emptySnapshot(descriptor.value)
476
+ memo.set(descriptor.value, child)
477
+ pending.push({ source: descriptor.value, target: child })
478
+ created.push(child)
479
+ }
480
+ descriptor.value = child
481
+ }
482
+ Object.defineProperty(target, key, descriptor)
483
+ }
484
+ }
485
+
486
+ for (const container of created) Object.freeze(container)
487
+ return root as A
488
+ }
489
+
490
+ /** Freezes one node built by this module while retaining its material snapshot. */
491
+ const freezeNode = (node: PlanNode): PlanNode =>
492
+ Object.freeze({
493
+ ...node,
494
+ dependsOn: Object.freeze([...node.dependsOn]),
495
+ conflicts: Object.freeze(
496
+ node.conflicts.map((conflict) => Object.freeze({ ...conflict, paths: Object.freeze([...conflict.paths]) }))
497
+ )
498
+ })
499
+
500
+ /** Plans whose complete immutable snapshot was created by this module. */
501
+ const frozenPlans = new WeakSet<Plan>()
502
+
503
+ /** Freezes the plan envelope and its newly built node list. */
504
+ const freezePlan = (plan: Plan): Plan => {
505
+ const frozen = Object.freeze({ ...plan, nodes: Object.freeze([...plan.nodes]) })
506
+ frozenPlans.add(frozen)
507
+ return frozen
508
+ }
509
+
510
+ /**
511
+ * Validates the runtime-only parts of `NodeDraft` without replacing effect
512
+ * admission. Effect 4 Struct decoding strips excess properties and copies its
513
+ * schema arrays. The decoded effect declaration replaces any caller-supplied
514
+ * `material.effects`, and every accepted draft receives one deeply frozen JSON
515
+ * mirror for both storage and hashing.
516
+ *
517
+ * @private
518
+ */
519
+ const validateDrafts = (
520
+ drafts: ReadonlyArray<NodeDraft>
521
+ ): Effect.Effect<ReadonlyArray<NodeDraft>, PlanError> =>
522
+ Effect.forEach(drafts, (draft) =>
523
+ Effect.gen(function*() {
524
+ if (typeof draft.id !== "string" || draft.id.length === 0) {
525
+ return yield* Effect.fail(
526
+ new PlanError({
527
+ code: "invalid_node",
528
+ message: `Node id must be a non-empty string, received ${describeValue(draft.id)}`
529
+ })
530
+ )
531
+ }
532
+ const nodeId = describeNodeId(draft.id)
533
+ if (draft.priority !== undefined && !Number.isSafeInteger(draft.priority)) {
534
+ return yield* Effect.fail(
535
+ new PlanError({
536
+ code: "invalid_node",
537
+ message: `Node ${nodeId} priority must be a safe integer, received ${describeValue(draft.priority)}`
538
+ })
539
+ )
540
+ }
541
+ if (draft.kind !== undefined && !Schema.is(NodeKind)(draft.kind)) {
542
+ return yield* Effect.fail(
543
+ new PlanError({
544
+ code: "invalid_node",
545
+ message: `Node ${nodeId} kind must be step, agent, or merge, received ${describeValue(draft.kind)}`
546
+ })
547
+ )
548
+ }
549
+ if (draft.conflictStrategy !== undefined && !Schema.is(PairStrategy)(draft.conflictStrategy)) {
550
+ return yield* Effect.fail(
551
+ new PlanError({
552
+ code: "invalid_node",
553
+ message: `Node ${nodeId} conflictStrategy must be serialize, lane, or fail, received ${
554
+ describeValue(draft.conflictStrategy)
555
+ }`
556
+ })
557
+ )
558
+ }
559
+ if (draft.runtimeStrategy !== undefined && !Schema.is(RuntimeStrategy)(draft.runtimeStrategy)) {
560
+ return yield* Effect.fail(
561
+ new PlanError({
562
+ code: "invalid_node",
563
+ message: `Node ${nodeId} runtimeStrategy must be delay-rebase or stop-merge, received ${
564
+ describeValue(draft.runtimeStrategy)
565
+ }`
566
+ })
567
+ )
568
+ }
569
+ const decodedMaterial = yield* Schema.decodeUnknownEffect(KeyMaterial.KeyMaterial)(draft.material).pipe(
570
+ Effect.mapError((cause) =>
571
+ new PlanError({
572
+ code: "invalid_node",
573
+ message: `Node ${nodeId} has invalid material: ${cause.message}`
574
+ })
575
+ )
576
+ )
577
+ const decodedEffects = yield* Schema.decodeUnknownEffect(NodeEffects)(draft.effects).pipe(
578
+ Effect.mapError((cause) =>
579
+ new PlanError({
580
+ code: "invalid_node",
581
+ message: `Node ${nodeId} has invalid effects: ${cause.message}`
582
+ })
583
+ )
584
+ )
585
+ let material: KeyMaterial.KeyMaterial
586
+ try {
587
+ material = snapshot(
588
+ jsonMirror({ ...decodedMaterial, effects: decodedEffects }, (planned) => planned) as KeyMaterial.KeyMaterial
589
+ )
590
+ } catch (cause) {
591
+ const path = cause instanceof GraphBuildError ? cause.path : []
592
+ return yield* Effect.fail(
593
+ new PlanError({
594
+ code: "invalid_node",
595
+ message: `Node ${nodeId} has invalid material payload at ${["$", ...path].join(".")}`
596
+ })
597
+ )
598
+ }
599
+ return Object.freeze({
600
+ ...draft,
601
+ material,
602
+ effects: material.effects as NodeEffects
603
+ })
604
+ }))
605
+
606
+ /**
607
+ * The effects schema admits each individual declaration in `validateDrafts`.
608
+ * This pass enforces the cross-field invariant that one path cannot be both a
609
+ * write and a removal.
610
+ *
611
+ * @private
612
+ */
613
+ const validateEffects = (id: string, effects: NodeEffects): PlanError | undefined => {
614
+ const removes = effects.removes ?? []
615
+ const writes = FileSet.expand(effects.writes)
616
+ for (const removal of removes) {
617
+ if (writes.some((entry) => FileSet.overlaps(entry, removal))) {
618
+ return new PlanError({
619
+ code: "invalid_effects",
620
+ message: `Node ${id} declares ${removal} as both a write and a removal`
621
+ })
622
+ }
623
+ }
624
+ return undefined
625
+ }
626
+
627
+ /** @private */
628
+ const keyNodes = (
629
+ drafts: ReadonlyArray<NodeDraft>,
630
+ existing: ReadonlyArray<PlanNode>,
631
+ generation: number
632
+ ): Effect.Effect<
633
+ ReadonlyArray<PlanNode>,
634
+ PlanError | StepKey.KeyMaterialError | Schema.SchemaError,
635
+ Crypto.Crypto
636
+ > =>
637
+ Effect.gen(function*() {
638
+ const digests: Record<string, string> = Object.create(null) as Record<string, string>
639
+ const known = new Set<string>()
640
+ for (const node of existing) {
641
+ Object.defineProperty(digests, node.id, {
642
+ configurable: true,
643
+ enumerable: true,
644
+ value: node.key,
645
+ writable: true
646
+ })
647
+ known.add(node.id)
648
+ }
649
+ for (const draft of drafts) {
650
+ if (known.has(draft.id)) {
651
+ return yield* Effect.fail(
652
+ new PlanError({ code: "duplicate_node", message: `Node ${draft.id} is already in the plan` })
653
+ )
654
+ }
655
+ known.add(draft.id)
656
+ }
657
+ const sorted = topological(drafts, new Set(existing.map((node) => node.id)))
658
+ if (!sorted.ok) return yield* Effect.fail(sorted.error)
659
+ const keyed: Array<PlanNode> = []
660
+ for (const draft of sorted.drafts) {
661
+ const invalid = validateEffects(draft.id, draft.effects)
662
+ if (invalid !== undefined) return yield* Effect.fail(invalid)
663
+ const key = yield* StepKey.planIdentity(draft.material, digests)
664
+ Object.defineProperty(digests, draft.id, {
665
+ configurable: true,
666
+ enumerable: true,
667
+ value: key,
668
+ writable: true
669
+ })
670
+ keyed.push(freezeNode({
671
+ id: draft.id,
672
+ kind: draft.kind ?? "step",
673
+ key,
674
+ material: draft.material,
675
+ effects: draft.effects,
676
+ dependsOn: KeyMaterial.dependencies(draft.material),
677
+ conflicts: [],
678
+ strategy: draft.conflictStrategy ?? "serialize",
679
+ runtime: draft.runtimeStrategy ?? "delay-rebase",
680
+ priority: draft.priority ?? 0,
681
+ generation
682
+ }))
683
+ }
684
+ return keyed
685
+ })
686
+
687
+ /**
688
+ * Compiles drafts into a plan: material-dependency order, dependency-digest
689
+ * substitution, overlap annotation, and the plan digest. No I/O.
690
+ * Inferred edges extend `dependsOn` without reordering the node array.
691
+ *
692
+ * Traversal uses explicit stacks. Because the conflict and reader/writer
693
+ * passes consider a quadratic number of node pairs, plans are bounded by
694
+ * {@link maximumPlanNodes} and fail with `graph_too_large` above that limit
695
+ * or the effect-analysis work budget. Analysis yields periodically.
696
+ *
697
+ * The options and drafts are copied when `compile` is called, so later
698
+ * caller mutation does not reach the plan. The result is deeply frozen.
699
+ *
700
+ * @param options - `planId` and `flow` name the plan; `nodes` are the drafts.
701
+ * @returns The generation-0 plan, whose `baseDigest` equals its `digest`.
702
+ * Fails with {@link PlanError} for a refused graph, `KeyMaterialError` or
703
+ * `SchemaError` when a key cannot be derived.
704
+ * @since 0.1.0
705
+ * @category constructors
706
+ */
707
+ export const compile = (options: {
708
+ readonly planId: string
709
+ readonly flow: string
710
+ readonly nodes: ReadonlyArray<NodeDraft>
711
+ }): Effect.Effect<Plan, PlanError | StepKey.KeyMaterialError | Schema.SchemaError, Crypto.Crypto> => {
712
+ const captured = snapshot(options)
713
+ return Effect.gen(function*() {
714
+ const invalidIdentity = validateIdentity(captured.planId, captured.flow)
715
+ if (invalidIdentity !== undefined) return yield* Effect.fail(invalidIdentity)
716
+ if (captured.nodes.length > maximumPlanNodes) return yield* Effect.fail(graphSizeError(captured.nodes.length))
717
+ const drafts = yield* validateDrafts(captured.nodes)
718
+ const keyed = yield* keyNodes(drafts, [], 0)
719
+ const nodes = yield* annotate(keyed, new Set())
720
+ const digest = yield* digestOf(captured.planId, captured.flow, nodes)
721
+ return freezePlan({
722
+ planId: captured.planId,
723
+ flow: captured.flow,
724
+ generation: 0,
725
+ baseDigest: digest,
726
+ digest,
727
+ nodes
728
+ })
729
+ })
730
+ }
731
+
732
+ /**
733
+ * Appends an elaborated subgraph to an existing plan.
734
+ *
735
+ * The plan GROWS; it is never invalidated. Nodes already in it keep their id,
736
+ * key, edges, and generation byte for byte, and the new nodes arrive pre-keyed
737
+ * against them, so a `hit` shows instantly. New nodes retain their material
738
+ * dependency order; scheduling must honor their complete `dependsOn` graph.
739
+ * Re-ordering after a reconciliation happens by re-keying *future* steps,
740
+ * never by rewriting history.
741
+ *
742
+ * A plan not produced by this module is checked with {@link verify} first.
743
+ *
744
+ * @param plan - The plan to grow.
745
+ * @param drafts - At least one new node. Ids must not repeat existing ones;
746
+ * `Ref` inputs may name existing nodes.
747
+ * @returns The plan with one more generation and a new `digest`. Fails with
748
+ * {@link PlanError} (`invalid_node` for an empty `drafts`, `invalid_plan` for a
749
+ * plan that fails verification), `KeyMaterialError`, or `SchemaError`.
750
+ * @since 0.1.0
751
+ * @category constructors
752
+ */
753
+ export const append = (
754
+ plan: Plan,
755
+ drafts: ReadonlyArray<NodeDraft>
756
+ ): Effect.Effect<Plan, PlanError | StepKey.KeyMaterialError | Schema.SchemaError, Crypto.Crypto> => {
757
+ const candidate = frozenPlans.has(plan) ? plan : snapshot(plan)
758
+ const capturedDrafts = snapshot(drafts)
759
+ return Effect.gen(function*() {
760
+ const invalidIdentity = validateIdentity(candidate.planId, candidate.flow)
761
+ if (invalidIdentity !== undefined) return yield* Effect.fail(invalidIdentity)
762
+ const capturedPlan = yield* verify(candidate)
763
+ const nextSize = capturedPlan.nodes.length + capturedDrafts.length
764
+ if (nextSize > maximumPlanNodes) return yield* Effect.fail(graphSizeError(nextSize))
765
+ const validated = yield* validateDrafts(capturedDrafts)
766
+ if (validated.length === 0) {
767
+ return yield* Effect.fail(
768
+ new PlanError({
769
+ code: "invalid_node",
770
+ message: `Plan ${capturedPlan.planId} append requires at least one draft`
771
+ })
772
+ )
773
+ }
774
+ const generation = capturedPlan.generation + 1
775
+ const keyed = yield* keyNodes(validated, capturedPlan.nodes, generation)
776
+ const nodes = yield* annotate(
777
+ [...capturedPlan.nodes, ...keyed],
778
+ new Set(capturedPlan.nodes.map((node) => node.id))
779
+ )
780
+ const digest = yield* digestOf(capturedPlan.planId, capturedPlan.flow, nodes)
781
+ return freezePlan({ ...capturedPlan, generation, digest, nodes })
782
+ })
783
+ }
784
+
785
+ /**
786
+ * Verifies an imported plan against the same key and topology rules as the
787
+ * compiler. Valid compiler-owned immutable values need no second compilation.
788
+ *
789
+ * Generation boundaries are replayed when reconstructing conflict annotations:
790
+ * an append may annotate new nodes, but cannot rewrite an earlier generation.
791
+ * Recorded material-dependency order is verified without sorting inferred edges.
792
+ * Keys and approval digests retain their existing format. The returned snapshot
793
+ * is immutable; later caller mutation cannot change the verified value.
794
+ *
795
+ * @since 1.0.0
796
+ * @category validation
797
+ */
798
+ export const verify = (
799
+ input: unknown
800
+ ): Effect.Effect<Plan, PlanError | StepKey.KeyMaterialError | Schema.SchemaError, Crypto.Crypto> =>
801
+ Effect.gen(function*() {
802
+ if (typeof input === "object" && input !== null && frozenPlans.has(input as Plan)) return input as Plan
803
+ // Check the array ceiling before schema decoding copies its members.
804
+ if (typeof input === "object" && input !== null && "nodes" in input && Array.isArray(input.nodes)) {
805
+ if (input.nodes.length > maximumPlanNodes) return yield* Effect.fail(graphSizeError(input.nodes.length))
806
+ }
807
+ const decoded = yield* Schema.decodeUnknownEffect(Plan)(input)
808
+ const invalid = (message: string) => new PlanError({ code: "invalid_plan", message })
809
+ if (
810
+ !Number.isSafeInteger(decoded.generation) || decoded.generation < 0 || decoded.generation > decoded.nodes.length
811
+ ) {
812
+ return yield* Effect.fail(
813
+ invalid("Plan generation must be a non-negative safe integer with a nonempty appended generation")
814
+ )
815
+ }
816
+ const generations = new Map<string, number>()
817
+ let previous = 0
818
+ for (const node of decoded.nodes) {
819
+ if (!Number.isSafeInteger(node.generation) || node.generation < previous || node.generation > previous + 1) {
820
+ return yield* Effect.fail(invalid(`Node ${node.id} has an invalid or out-of-order generation`))
821
+ }
822
+ generations.set(node.id, node.generation)
823
+ previous = node.generation
824
+ }
825
+ if (previous !== decoded.generation) {
826
+ return yield* Effect.fail(invalid("Plan generation does not match its newest nodes"))
827
+ }
828
+ for (const node of decoded.nodes) {
829
+ for (const dependency of KeyMaterial.dependencies(node.material)) {
830
+ const generation = generations.get(dependency)
831
+ if (generation !== undefined && generation > node.generation) {
832
+ return yield* Effect.fail(invalid(`Node ${node.id} refers to a future generation`))
833
+ }
834
+ }
835
+ }
836
+ const drafts = yield* validateDrafts(decoded.nodes.map((node) => ({
837
+ id: node.id,
838
+ kind: node.kind,
839
+ material: node.material,
840
+ effects: node.effects,
841
+ conflictStrategy: node.strategy,
842
+ runtimeStrategy: node.runtime,
843
+ priority: node.priority
844
+ })))
845
+ const keyed = (yield* keyNodes(drafts, [], 0)).map((node) =>
846
+ freezeNode({ ...node, generation: generations.get(node.id)! })
847
+ )
848
+ // Replay boundaries within one annotation pass, retaining its expansion
849
+ // map, candidate trie, inferred graph and reachability cache throughout.
850
+ const nodes = yield* annotate(keyed, new Set(), true)
851
+ const baseDigest = yield* digestOf(decoded.planId, decoded.flow, nodes.filter((node) => node.generation === 0))
852
+ const digest = decoded.generation === 0 ? baseDigest : yield* digestOf(decoded.planId, decoded.flow, nodes)
853
+ if (decoded.baseDigest !== baseDigest || decoded.digest !== digest) {
854
+ return yield* Effect.fail(invalid("Plan approval digest does not match its compiled content"))
855
+ }
856
+ // Compare the complete node contract, including material and generation,
857
+ // rather than trusting the claimed keys or the approval projection alone.
858
+ const hash = Schema.decodeUnknownEffect(DerivedKey)
859
+ if ((yield* hash(decoded.nodes)) !== (yield* hash(nodes))) {
860
+ return yield* Effect.fail(
861
+ invalid("Plan nodes do not match their compiled keys, effects, ordering, or generations")
862
+ )
863
+ }
864
+ return freezePlan({ ...decoded, nodes })
865
+ })
866
+
867
+ /**
868
+ * The nodes added by the newest generation: what {@link module:PlanStore}
869
+ * appends and what the `subgraph-appended` journal record names.
870
+ *
871
+ * @param plan - A plan.
872
+ * @returns The nodes whose generation equals `plan.generation`, in order.
873
+ * @since 0.1.0
874
+ * @category accessors
875
+ */
876
+ export const generationNodes = (plan: Plan): ReadonlyArray<PlanNode> =>
877
+ plan.nodes.filter((node) => node.generation === plan.generation)
878
+
879
+ /**
880
+ * Whether `input` is a compiler-owned immutable snapshot that already passed
881
+ * {@link verify}: such values take verify's trusted fast path and need no
882
+ * second schema validation.
883
+ *
884
+ * @param input - Any value.
885
+ * @returns `true` only for a plan returned by {@link compile},
886
+ * {@link append}, or {@link verify}; a structurally equal copy returns `false`.
887
+ * @since 1.0.0
888
+ * @category guards
889
+ */
890
+ export const isVerified = (input: unknown): input is Plan =>
891
+ typeof input === "object" && input !== null && frozenPlans.has(input as Plan)
892
+
893
+ /**
894
+ * The approval digest of every generation before the newest: the digest this
895
+ * plan carried before its last append. {@link module:PlanStore}'s append
896
+ * matches it against the stored envelope, so an append proves the recorded
897
+ * plan's integrity from the already-verified prefix instead of decoding and
898
+ * re-verifying every stored row.
899
+ *
900
+ * For a generation-0 plan no generation precedes the newest, so this is the
901
+ * digest of an empty node list.
902
+ *
903
+ * @param plan - A plan.
904
+ * @returns The approval digest of the nodes older than `plan.generation`.
905
+ * @since 1.0.0
906
+ * @category accessors
907
+ */
908
+ export const prefixDigest = (
909
+ plan: Plan
910
+ ): Effect.Effect<StepKey.StepKey, StepKey.KeyMaterialError | Schema.SchemaError, Crypto.Crypto> =>
911
+ digestOf(plan.planId, plan.flow, plan.nodes.filter((node) => node.generation < plan.generation))