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

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 (199) hide show
  1. package/CHANGELOG.md +183 -0
  2. package/LICENSE +21 -0
  3. package/README.md +228 -2
  4. package/dist/cjs/CachePolicy.d.ts +131 -0
  5. package/dist/cjs/CachePolicy.d.ts.map +1 -0
  6. package/dist/cjs/CachePolicy.js +50 -0
  7. package/dist/cjs/CachePolicy.js.map +7 -0
  8. package/dist/cjs/Effects.d.ts +240 -0
  9. package/dist/cjs/Effects.d.ts.map +1 -0
  10. package/dist/cjs/Effects.js +76 -0
  11. package/dist/cjs/Effects.js.map +7 -0
  12. package/dist/cjs/FileSet.d.ts +288 -0
  13. package/dist/cjs/FileSet.d.ts.map +1 -0
  14. package/dist/cjs/FileSet.js +191 -0
  15. package/dist/cjs/FileSet.js.map +7 -0
  16. package/dist/cjs/GraphBuildError.d.ts +122 -0
  17. package/dist/cjs/GraphBuildError.d.ts.map +1 -0
  18. package/dist/cjs/GraphBuildError.js +71 -0
  19. package/dist/cjs/GraphBuildError.js.map +7 -0
  20. package/dist/cjs/KeyMaterial.d.ts +102 -0
  21. package/dist/cjs/KeyMaterial.d.ts.map +1 -0
  22. package/dist/cjs/KeyMaterial.js +64 -0
  23. package/dist/cjs/KeyMaterial.js.map +7 -0
  24. package/dist/cjs/Node.d.ts +606 -0
  25. package/dist/cjs/Node.d.ts.map +1 -0
  26. package/dist/cjs/Node.js +207 -0
  27. package/dist/cjs/Node.js.map +7 -0
  28. package/dist/cjs/Placement.d.ts +117 -0
  29. package/dist/cjs/Placement.d.ts.map +1 -0
  30. package/dist/cjs/Placement.js +46 -0
  31. package/dist/cjs/Placement.js.map +7 -0
  32. package/dist/cjs/Plan.d.ts +440 -0
  33. package/dist/cjs/Plan.d.ts.map +1 -0
  34. package/dist/cjs/Plan.js +508 -0
  35. package/dist/cjs/Plan.js.map +7 -0
  36. package/dist/cjs/PlanDiff.d.ts +58 -0
  37. package/dist/cjs/PlanDiff.d.ts.map +1 -0
  38. package/dist/cjs/PlanDiff.js +124 -0
  39. package/dist/cjs/PlanDiff.js.map +7 -0
  40. package/dist/cjs/Planned.d.ts +121 -0
  41. package/dist/cjs/Planned.d.ts.map +1 -0
  42. package/dist/cjs/Planned.js +72 -0
  43. package/dist/cjs/Planned.js.map +7 -0
  44. package/dist/cjs/Repetition.d.ts +31 -0
  45. package/dist/cjs/Repetition.d.ts.map +1 -0
  46. package/dist/cjs/Repetition.js +36 -0
  47. package/dist/cjs/Repetition.js.map +7 -0
  48. package/dist/cjs/Scheduling.d.ts +89 -0
  49. package/dist/cjs/Scheduling.d.ts.map +1 -0
  50. package/dist/cjs/Scheduling.js +76 -0
  51. package/dist/cjs/Scheduling.js.map +7 -0
  52. package/dist/cjs/StepKey.d.ts +333 -0
  53. package/dist/cjs/StepKey.d.ts.map +1 -0
  54. package/dist/cjs/StepKey.js +311 -0
  55. package/dist/cjs/StepKey.js.map +7 -0
  56. package/dist/cjs/index.d.ts +91 -0
  57. package/dist/cjs/index.d.ts.map +1 -0
  58. package/dist/cjs/index.js +59 -0
  59. package/dist/cjs/index.js.map +7 -0
  60. package/dist/cjs/internal/ConflictAnnotation.d.ts +59 -0
  61. package/dist/cjs/internal/ConflictAnnotation.d.ts.map +1 -0
  62. package/dist/cjs/internal/ConflictAnnotation.js +247 -0
  63. package/dist/cjs/internal/ConflictAnnotation.js.map +7 -0
  64. package/dist/cjs/internal/EffectCandidates.d.ts +19 -0
  65. package/dist/cjs/internal/EffectCandidates.d.ts.map +1 -0
  66. package/dist/cjs/internal/EffectCandidates.js +92 -0
  67. package/dist/cjs/internal/EffectCandidates.js.map +7 -0
  68. package/dist/cjs/internal/JsonMirror.d.ts +34 -0
  69. package/dist/cjs/internal/JsonMirror.d.ts.map +1 -0
  70. package/dist/cjs/internal/JsonMirror.js +195 -0
  71. package/dist/cjs/internal/JsonMirror.js.map +7 -0
  72. package/dist/cjs/internal/effects.d.ts +245 -0
  73. package/dist/cjs/internal/effects.d.ts.map +1 -0
  74. package/dist/cjs/internal/effects.js +290 -0
  75. package/dist/cjs/internal/effects.js.map +7 -0
  76. package/dist/cjs/internal/node.d.ts +526 -0
  77. package/dist/cjs/internal/node.d.ts.map +1 -0
  78. package/dist/cjs/internal/node.js +312 -0
  79. package/dist/cjs/internal/node.js.map +7 -0
  80. package/dist/cjs/package.json +1 -0
  81. package/dist/cjs/test/PlanFixtures.d.ts +127 -0
  82. package/dist/cjs/test/PlanFixtures.d.ts.map +1 -0
  83. package/dist/cjs/test/PlanFixtures.js +59 -0
  84. package/dist/cjs/test/PlanFixtures.js.map +7 -0
  85. package/dist/esm/CachePolicy.d.ts +131 -0
  86. package/dist/esm/CachePolicy.d.ts.map +1 -0
  87. package/dist/esm/CachePolicy.js +117 -0
  88. package/dist/esm/CachePolicy.js.map +1 -0
  89. package/dist/esm/Effects.d.ts +240 -0
  90. package/dist/esm/Effects.d.ts.map +1 -0
  91. package/dist/esm/Effects.js +167 -0
  92. package/dist/esm/Effects.js.map +1 -0
  93. package/dist/esm/FileSet.d.ts +288 -0
  94. package/dist/esm/FileSet.d.ts.map +1 -0
  95. package/dist/esm/FileSet.js +324 -0
  96. package/dist/esm/FileSet.js.map +1 -0
  97. package/dist/esm/GraphBuildError.d.ts +122 -0
  98. package/dist/esm/GraphBuildError.d.ts.map +1 -0
  99. package/dist/esm/GraphBuildError.js +146 -0
  100. package/dist/esm/GraphBuildError.js.map +1 -0
  101. package/dist/esm/KeyMaterial.d.ts +102 -0
  102. package/dist/esm/KeyMaterial.d.ts.map +1 -0
  103. package/dist/esm/KeyMaterial.js +85 -0
  104. package/dist/esm/KeyMaterial.js.map +1 -0
  105. package/dist/esm/Node.d.ts +606 -0
  106. package/dist/esm/Node.d.ts.map +1 -0
  107. package/dist/esm/Node.js +521 -0
  108. package/dist/esm/Node.js.map +1 -0
  109. package/dist/esm/Placement.d.ts +117 -0
  110. package/dist/esm/Placement.d.ts.map +1 -0
  111. package/dist/esm/Placement.js +65 -0
  112. package/dist/esm/Placement.js.map +1 -0
  113. package/dist/esm/Plan.d.ts +440 -0
  114. package/dist/esm/Plan.d.ts.map +1 -0
  115. package/dist/esm/Plan.js +673 -0
  116. package/dist/esm/Plan.js.map +1 -0
  117. package/dist/esm/PlanDiff.d.ts +58 -0
  118. package/dist/esm/PlanDiff.d.ts.map +1 -0
  119. package/dist/esm/PlanDiff.js +159 -0
  120. package/dist/esm/PlanDiff.js.map +1 -0
  121. package/dist/esm/Planned.d.ts +121 -0
  122. package/dist/esm/Planned.d.ts.map +1 -0
  123. package/dist/esm/Planned.js +147 -0
  124. package/dist/esm/Planned.js.map +1 -0
  125. package/dist/esm/Repetition.d.ts +31 -0
  126. package/dist/esm/Repetition.d.ts.map +1 -0
  127. package/dist/esm/Repetition.js +24 -0
  128. package/dist/esm/Repetition.js.map +1 -0
  129. package/dist/esm/Scheduling.d.ts +89 -0
  130. package/dist/esm/Scheduling.d.ts.map +1 -0
  131. package/dist/esm/Scheduling.js +82 -0
  132. package/dist/esm/Scheduling.js.map +1 -0
  133. package/dist/esm/StepKey.d.ts +333 -0
  134. package/dist/esm/StepKey.d.ts.map +1 -0
  135. package/dist/esm/StepKey.js +478 -0
  136. package/dist/esm/StepKey.js.map +1 -0
  137. package/dist/esm/index.d.ts +91 -0
  138. package/dist/esm/index.d.ts.map +1 -0
  139. package/dist/esm/index.js +91 -0
  140. package/dist/esm/index.js.map +1 -0
  141. package/dist/esm/internal/ConflictAnnotation.d.ts +59 -0
  142. package/dist/esm/internal/ConflictAnnotation.d.ts.map +1 -0
  143. package/dist/esm/internal/ConflictAnnotation.js +332 -0
  144. package/dist/esm/internal/ConflictAnnotation.js.map +1 -0
  145. package/dist/esm/internal/EffectCandidates.d.ts +19 -0
  146. package/dist/esm/internal/EffectCandidates.d.ts.map +1 -0
  147. package/dist/esm/internal/EffectCandidates.js +86 -0
  148. package/dist/esm/internal/EffectCandidates.js.map +1 -0
  149. package/dist/esm/internal/JsonMirror.d.ts +34 -0
  150. package/dist/esm/internal/JsonMirror.d.ts.map +1 -0
  151. package/dist/esm/internal/JsonMirror.js +204 -0
  152. package/dist/esm/internal/JsonMirror.js.map +1 -0
  153. package/dist/esm/internal/effects.d.ts +245 -0
  154. package/dist/esm/internal/effects.d.ts.map +1 -0
  155. package/dist/esm/internal/effects.js +459 -0
  156. package/dist/esm/internal/effects.js.map +1 -0
  157. package/dist/esm/internal/node.d.ts +526 -0
  158. package/dist/esm/internal/node.d.ts.map +1 -0
  159. package/dist/esm/internal/node.js +564 -0
  160. package/dist/esm/internal/node.js.map +1 -0
  161. package/dist/esm/test/PlanFixtures.d.ts +127 -0
  162. package/dist/esm/test/PlanFixtures.d.ts.map +1 -0
  163. package/dist/esm/test/PlanFixtures.js +60 -0
  164. package/dist/esm/test/PlanFixtures.js.map +1 -0
  165. package/docs/README.md +165 -0
  166. package/docs/api.md +1475 -0
  167. package/docs/concepts/authoring-ast.md +203 -0
  168. package/docs/concepts/effects-and-conflicts.md +135 -0
  169. package/docs/concepts/plan-value.md +168 -0
  170. package/docs/concepts/step-keys.md +179 -0
  171. package/docs/guides/author-a-node-graph.md +182 -0
  172. package/docs/guides/compile-a-plan.md +146 -0
  173. package/docs/guides/declare-file-effects.md +143 -0
  174. package/docs/guides/diff-two-plans.md +101 -0
  175. package/docs/installation.md +71 -0
  176. package/docs/quickstart.md +128 -0
  177. package/docs/testing.md +73 -0
  178. package/docs/troubleshooting.md +314 -0
  179. package/package.json +254 -3
  180. package/src/CachePolicy.ts +143 -0
  181. package/src/Effects.ts +284 -0
  182. package/src/FileSet.ts +411 -0
  183. package/src/GraphBuildError.ts +158 -0
  184. package/src/KeyMaterial.ts +106 -0
  185. package/src/Node.ts +871 -0
  186. package/src/Placement.ts +99 -0
  187. package/src/Plan.ts +868 -0
  188. package/src/PlanDiff.ts +199 -0
  189. package/src/Planned.ts +208 -0
  190. package/src/Repetition.ts +33 -0
  191. package/src/Scheduling.ts +154 -0
  192. package/src/StepKey.ts +710 -0
  193. package/src/index.ts +103 -0
  194. package/src/internal/ConflictAnnotation.ts +374 -0
  195. package/src/internal/EffectCandidates.ts +89 -0
  196. package/src/internal/JsonMirror.ts +255 -0
  197. package/src/internal/effects.ts +543 -0
  198. package/src/internal/node.ts +858 -0
  199. package/src/test/PlanFixtures.ts +76 -0
@@ -0,0 +1,179 @@
1
+ ---
2
+ title: "Step keys"
3
+ description: "How key material becomes a step key: sealed content keys, run-local ordinal keys, dependency substitution, and the collisions the compiler is built to refuse."
4
+ sidebar:
5
+ order: 2
6
+ ---
7
+
8
+ A step key identifies a declaration or a durable execution. It is not, by
9
+ itself, permission to reuse a result across runs. `StepKey` produces these
10
+ identities from the declared `KeyMaterial`.
11
+
12
+ The derivations answer different questions:
13
+
14
+ - `StepKey.planIdentity` produces the **plan key** for every effect tier: the key a node is
15
+ _identified_ by, computed at plan time from the resolved keys of its
16
+ dependencies.
17
+ - `StepKey.dispatchIdentity` produces a sealed **dispatch key**: the key a run is
18
+ _cached_ under, computed at execution time from the settled content its
19
+ dependencies produced.
20
+
21
+ - `StepKey.ordinal` produces a run-local execution key for non-cacheable work.
22
+ The plan scheduler scopes it by run, plan, structural node, and declaration
23
+ fingerprint, so repeated identical effects do not collapse into one action.
24
+
25
+ Sealed plan keys still use `StepKey.fromKeyMaterial` and retain the existing
26
+ content-key format. Other tiers use a distinct `plan-declaration` namespace
27
+ that binds approvals without claiming cacheability.
28
+
29
+ ## Key material
30
+
31
+ `KeyMaterial.KeyMaterial` is everything that can change a node's result:
32
+
33
+ | Field | What it contributes |
34
+ | ------------------ | ------------------------------------------------------------------------------------- |
35
+ | `version` | `KeyMaterial.version`. Folded into every hashed body, so a bump re-keys everything. |
36
+ | `kind` | The tier: `sealed`, `compensable`, or `irreversible`. |
37
+ | `nondeterministic` | Optional. Absence claims determinism; only the explicit declaration changes identity. |
38
+ | `body` | The node's own opaque declaration. |
39
+ | `inputs` | Ordered `InputRef` values: what this node consumes. |
40
+ | `layers` | The composition identity the step runs under. |
41
+ | `capabilities` | The authority the step declares. |
42
+ | `effects` | Opaque here: canonically serialized, never interpreted. |
43
+ | `placement` | Opaque here, for the same reason. |
44
+
45
+ Keeping `effects` and `placement` opaque is what keeps the key compiler
46
+ independent of whatever the flow builder decides an effect declaration looks
47
+ like. `Plan.compile` is stricter than the compiler underneath it: it decodes
48
+ `NodeDraft.effects` through `NodeEffects` and writes the result into
49
+ `material.effects`, replacing anything a caller put there. That makes the draft
50
+ declaration the single derivation point for effect identity, so a node's key
51
+ cannot disagree with the effects its conflict annotations and approval payload
52
+ were computed from.
53
+
54
+ ## Input references
55
+
56
+ An `InputRef` is one of three tagged variants:
57
+
58
+ | Variant | Meaning |
59
+ | ----------------- | --------------------------------------------------------- |
60
+ | `Literal{value}` | A value hashed inline. |
61
+ | `Ref{from, path}` | The result of node `from`, projected along `path`. |
62
+ | `Pending{from}` | An ordering reference to node `from`, consuming no value. |
63
+
64
+ The tag is hashed, so `Pending{from}` and `Ref{from, path: []}` cannot collide
65
+ even though both resolve to the same dependency digest.
66
+
67
+ `KeyMaterial.dependencies` reads the graph-local dependencies a material names,
68
+ in declaration order and without duplicates. It is the single derivation of a
69
+ node's edge set, which is why a hashed reference and an edge can never disagree.
70
+
71
+ ## Sealed, and everything else
72
+
73
+ Only `sealed` material may become a content key. `StepKey.fromKeyMaterial` fails
74
+ `non_content_material` for the other two tiers, and so does
75
+ `StepKey.dispatchIdentity`.
76
+
77
+ `StepKey.ordinal` mints the deliberately run-local key of compensable,
78
+ irreversible, or unsealed work, from a run id, an optional parent scope, an
79
+ ordinal, and a `tier` of `compensable`, `irreversible`, or `unsealed`. Those
80
+ keys cannot be reused across runs, which is the point: work that changed the
81
+ world outside the workspace must not be served from a cache.
82
+
83
+ ## What a plan key folds in
84
+
85
+ `planIdentity` substitutes each `Ref` and `Pending` for the already computed
86
+ key of the referenced node. For sealed material it delegates to
87
+ `fromKeyMaterial`; other tiers produce a tier-bearing declaration fingerprint.
88
+
89
+ Structural node ids do not enter the plan key. Renaming a node preserves its
90
+ declaration fingerprint. A non-cacheable execution key does include its
91
+ structural address: renaming an effect must not accidentally replay a different
92
+ invocation. Changing what a node consumes changes downstream plan keys.
93
+
94
+ A dependency digest is resolved as an own data property. A `Ref` naming
95
+ `toString` or `constructor` is a `missing_dependency` refusal rather than a
96
+ colliding key, and so is a digest supplied through an accessor or as a non-string.
97
+
98
+ ## What a dispatch key folds in instead
99
+
100
+ A plan key folds the resolved keys of every upstream node, transitively, so an
101
+ edit anywhere upstream re-keys everything below it, even when the edited node's
102
+ output value is byte for byte what it was before.
103
+
104
+ `dispatchIdentity` gives the JSON value channel the property the file channel
105
+ already had through measured boundary digests. It folds the node's own material
106
+ and never an upstream key. Each input contributes content instead: a `Literal`
107
+ its value, a `Ref` the digest of the settled result of `from` projected along
108
+ `path`, and a `Pending` nothing beyond its tag, because ordering does not change
109
+ what a node consumes.
110
+
111
+ `results` must hold every dependency the material names. The scheduler's halt
112
+ rule guarantees it: a dependent of failed or skipped work never dispatches, so a
113
+ `Ref` always resolves against a success.
114
+
115
+ ## Projection
116
+
117
+ `StepKey.project` is the one projection semantics for the value channel. It
118
+ resolves only own data properties, so a path segment that is missing, inherited,
119
+ or an accessor yields `undefined` and no getter runs during key derivation.
120
+
121
+ Walking off the end of a result is a fact about the graph, not a failure.
122
+ `undefined` drops out of the canonical form, so it hashes distinctly from every
123
+ JSON value including `null`.
124
+
125
+ Every consumer that resolves a `Ref` at execution time must resolve it this same
126
+ way. Two inputs that key identically but are consumed differently is a stale-hit
127
+ vector, and one shared projection is what closes it.
128
+
129
+ ## The collisions this compiler refuses
130
+
131
+ Each of these is a fix that stayed.
132
+
133
+ **A literal that looks like a digest.** The brand behind `StepKey.digestInput`
134
+ is private, so a plain object that merely has a `digest` field hashes as a
135
+ literal. Actions pass content hashes around as ordinary data; shape sniffing
136
+ would have hashed a genuine upstream-result reference and an ordinary content
137
+ hash identically.
138
+
139
+ **Environment material merged into the caller's.** `environment` is hashed in
140
+ its own namespace, so `caller{fs:["a"]} + env{fs:["b"]}` cannot alias
141
+ `caller{fs:["a","b"]} + env{}`. Environment layers keep declaration order,
142
+ because composition order can change behavior; caller-owned layers are
143
+ set-normalized.
144
+
145
+ **An unknown environment serving a cross-run hit.** `EnvironmentIdentity` is a
146
+ discriminated union. A declared environment carries no `runScope`; an undeclared
147
+ one must carry a non-empty one, which pins the key to a single run. Both
148
+ `content` and `dispatchIdentity` enforce that at run time with
149
+ `invalid_environment`.
150
+
151
+ **A bumped format keying like the old one.** `version` is folded into every
152
+ hashed body, so a change to what material means re-keys every node derived from
153
+ it.
154
+
155
+ ## Sharing work between keys
156
+
157
+ `StepKey.makeDigestMemo` creates a memo that shares one in-flight projected
158
+ value digest between concurrent callers, addressed by the `[from, path]` tuple.
159
+ Pass it to `dispatchIdentity` through `digestMemo` when several keys in one
160
+ wavefront project the same upstream result.
161
+
162
+ Entries are sound only while each settled `from` value is immutable, so create a
163
+ fresh memo when those values can change.
164
+
165
+ A waiter never inherits the leader's interruption. If the leader's fiber is
166
+ cancelled, the waiter competes to become the replacement leader and recomputes,
167
+ rather than failing on an interrupt it did not request.
168
+
169
+ ## Failures
170
+
171
+ `StepKey.KeyMaterialError` carries one of three codes:
172
+
173
+ | `code` | Cause |
174
+ | ---------------------- | ----------------------------------------------------------------------------- |
175
+ | `invalid_environment` | An environment identity that is neither properly declared nor run-scoped. |
176
+ | `missing_dependency` | A reference whose digest or settled result is absent, inherited, or mistyped. |
177
+ | `non_content_material` | A content or dispatch key was asked for on non-sealed material. |
178
+
179
+ [Troubleshooting](../troubleshooting.md) states the fix for each one.
@@ -0,0 +1,182 @@
1
+ ---
2
+ title: "Author a node graph"
3
+ description: "Build topology with the Node combinators, pass step results as planned values, and turn the result into the drafts Plan.compile takes."
4
+ sidebar:
5
+ order: 1
6
+ ---
7
+
8
+ `Node` is the authoring AST: pure, pipeable values that describe what a run will
9
+ do. This guide builds a graph with it and turns that graph into
10
+ `Plan.NodeDraft` values.
11
+
12
+ Most flow authors reach this layer through [`@smthrs/flow`](/api/flow), which
13
+ walks a flow body for you. Reach for `Node` directly when you are writing that
14
+ walk, testing topology, or composing a graph outside a flow declaration.
15
+
16
+ ## Start from a value
17
+
18
+ `Node.succeed` lifts a constant. Nothing runs:
19
+
20
+ ```ts
21
+ import * as Node from "@smthrs/plan/Node"
22
+
23
+ const lint = Node.succeed("lint")
24
+ ```
25
+
26
+ ## Transform with map
27
+
28
+ `Node.map` defers a pure function. It is digested at build time and applied to
29
+ the real value later:
30
+
31
+ ```ts
32
+ const shouted = Node.map(Node.succeed({ name: "world" }), (value) => value.name.toUpperCase())
33
+ ```
34
+
35
+ Use `map` for computation only. A `map` that chooses what happens next is a
36
+ `branch` written wrongly, and the plan loses the topology a reviewer needs to
37
+ see.
38
+
39
+ ## Sequence nodes and bind planned references
40
+
41
+ `Node.andThen(first, next)` sequences two nodes when the first result is not needed.
42
+ Use `Node.bindPlanned(first, build)` when the next node consumes a reference to
43
+ that result. The builder runs once at plan time against a `Planned` placeholder:
44
+
45
+ ```ts
46
+ import { Action } from "@smthrs/flow"
47
+ import type * as Planned from "@smthrs/plan/Planned"
48
+ import * as Schema from "effect/Schema"
49
+
50
+ const Draft = Action.make("docs/Draft", {
51
+ payload: { topic: Schema.String },
52
+ success: Schema.String,
53
+ error: Schema.String
54
+ })
55
+
56
+ const Publish = Action.make("docs/Publish", {
57
+ payload: { text: Schema.String, urgent: Schema.Boolean },
58
+ success: Schema.String
59
+ })
60
+
61
+ const article = Node.bindPlanned(
62
+ Draft.call({ topic: "durable plans" }),
63
+ (text: Planned.Planned<string>) => Publish.call({ text, urgent: false })
64
+ )
65
+ ```
66
+
67
+ `text` is a placeholder, not a string. Pass it into a payload field, read a
68
+ field off it, hand it to a branch: all of that records a reference. Computing on
69
+ it can throw `planned_value_computed` with the node and path named. JavaScript
70
+ truthiness and strict equality cannot be trapped: they inspect the reference,
71
+ not its future result. Use `Node.branch` for decisions and `Node.map` for real-value
72
+ computation. Enable the type-aware ESLint rule
73
+ `@typescript-eslint/strict-boolean-expressions: "error"` to reject planned values
74
+ in conditions. Explicit `Boolean(reference)` and reference equality still require
75
+ review. [The authoring AST](../concepts/authoring-ast.md) explains the reference contract.
76
+
77
+ ## Decide with branch
78
+
79
+ Both arms are built once, symbolically, so the plan carries the exit condition
80
+ and both continuations before anything runs:
81
+
82
+ ```ts
83
+ const decided = Node.bindPlanned(
84
+ Draft.call({ topic: "durable plans" }),
85
+ (text: Planned.Planned<string>) =>
86
+ Node.branch(Node.succeed({ urgent: true }), {
87
+ if: (flags) => flags.urgent,
88
+ then: () => Publish.call({ text, urgent: true }),
89
+ else: () => Publish.call({ text, urgent: false })
90
+ })
91
+ )
92
+ ```
93
+
94
+ `if` runs later, on the real value. `then` and `else` run now, once each.
95
+
96
+ ## Recover with catch
97
+
98
+ `Node.catch` stores the failure arm as topology beside the protected graph.
99
+ Without an `error` schema it handles the whole typed error channel; with one it
100
+ handles only the values that schema accepts, and the rest survives in the
101
+ resulting error type:
102
+
103
+ ```ts
104
+ const recovered = Node.catch(Draft.call({ topic: "fallbacks" }), {
105
+ onFailure: () => Node.succeed("draft unavailable")
106
+ })
107
+ ```
108
+
109
+ ## Fan out with all
110
+
111
+ `Node.all` combines independent children by name. Its width is fixed here, at
112
+ build time:
113
+
114
+ ```ts
115
+ const batched = Node.all({
116
+ lint: Node.priority(Node.succeed("lint"), 10),
117
+ types: Node.succeed("types")
118
+ })
119
+ ```
120
+
121
+ To fan out over something a step discovered, end the round and carry the list in
122
+ the next flow's payload, where it is real data. A plan is a DAG and cannot grow
123
+ a leg it did not declare.
124
+
125
+ ## Prioritize without re-keying
126
+
127
+ `Node.priority` attaches a scheduling priority and leaves the original node
128
+ unchanged. Higher runs first among ready work, so a priority changes latency and
129
+ nothing else. It never enters key material, so a prioritized node keeps its
130
+ cache hit. Children inherit the value lexically when the graph is built, and a
131
+ child that states its own keeps it.
132
+
133
+ `Node.declaredPriority(ast)` reads back the value a node states, or `undefined`
134
+ when it inherits.
135
+
136
+ `Node.priority` refuses anything that is not a safe integer with
137
+ `invalid_priority`, because no ordering could compare it.
138
+
139
+ ## Declare captures for a deterministic digest
140
+
141
+ The AST stores a digest of a function's exact source, never the closure. A
142
+ function with undeclared captures also carries process-local entropy, so two
143
+ indistinguishable sources fail closed rather than sharing a cache key. Declare
144
+ the inert values a function closes over to get a deterministic identity instead:
145
+
146
+ ```ts
147
+ const suffixed = Node.capture({ suffix: "!" }, (value: string) => `${value}!`)
148
+ ```
149
+
150
+ ## Turn the graph into drafts
151
+
152
+ `Plan.compile` takes `NodeDraft` values, and [`@smthrs/flow`](/api/flow) is what
153
+ produces them. `Graph.build` walks a flow declaration or a bare node once;
154
+ `Graph.drafts` reads the drafts back:
155
+
156
+ ```ts
157
+ import { Flow, Graph } from "@smthrs/flow"
158
+ import * as Plan from "@smthrs/plan/Plan"
159
+
160
+ const Article = Flow.make("docs/Article", {
161
+ payload: { topic: Schema.String },
162
+ success: Schema.String,
163
+ error: Schema.String,
164
+ body: ({ topic }: { readonly topic: string }) =>
165
+ Node.bindPlanned(Draft.call({ topic }), (text: Planned.Planned<string>) => Publish.call({ text, urgent: false }))
166
+ })
167
+
168
+ const drafts: ReadonlyArray<Plan.NodeDraft> = Graph.drafts(Graph.build(Article, { topic: "plans" }))
169
+ ```
170
+
171
+ `Graph.drafts` throws the first typed build refusal rather than returning
172
+ partial drafts, because returning them would turn missing topology into a valid
173
+ plan. `Graph.diagnostics` lists the recoverable issues a build recorded; fatal
174
+ refusals, including computing on a planned value, throw from `Graph.build`
175
+ itself.
176
+
177
+ ## Next
178
+
179
+ - [Compile drafts into a plan](./compile-a-plan.md): keys, ordering, conflict
180
+ annotation, and the digest.
181
+ - [The authoring AST](../concepts/authoring-ast.md): why building runs nothing,
182
+ and how a closure survives as a digest.
@@ -0,0 +1,146 @@
1
+ ---
2
+ title: "Compile drafts into a plan"
3
+ description: "Turn NodeDraft values into a keyed graph: what compile does in order, the one service it needs, and every refusal it can answer with."
4
+ sidebar:
5
+ order: 2
6
+ ---
7
+
8
+ `Plan.compile` is the whole plan phase in one function. Hand it drafts, get back
9
+ a keyed graph with a digest.
10
+
11
+ ```ts
12
+ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"
13
+ import * as KeyMaterial from "@smthrs/plan/KeyMaterial"
14
+ import * as Plan from "@smthrs/plan/Plan"
15
+ import * as Effect from "effect/Effect"
16
+
17
+ const readPr: Plan.NodeDraft = {
18
+ id: "read-pr",
19
+ material: {
20
+ version: KeyMaterial.version,
21
+ kind: "sealed",
22
+ body: { action: "read-pr", pr: 4821 },
23
+ inputs: [],
24
+ layers: [],
25
+ capabilities: ["net:get"]
26
+ },
27
+ effects: { reads: [], writes: ["pr.json"], boundaryMode: "hard" }
28
+ }
29
+
30
+ const runTests: Plan.NodeDraft = {
31
+ id: "run-tests",
32
+ material: {
33
+ version: KeyMaterial.version,
34
+ kind: "sealed",
35
+ body: { action: "run-tests" },
36
+ inputs: [{ _tag: "Ref", from: "read-pr", path: [] }],
37
+ layers: [],
38
+ capabilities: []
39
+ },
40
+ effects: { reads: ["pr.json"], writes: ["report.json"], boundaryMode: "hard" },
41
+ priority: 10,
42
+ conflictStrategy: "serialize",
43
+ runtimeStrategy: "delay-rebase"
44
+ }
45
+
46
+ const compiled = Plan.compile({
47
+ planId: "review-4821",
48
+ flow: "example/Review",
49
+ nodes: [readPr, runTests]
50
+ }).pipe(Effect.provide(NodeCrypto.layer))
51
+ ```
52
+
53
+ ## What a draft carries
54
+
55
+ | Field | Required | What it decides |
56
+ | ------------------ | -------- | -------------------------------------------------------------------------------------------------------------------------- |
57
+ | `id` | yes | The durable lookup address. Not part of the declaration fingerprint; non-cacheable execution uses it as a run-local scope. |
58
+ | `material` | yes | Everything the key is derived from. |
59
+ | `effects` | yes | Declared reads, writes, optional removals, and the boundary mode. |
60
+ | `kind` | no | `step`, `agent`, or `merge`. Defaults to `step`. |
61
+ | `priority` | no | Scheduling tie-break. Defaults to 0. |
62
+ | `conflictStrategy` | no | This node's preferred plan-time verdict for an overlap. Defaults to `serialize`. |
63
+ | `runtimeStrategy` | no | This node's preferred response when an overlap bites. Defaults to `delay-rebase`. |
64
+
65
+ ## What compile does, in order
66
+
67
+ 1. Validates `planId`, `flow`, and every draft, decoding `material` and
68
+ `effects` through their schemas.
69
+ 2. Puts the drafts in topological order from the dependencies their material
70
+ names.
71
+ 3. Keys each node, substituting every `Ref` and `Pending` for the already
72
+ computed key of the node it names.
73
+ 4. Annotates overlapping write sets and adds the ordering edges the verdicts
74
+ imply.
75
+ 5. Adds reader-after-writer edges where explicit `Ref`/`Pending` paths have not
76
+ already selected read-before-write sequencing. A read before a writer
77
+ consumes an earlier version, not that later writer's output. Contradictory
78
+ inferred orderings still fail with `cycle`.
79
+ 6. Derives the plan digest over the whole result.
80
+
81
+ The nodes come back in topological order of material dependencies, deep-frozen,
82
+ at generation 0, with `baseDigest` equal to `digest`. Inferred edges extend
83
+ `dependsOn` without reordering the array. For example, a reader declared before
84
+ an independent writer stays first in `plan.nodes`, even when its `dependsOn`
85
+ contains that writer. Schedule nodes from the complete `dependsOn` graph;
86
+ iterating the array alone can run a reader before its producer.
87
+
88
+ ## The one service it needs
89
+
90
+ Compiling asks for Effect's `Crypto` service and nothing else. Provide it from a
91
+ platform package:
92
+
93
+ ```ts
94
+ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"
95
+
96
+ const plan = compiled.pipe(Effect.provide(NodeCrypto.layer))
97
+ ```
98
+
99
+ In a browser, provide the browser platform's `Crypto` layer instead. No part of
100
+ compilation reaches a file, a clock, or a network.
101
+
102
+ ## Your drafts are copied, not held
103
+
104
+ `compile` snapshots its options before it does anything else, and the plan it
105
+ returns is deep-frozen. Mutating a draft afterwards cannot change the plan, its
106
+ keys, or its digest. Material is stored as the inert JSON mirror its key already
107
+ covers, so a `Date` or `URL` in a body is stored as the value it serializes to.
108
+
109
+ ## When compile refuses
110
+
111
+ `PlanError` is a closed set of seven codes, so a caller can switch on `code`.
112
+
113
+ | `code` | Cause |
114
+ | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
115
+ | `cycle` | Material dependencies close a cycle, or a reader-after-writer edge would close one. |
116
+ | `unknown_dependency` | A `Ref` or `Pending` names a node that is neither in the drafts nor already in the plan. |
117
+ | `duplicate_node` | A draft reuses an id the plan already holds. |
118
+ | `overlap_forbidden` | A `fail` pair genuinely overlaps and no dependency path orders it. |
119
+ | `invalid_effects` | One path is declared as both a write and a removal. |
120
+ | `invalid_node` | An empty id or flow, a priority that is not a safe integer, a value outside a literal set, or material this release cannot decode. |
121
+ | `graph_too_large` | The plan would hold more than `Plan.maximumPlanNodes` nodes. |
122
+
123
+ All three effect tiers compile. `StepKey.planIdentity` produces approval-bound
124
+ declaration fingerprints; compilation does not grant cache eligibility.
125
+ `compile` can also fail with `StepKey.KeyMaterialError` when a dependency
126
+ digest is missing, and with a `Schema.SchemaError` from decoding.
127
+ [Troubleshooting](../troubleshooting.md) states the fix for each one.
128
+
129
+ ## Size
130
+
131
+ `Plan.maximumPlanNodes` is 10,000. The conflict and reader-after-writer passes
132
+ compare node pairs, so the cost is at least quadratic in node count, and each
133
+ pair whose write sets actually overlap adds one on-demand reachability walk.
134
+ A plan above the ceiling is refused before any pair is compared.
135
+
136
+ If you are approaching the ceiling, split the work across flow boundaries rather
137
+ than raising it: a plan that large is also a plan no operator can review.
138
+
139
+ ## Next
140
+
141
+ - [Persist a plan](https://plan-store.smithers.sh/guides/persist-a-plan/): record the compiled value and read it
142
+ back.
143
+ - [Declare the files a node touches](./declare-file-effects.md): the vocabulary
144
+ behind step 4 and step 5.
145
+ - [Step keys](../concepts/step-keys.md): what step 3 folds in, and what it
146
+ deliberately leaves out.
@@ -0,0 +1,143 @@
1
+ ---
2
+ title: "Declare the files a node touches"
3
+ description: "Write a node's read, write, and removal sets with FileSet: literal paths, globs, tree artifacts, filegroups, and the forms a workspace-relative path may not take."
4
+ sidebar:
5
+ order: 6
6
+ ---
7
+
8
+ `FileSet` is the static filesystem vocabulary planning and execution share.
9
+ A node's `effects` are written in it, and everything the plan knows about
10
+ conflicts comes from what you declare here.
11
+
12
+ ```ts
13
+ import * as Plan from "@smthrs/plan/Plan"
14
+
15
+ const effects: Plan.NodeEffects = {
16
+ reads: [{ _tag: "Glob", include: ["src/**/*.ts"] }],
17
+ writes: ["dist/bundle.js"],
18
+ removes: ["dist/stale.js"],
19
+ boundaryMode: "hard"
20
+ }
21
+ ```
22
+
23
+ ## The four declaration forms
24
+
25
+ | Form | Written as | Where it is valid |
26
+ | -------------- | ----------------------------------------- | -------------------------- |
27
+ | `Pattern` | A workspace-relative literal path string. | reads, writes, and removes |
28
+ | `Glob` | `{_tag: "Glob", include, exclude?}` | reads and writes |
29
+ | `TreeArtifact` | `{_tag: "TreeArtifact", path}` | writes only |
30
+ | `Filegroup` | `{_tag: "Filegroup", name, entries}` | reads and writes |
31
+
32
+ Bare string entries are literal paths at both overlap analysis and execution,
33
+ even when they contain `*` or `**`. Use `Glob.include` and `Glob.exclude` for
34
+ wildcard matching in read and write declarations.
35
+
36
+ ```ts
37
+ import * as FileSet from "@smthrs/plan/FileSet"
38
+
39
+ const sources: FileSet.Glob = {
40
+ _tag: "Glob",
41
+ include: ["src/**/*.ts"],
42
+ exclude: ["src/**/*.test.ts"]
43
+ }
44
+
45
+ const bundle: FileSet.TreeArtifact = { _tag: "TreeArtifact", path: "dist" }
46
+
47
+ const group: FileSet.Filegroup = FileSet.makeFilegroup("sources", [sources, "package.json"])
48
+ ```
49
+
50
+ A tree artifact is a whole directory captured and replayed as one output. It
51
+ belongs in a write set, and `ReadEntry` deliberately excludes it: a read set
52
+ names what the node consumes, and a directory output is not that.
53
+
54
+ ## Expanding a filegroup
55
+
56
+ A filegroup is a name for a reusable collection. Both plan passes expand groups
57
+ before they compare anything, and `expand` does the same for a caller,
58
+ deterministically and in declaration order:
59
+
60
+ ```ts
61
+ const flattened: ReadonlyArray<FileSet.Entry> = FileSet.expand([group, bundle])
62
+ const reads: ReadonlyArray<FileSet.ReadEntry> = FileSet.expandReads([
63
+ { _tag: "Filegroup", name: "sources", entries: [sources] }
64
+ ])
65
+ ```
66
+
67
+ `expandReads` is the read-set counterpart, typed so a tree artifact cannot
68
+ appear in the result.
69
+
70
+ ## What a workspace-relative path may not be
71
+
72
+ `workspaceRelative` decides whether a declaration names a file one way only. It
73
+ refuses:
74
+
75
+ - Absolute paths, POSIX (`/etc/passwd`) and drive-letter (`C:/tmp`), and the
76
+ drive-relative `C:tmp`.
77
+ - Upward traversal: any `..` segment.
78
+ - Aliasing forms: `.` segments and empty segments.
79
+ - C0 controls (U+0000 through U+001F) and DEL (U+007F).
80
+
81
+ C1 controls (U+0080 through U+009F) stay legal, because those bytes are valid in
82
+ a POSIX file name.
83
+
84
+ The aliasing rules are correctness, not tidiness. Two spellings of one file that
85
+ the overlap check cannot see would let a plan run a reader and its writer in the
86
+ same wavefront round.
87
+
88
+ ## Canonical form
89
+
90
+ `canonical` rewrites every separator to `/` and normalizes Unicode to NFC. Every
91
+ exact-path comparison in the module goes through it:
92
+
93
+ ```ts
94
+ const same = FileSet.canonical("dist\\bundle.js") === FileSet.canonical("dist/bundle.js") // true
95
+ ```
96
+
97
+ So the backslash spelling and the NFD spelling of one workspace path overlap.
98
+ A glob, by contrast, tests the path bytes it is handed: canonicalizing a
99
+ _measured_ path before matching it is the caller's decision, because a backslash
100
+ inside a path segment is literal text on a POSIX filesystem.
101
+
102
+ Overlap goes one step further and compares `folded`, the canonical form with
103
+ letter case folded away. A case-insensitive filesystem opens `src/A.ts` and
104
+ `src/a.ts` as one file, so two nodes writing them serialize.
105
+
106
+ ## Matching and overlap
107
+
108
+ | Function | Answers |
109
+ | ------------------------------- | ------------------------------------------------- |
110
+ | `matchesPattern(pattern, path)` | Whether one path matches one Bazel-style pattern. |
111
+ | `matchesGlob(glob, path)` | The same, honoring the glob's `exclude` list. |
112
+ | `overlaps(left, right)` | Whether two entries might share a path. |
113
+ | `isGlob`, `isTreeArtifact` | Which variant an entry is. |
114
+
115
+ In `Glob.include`, `Glob.exclude`, and the `matchesPattern` helper, `**` matches
116
+ path segments; `*` matches within one segment and never crosses a separator. A
117
+ trailing `**` matches the rest of the path. Calling `matchesPattern` directly does
118
+ not change how a bare string declaration is interpreted.
119
+
120
+ `overlaps` is conservative by design: `true` may over-serialize, and `false`
121
+ proves that no path can belong to both declarations. Two globs always overlap,
122
+ and so do a glob and a tree artifact.
123
+
124
+ ## Writes and removals are one set
125
+
126
+ A removal mutates the world exactly as a write does, so both plan passes fold
127
+ `writes` and `removes` together. Declaring one path as both fails compilation
128
+ with `invalid_effects`, because the declaration contradicts itself.
129
+
130
+ `removes` accepts literal path strings only, not globs or tree artifacts.
131
+
132
+ ## Boundary mode
133
+
134
+ `boundaryMode` is `hard` or `expected`. It travels with the declaration into the
135
+ measured boundary at execution time. The plan passes do not interpret it: they
136
+ only need to know which paths a node produces and which it consumes.
137
+
138
+ ## Next
139
+
140
+ - [Declared effects and conflicts](../concepts/effects-and-conflicts.md): what
141
+ the two plan passes do with these declarations.
142
+ - [Compile drafts into a plan](./compile-a-plan.md): where effects are decoded
143
+ and written back into key material.