@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
package/docs/api.md ADDED
@@ -0,0 +1,1475 @@
1
+ ---
2
+ title: "API reference"
3
+ description: "Every public export of @smthrs/plan: the authoring AST, the planned placeholder, key material, placement, the step-key compiler, the plan value, and its diff."
4
+ ---
5
+
6
+ `@smthrs/plan` exports thirteen modules from its root entry point, and each is also
7
+ importable from `@smthrs/plan/<Module>`:
8
+
9
+ ```ts
10
+ import { FileSet, Node, Plan, PlanDiff } from "@smthrs/plan"
11
+ // or
12
+ import * as Plan from "@smthrs/plan/Plan"
13
+ ```
14
+
15
+ `@smthrs/plan/internal/*` and `@smthrs/plan/*/index` are not public.
16
+ `@smthrs/plan/package.json` is exported.
17
+
18
+ | Namespace | What it is |
19
+ | ----------------- | --------------------------------------------------------------------------------------------------------- |
20
+ | `Node` | The pure, pipeable authoring AST: `succeed`, `all`, `map`, `andThen`, `branch`, `catch`, `priority`. |
21
+ | `Planned` | The strict placeholder a body sees where a step result will be, and the reference it records. |
22
+ | `GraphBuildError` | The refusals a plan-time build raises instead of producing a wrong plan. |
23
+ | `FileSet` | The static filesystem vocabulary: patterns, globs, tree artifacts, filegroups, and overlap. |
24
+ | `Effects` | The one effect envelope model: declare, narrow a step against an envelope, find write overlap. |
25
+ | `KeyMaterial` | What a planner declares about a node: body, tagged input references, layers, capabilities, effects. |
26
+ | `StepKey` | The compiler that turns material plus resolved dependency digests into a [`@smthrs/keys`](/api/keys) key. |
27
+ | `Plan` | `compile`, `append`, the node and conflict schemas, and the digest an approval binds to. |
28
+ | `PlanDiff` | A plan comparison as a value: added, removed, re-keyed with attribution, unchanged. |
29
+ | `Placement` | Where a node runs: the four directives, their host-selection detail, and the annotation key. |
30
+ | `Scheduling` | The pure admission policy a scheduler consults: concurrency, priority order, and the halt rule. |
31
+ | `CachePolicy` | How far a recorded sealed result travels, how long it stays servable, and the key it rides under. |
32
+ | `Repetition` | What a bounded repetition answers with at its ceiling. |
33
+
34
+ The shortest composition that reaches every layer:
35
+
36
+ ```ts
37
+ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"
38
+ import { Plan, PlanDiff } from "@smthrs/plan"
39
+ import * as Effect from "effect/Effect"
40
+
41
+ const program = Effect.gen(function*() {
42
+ const plan = yield* Plan.compile({
43
+ planId: "review-4821",
44
+ flow: "example/Review",
45
+ nodes: [
46
+ {
47
+ id: "read-pr",
48
+ material: {
49
+ version: "flows/key-material/v2",
50
+ kind: "sealed",
51
+ body: { action: "read-pr", pr: 4821 },
52
+ inputs: [],
53
+ layers: [],
54
+ capabilities: ["net:get"]
55
+ },
56
+ effects: { reads: [], writes: ["pr.json"], boundaryMode: "hard" }
57
+ },
58
+ {
59
+ id: "run-tests",
60
+ material: {
61
+ version: "flows/key-material/v2",
62
+ kind: "sealed",
63
+ body: { action: "run-tests" },
64
+ inputs: [{ _tag: "Ref", from: "read-pr", path: [] }],
65
+ layers: [],
66
+ capabilities: []
67
+ },
68
+ effects: { reads: ["pr.json"], writes: ["report.json"], boundaryMode: "hard" }
69
+ }
70
+ ]
71
+ })
72
+
73
+ return PlanDiff.diff(plan, plan)
74
+ }).pipe(Effect.provide(NodeCrypto.layer))
75
+ ```
76
+
77
+ Compiling needs Effect's `Crypto` service and nothing else. The package depends on [`@smthrs/crypto`](/api/crypto), [`@smthrs/canonical`](/api/canonical), [`@smthrs/keys`](/api/keys), and `effect`, performs no I/O, and is browser-safe. Persisting a compiled plan is [`@smthrs/plan-store`](/api/plan-store), which is where the database dependency lives.
78
+
79
+ ## Entry point
80
+
81
+ | Import | Source | Platform |
82
+ | -------------- | ---------------------------------------------------------------------------------------------------------- | ---------------- |
83
+ | `@smthrs/plan` | [src/index.ts](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/plan/src/index.ts) | Node and browser |
84
+
85
+ ## KeyMaterial
86
+
87
+ [src/KeyMaterial.ts](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/plan/src/KeyMaterial.ts)
88
+
89
+ What a planner declares about one node, handed to the compiler: a `version`, a tier `kind`, an optional `nondeterministic` flag, an opaque `body`, an ordered list of `InputRef`s, `layers`, `capabilities`, and opaque `effects` and `placement`.
90
+
91
+ `kind` is `sealed`, `compensable`, or `irreversible`. Absence of `nondeterministic` claims determinism; only the explicit declaration changes identity. The `InputRef` tag is hashed, so `Pending{from}` and `Ref{from, path: []}` cannot collide even though both resolve to the same dependency digest.
92
+
93
+ `dependencies` is the single derivation of a node's edge set, so a hashed reference and an edge can never disagree. `StepKey` canonically serializes `effects` and `placement` and never interprets them, which keeps the key compiler independent of whatever the flow builder decides an effect declaration looks like. `Plan.compile` is stricter: it decodes `NodeDraft.effects` through `NodeEffects` and writes the result into `material.effects`, replacing anything a caller put there. That makes the draft declaration the single derivation point for effect identity, so a node's key cannot disagree with the effects its conflict annotations and approval payload were computed from.
94
+
95
+ ### KeyMaterial.InputRef
96
+
97
+ ```ts
98
+ type InputRef =
99
+ | { readonly _tag: "Literal"; readonly value: unknown }
100
+ | { readonly _tag: "Ref"; readonly from: string; readonly path: ReadonlyArray<string> }
101
+ | { readonly _tag: "Pending"; readonly from: string }
102
+ ```
103
+
104
+ One declared input. `Literal` is hashed inline. `Ref` names an upstream node and the property path read off its result. `Pending` names an upstream node without consuming its value, which is an ordering reference. `from` is a non-empty string. Exported as both a schema and a type.
105
+
106
+ ### KeyMaterial.KeyMaterial
107
+
108
+ ```ts
109
+ const KeyMaterial: Schema.Struct<{
110
+ version: Schema.Literal<"flows/key-material/v2">
111
+ kind: Schema.Literals<["sealed", "compensable", "irreversible"]>
112
+ nondeterministic: Schema.optional<Schema.Literal<true>>
113
+ body: Schema.Unknown
114
+ inputs: Schema.Array$<typeof InputRef>
115
+ layers: Schema.Array$<Schema.String>
116
+ capabilities: Schema.Array$<Schema.String>
117
+ effects: Schema.optional<Schema.Unknown>
118
+ placement: Schema.optional<Schema.Unknown>
119
+ }>
120
+ ```
121
+
122
+ Everything that can change a node's result. Exported as both a schema and a type.
123
+
124
+ ### KeyMaterial.version
125
+
126
+ ```ts
127
+ const version: "flows/key-material/v2"
128
+ ```
129
+
130
+ The material version, folded into every hashed body so a bump re-keys every node derived from it.
131
+
132
+ ### KeyMaterial.dependencies
133
+
134
+ ```ts
135
+ const dependencies: (material: KeyMaterial) => ReadonlyArray<string>
136
+ ```
137
+
138
+ The graph-local dependencies a material names, in declaration order and without duplicates. `Literal` inputs contribute nothing. `Plan.compile` uses the result as the node's edge set.
139
+
140
+ ## Scheduling
141
+
142
+ `Scheduling.make(concurrency?)` creates a pure admission policy. Import it from
143
+ `@smthrs/plan/Scheduling` or the package's `Scheduling` namespace. The optional
144
+ `steps` and `agents` limits must be positive safe integers; omission is
145
+ unbounded within the safe-integer range. Construction snapshots the limits.
146
+
147
+ `policy.admit(ready, active)` takes candidates `{ node, order, waited }` and
148
+ the current permit usage `{ steps, agents }`. Each node supplies its unique
149
+ `id`, `kind` (`step`, `agent`, or `merge`), and safe-integer `priority`.
150
+ `order` is its unique non-negative position in the compiled plan, not arrival
151
+ order. `waited` is its non-negative count of capacity-constrained admission
152
+ passes. An agent consumes both a step permit and an agent permit.
153
+
154
+ The result contains immutable `admitted` and `deferred` candidate arrays plus
155
+ the number of newly admitted `agents`. Only deferred candidates receive an
156
+ incremented age; input candidates and node values are not mutated. Age
157
+ saturates at `Number.MAX_SAFE_INTEGER`. Exact priority-plus-age comparison
158
+ prevents floating-point rounding from changing the ordering. Equal scores
159
+ preserve plan order; a blocked agent does not prevent a regular node from using
160
+ available step capacity. Invalid counts, duplicate IDs/positions, or invalid
161
+ priorities are refused synchronously with `RangeError`.
162
+
163
+ This policy is used by `@smthrs/engine-store`'s durable plan scheduler. It does
164
+ not determine readiness, evaluate branches, launch nodes, or cancel effects.
165
+ The caller must supply only ready candidates and apply the returned ages before
166
+ its next admission pass. Public interpreter/compiled scheduling parity is not
167
+ provided by this policy alone.
168
+
169
+ ## StepKey
170
+
171
+ [src/StepKey.ts](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/plan/src/StepKey.ts)
172
+
173
+ The compiler from material to a [`@smthrs/keys`](/api/keys) `Key`. `planIdentity` substitutes each `Ref` and `Pending` for the already-computed key of the referenced node. Sealed material retains the `fromKeyMaterial` content-key format; other tiers use a separate declaration namespace. `ordinal` mints the deliberately run-local key of compensable, irreversible, or unsealed work, whose `tier` is one of those three words.
174
+
175
+ Structural node ids do not enter declaration fingerprints. Non-cacheable execution keys also need a run-local structural scope to distinguish repeated identical effects. Only `sealed` material may become a content key, so `fromKeyMaterial` fails `non_content_material` for the other two tiers. A dependency digest is resolved as an own property, so a `Ref` naming `toString` or `constructor` is a `missing_dependency` refusal rather than a colliding key.
176
+
177
+ The brand behind `digestInput` is private, so a plain object that merely has a `digest` field hashes as a literal. That closes a collision where shape sniffing hashed a genuine upstream-result reference and an ordinary content hash identically.
178
+
179
+ `environment` is hashed in its own namespace rather than merged into the caller's declarations, so `caller{fs:["a"]} + env{fs:["b"]}` cannot alias `caller{fs:["a","b"]} + env{}`. Environment layers keep declaration order because composition order can change behavior; caller-owned layers are set-normalized. `EnvironmentIdentity` is a discriminated union: a declared environment carries no `runScope`, and an undeclared one must carry a non-empty one, pinning the key to a single run so a step whose environment identity is unknown never serves a cross-run hit. Both `content` and `dispatchIdentity` enforce that at run time with `invalid_environment`.
180
+
181
+ `project` is the one projection semantics for the value channel. It resolves only own data properties, so a path segment that is missing, inherited, or an accessor yields `undefined` and no getter runs during key derivation.
182
+
183
+ A `DigestMemo` shares one in-flight projected-value digest between concurrent callers. A waiter never inherits the leader's interruption: if the leader's fiber is cancelled, the waiter recomputes as the new leader.
184
+
185
+ ### StepKey.StepKey
186
+
187
+ ```ts
188
+ type StepKey = StoredKey
189
+ ```
190
+
191
+ A computed step key: `key1_` plus a SHA-256 digest of the canonical serialization of the material. Identical in representation to every other flow key, which is what lets the engine dispatch under it.
192
+
193
+ ### StepKey.ContentIdentity
194
+
195
+ ```ts
196
+ interface ContentIdentity {
197
+ readonly body: unknown
198
+ readonly inputs: Readonly<Record<string, unknown | DigestInput>>
199
+ readonly layers: ReadonlyArray<string>
200
+ readonly capabilities: Readonly<Record<string, ReadonlyArray<string>>>
201
+ readonly environment?: EnvironmentIdentity | undefined
202
+ readonly hermetic?: {
203
+ readonly readSet: ReadonlyArray<{ readonly path: string; readonly digest: string }>
204
+ readonly writeSet: ReadonlyArray<FileSet.Entry>
205
+ readonly removes?: ReadonlyArray<string> | undefined
206
+ readonly boundaryMode: "hard" | "expected"
207
+ } | undefined
208
+ }
209
+ ```
210
+
211
+ Material describing a sealed or hermetic content-addressed step. `layers` and each capability group are set-normalized before hashing; `hermetic.readSet` is sorted and deduplicated by path and digest, and `hermetic.writeSet` by entry.
212
+
213
+ ### StepKey.OrdinalIdentity
214
+
215
+ ```ts
216
+ interface OrdinalIdentity {
217
+ readonly runId: string
218
+ readonly parentScope?: string | undefined
219
+ readonly ordinal: number
220
+ readonly tier: "compensable" | "irreversible" | "unsealed"
221
+ }
222
+ ```
223
+
224
+ Material describing the run-local identity of a non-cacheable step.
225
+
226
+ ### StepKey.EnvironmentIdentity
227
+
228
+ ```ts
229
+ type EnvironmentIdentity =
230
+ | {
231
+ readonly declared: true
232
+ readonly layers: ReadonlyArray<string>
233
+ readonly capabilities: Readonly<Record<string, ReadonlyArray<string>>>
234
+ readonly runScope?: undefined
235
+ }
236
+ | {
237
+ readonly declared: false
238
+ readonly layers: ReadonlyArray<string>
239
+ readonly capabilities: Readonly<Record<string, ReadonlyArray<string>>>
240
+ readonly runScope: string
241
+ }
242
+ ```
243
+
244
+ The engine-resolved execution environment a content key is computed under.
245
+ `EnvironmentIdentity` is also a runtime schema; its TypeScript type is derived
246
+ from that schema. `content`, `dispatchIdentity`, and `environmentIdentity`
247
+ validate the entire shape, reject unknown fields, and return typed
248
+ `invalid_environment` errors for invalid identities rather than defects.
249
+
250
+ ### StepKey.environmentIdentity
251
+
252
+ ```ts
253
+ const environmentIdentity: (
254
+ environment?: EnvironmentIdentity
255
+ ) => Effect.Effect<StoredKey, KeyMaterialError | Schema.SchemaError, Crypto.Crypto>
256
+ ```
257
+
258
+ A fingerprint for binding a durable execution to its runtime environment,
259
+ not an action dispatch key. It has a separate `execution-environment/v1`
260
+ namespace and uses the same normalization as content/dispatch keys: layer
261
+ order and duplicates are significant; layer strings and capability patterns
262
+ normalize to NFC; capability-pattern sets sort and deduplicate. Capability
263
+ group names retain their exact spelling. An omitted environment, declared-empty
264
+ environment, and undeclared run-scoped environment remain distinct.
265
+
266
+ `PlanScheduler` snapshots the identity at construction and stores this
267
+ fingerprint with `PlanInputStore` before dispatch. Changed identities cannot
268
+ resume the same run. This does not change existing valid action-key material
269
+ or detect implementation changes missing from the declared identity.
270
+
271
+ ### StepKey.DigestInput
272
+
273
+ ```ts
274
+ interface DigestInput {
275
+ readonly digest: string
276
+ readonly reference?: "ref" | "pending" | "ref-projected"
277
+ readonly path?: ReadonlyArray<string>
278
+ }
279
+ ```
280
+
281
+ A precomputed digest supplied as a step input rather than a literal value. It also carries a private brand, which is the whole point: only `digestInput` can produce one.
282
+
283
+ ### StepKey.digestInput
284
+
285
+ ```ts
286
+ const digestInput: (
287
+ digest: string,
288
+ reference?: {
289
+ readonly reference: "ref" | "pending" | "ref-projected"
290
+ readonly path?: ReadonlyArray<string>
291
+ }
292
+ ) => DigestInput
293
+ ```
294
+
295
+ Nominally tags a precomputed digest so it is hashed as a digest reference rather than a literal value.
296
+
297
+ ### StepKey.isDigestInput
298
+
299
+ ```ts
300
+ const isDigestInput: (value: unknown) => value is DigestInput
301
+ ```
302
+
303
+ Type guard for values produced by `digestInput`. It reads the private brand, so a plain `{digest: "..."}` answers `false`.
304
+
305
+ ### StepKey.DigestMemo
306
+
307
+ ```ts
308
+ interface DigestMemo {
309
+ readonly digest: (
310
+ from: string,
311
+ path: ReadonlyArray<string>,
312
+ compute: Effect.Effect<StepKey, Schema.SchemaError, Crypto.Crypto>
313
+ ) => Effect.Effect<StepKey, Schema.SchemaError, Crypto.Crypto>
314
+ }
315
+ ```
316
+
317
+ Caller-owned memoization context for projected dependency-value digests, addressed by the JSON-encoded `[from, path]` tuple. Entries are sound only while each settled `from` value is immutable, so create a fresh memo when those values can change.
318
+
319
+ ### StepKey.makeDigestMemo
320
+
321
+ ```ts
322
+ const makeDigestMemo: () => DigestMemo
323
+ ```
324
+
325
+ Creates an empty memo.
326
+
327
+ ### StepKey.content
328
+
329
+ ```ts
330
+ const content: (
331
+ identity: ContentIdentity
332
+ ) => Effect.Effect<StepKey, KeyMaterialError | Schema.SchemaError, Crypto.Crypto>
333
+ ```
334
+
335
+ Produces a cross-run reusable key. Set-like declarations are normalized before serialization; write declarations remain part of the identity even when the step writes nothing.
336
+
337
+ ### StepKey.ordinal
338
+
339
+ ```ts
340
+ const ordinal: (
341
+ identity: OrdinalIdentity
342
+ ) => Effect.Effect<StepKey, Schema.SchemaError, Crypto.Crypto>
343
+ ```
344
+
345
+ Produces a run-local key for compensable, irreversible, or unsealed work. These keys intentionally cannot be reused across runs.
346
+
347
+ ### StepKey.planIdentity
348
+
349
+ ```ts
350
+ const planIdentity: (
351
+ material: KeyMaterial.KeyMaterial,
352
+ dependencyDigests: Readonly<Record<string, string>>
353
+ ) => Effect.Effect<StepKey, KeyMaterialError | Schema.SchemaError, Crypto.Crypto>
354
+ ```
355
+
356
+ The declaration fingerprint used by `Plan.compile`, `append`, and `verify`.
357
+ Accepts every effect tier. Non-sealed fingerprints use a distinct, tier-bearing
358
+ namespace and are not cross-run cache keys. Dependency references must resolve
359
+ to own string-valued properties, as with `fromKeyMaterial`.
360
+
361
+ ### StepKey.fromKeyMaterial
362
+
363
+ ```ts
364
+ const fromKeyMaterial: (
365
+ material: KeyMaterial.KeyMaterial,
366
+ dependencyDigests: Readonly<Record<string, string>>
367
+ ) => Effect.Effect<StepKey, KeyMaterialError | Schema.SchemaError, Crypto.Crypto>
368
+ ```
369
+
370
+ The sealed plan key: resolves graph-local references against `dependencyDigests`, which must hold an own string-valued property for every node the material names, then builds a content key. Fails `non_content_material` unless `material.kind` is `sealed`. Use `planIdentity` for a tier-independent compiler.
371
+
372
+ ### StepKey.dispatchIdentity
373
+
374
+ ```ts
375
+ const dispatchIdentity: (options: {
376
+ readonly material: KeyMaterial.KeyMaterial
377
+ readonly results: Readonly<Record<string, unknown>>
378
+ readonly hermetic: NonNullable<ContentIdentity["hermetic"]>
379
+ readonly environment?: EnvironmentIdentity | undefined
380
+ readonly digestMemo?: DigestMemo | undefined
381
+ }) => Effect.Effect<StepKey, KeyMaterialError | Schema.SchemaError, Crypto.Crypto>
382
+ ```
383
+
384
+ The key a dispatch is _cached_ under, as distinct from the plan key a node is _identified_ by.
385
+
386
+ A plan key folds the resolved keys of every upstream node, transitively, so an edit anywhere upstream re-keys everything below it, even when the edited node's output value is byte for byte what it was before. This derivation folds the node's own material and never an upstream key. Each input contributes content instead: a `Literal` its value, a `Ref` the digest of the settled result of `from` projected along `path`, and a `Pending` nothing beyond its tag. The measured hermetic boundary is folded unchanged.
387
+
388
+ `results` must hold every dependency the material names. The scheduler's halt rule guarantees it: a dependent of failed or skipped work never dispatches, so a `Ref` always resolves against a success.
389
+
390
+ ### StepKey.project
391
+
392
+ ```ts
393
+ const project: (value: unknown, path: ReadonlyArray<string>) => unknown
394
+ ```
395
+
396
+ Projects a settled result along a `Ref` path. Only own data properties resolve, so a missing, inherited, or accessor segment yields `undefined` without invoking a getter. A projection that walks off the end of a result is a fact about the graph, not a failure: `undefined` drops out of the canonical form, so it hashes distinctly from every JSON value including `null`.
397
+
398
+ This is exported because it is the one projection semantics for the value channel. Every consumer that resolves a `Ref` at execution time must resolve it this way, or two inputs that key identically could be consumed differently.
399
+
400
+ ### StepKey.KeyMaterialError
401
+
402
+ ```ts
403
+ class KeyMaterialError extends Schema.TaggedError<KeyMaterialError>()("@smthrs/plan/KeyMaterialError", {
404
+ code: Schema.Literals(["invalid_environment", "missing_dependency", "non_content_material"])
405
+ message: Schema.String
406
+ })
407
+ ```
408
+
409
+ Stable failures while resolving graph-local dependency references.
410
+
411
+ ## Plan
412
+
413
+ [src/Plan.ts](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/plan/src/Plan.ts)
414
+
415
+ `compile` puts drafts in topological order of material dependencies, substitutes dependency digests, annotates write-set overlaps, and derives the plan digest. `append` adds a pre-keyed subgraph at the next generation, and `generationNodes` reads back the nodes the newest generation added.
416
+
417
+ Inferred edges extend `dependsOn` without reordering `plan.nodes`, so a reader can precede its writer in the array. Schedule nodes from the complete `dependsOn` graph.
418
+
419
+ `PlanNode.kind` is `step`, `agent`, or `merge`. `dependsOn` is the edge set: material references, ordering edges from `serialize`, and inferred reader-after-writer edges. Explicit `Ref`/`Pending` dependency paths select the version being read: a read before a writer consumes the initial source or an earlier producer's output, not that later writer's output. Otherwise, the compiler orders a reader after overlapping writers. A contradictory inferred ordering still fails with `cycle`; the diagnostic names both nodes, overlapping paths, and the dependency chain. Ordering edges are deliberately not key material: file content enters dispatch identity through boundary digests. `NodeEffects` carries `reads`, `writes`, optional `removes`, and `boundaryMode` (`hard` or `expected`). Removals participate in ordering just like writes.
420
+
421
+ Planning performs no I/O. Declared effects carry read and write _paths_, never digests, because measuring a path is run-time work. A node's key is a function of what it consumes, so an edited declaration re-keys that node and its dependent cone and nothing else. That is the entire invalidation mechanism: there is no reverse-dependency index and no invalidating node visitor, because content addressing subsumes both.
422
+
423
+ A plan grows and is never rewritten. `append` leaves the nodes already in it with their id, key, edges, and generation byte for byte, and the new nodes arrive pre-keyed against them. Re-ordering after a reconciliation happens by re-keying future steps.
424
+
425
+ `baseDigest` is the digest at generation 0: what a human approved and what a running run pins. `digest` advances with every appended elaboration. Both cover node identity, every computed key, the edge set, the conflict annotations, the declared effects, the priority, and each node's own conflict and runtime strategies.
426
+
427
+ A compiled plan is a deep-frozen snapshot of the drafts it was given. Material is stored as the inert JSON mirror its key already covers, so a `Date`, a `URL`, or any value with a data-valued callable `toJSON` is stored as the value it serializes to, and mutating a caller's draft after compiling cannot change the plan, its keys, or its digest. A material accessor, or a prototype with no JSON representation, is refused as `invalid_node` naming the node and the payload path rather than stored by reference. A `Planned` placeholder is left intact so canonical serialization still refuses it.
428
+
429
+ ### Plan.NodeEffects
430
+
431
+ ```ts
432
+ const NodeEffects: Schema.Struct<{
433
+ reads: Schema.Array$<typeof FileSet.ReadDeclaration>
434
+ writes: Schema.Array$<typeof FileSet.Declaration>
435
+ removes: Schema.optional<Schema.Array$<typeof FileSet.Pattern>>
436
+ boundaryMode: Schema.Literals<["hard", "expected"]>
437
+ }>
438
+ ```
439
+
440
+ What a node does to the world, declared as paths. Exported as both a schema and a type.
441
+
442
+ ### Plan.PairStrategy
443
+
444
+ ```ts
445
+ type PairStrategy = "serialize" | "lane" | "fail"
446
+ ```
447
+
448
+ The plan-time verdict for one overlapping pair of writers. Exported as both a schema and a type.
449
+
450
+ ### Plan.RuntimeStrategy
451
+
452
+ ```ts
453
+ type RuntimeStrategy = "delay-rebase" | "stop-merge"
454
+ ```
455
+
456
+ What the scheduler does when a predicted overlap actually bites. Exported as both a schema and a type.
457
+
458
+ ### Plan.ConflictAnnotation
459
+
460
+ ```ts
461
+ const ConflictAnnotation: Schema.Struct<{
462
+ with: Schema.NonEmptyString
463
+ paths: Schema.Array$<Schema.String>
464
+ strategy: typeof PairStrategy
465
+ runtime: typeof RuntimeStrategy
466
+ }>
467
+ ```
468
+
469
+ One resolved overlap between two writers that no dependency path already orders. Conflict is a property of the pair, not of one declaration. Exported as both a schema and a type.
470
+
471
+ ### Plan.PlanNode
472
+
473
+ ```ts
474
+ const PlanNode: Schema.Struct<{
475
+ id: Schema.NonEmptyString
476
+ kind: Schema.Literals<["step", "agent", "merge"]>
477
+ key: typeof StoredKey
478
+ material: typeof KeyMaterial.KeyMaterial
479
+ effects: typeof NodeEffects
480
+ dependsOn: Schema.Array$<Schema.NonEmptyString>
481
+ conflicts: Schema.Array$<typeof ConflictAnnotation>
482
+ strategy: typeof PairStrategy
483
+ runtime: typeof RuntimeStrategy
484
+ priority: Schema.Int
485
+ generation: Schema.Int
486
+ }>
487
+ ```
488
+
489
+ A keyed node of the plan. `strategy` and `runtime` are this declaration's own preferences, recorded so a later elaboration can resolve a pair against them without re-reading the flow source. Exported as both a schema and a type.
490
+
491
+ ### Plan.Plan
492
+
493
+ ```ts
494
+ const Plan: Schema.Struct<{
495
+ planId: Schema.NonEmptyString
496
+ flow: Schema.NonEmptyString
497
+ generation: Schema.Int
498
+ baseDigest: typeof StoredKey
499
+ digest: typeof StoredKey
500
+ nodes: Schema.Array$<typeof PlanNode>
501
+ }>
502
+ ```
503
+
504
+ The whole keyed graph plus the digest an approval binds to. Exported as both a schema and a type.
505
+
506
+ ### Plan.NodeDraft
507
+
508
+ ```ts
509
+ interface NodeDraft {
510
+ readonly id: string
511
+ readonly material: KeyMaterial.KeyMaterial
512
+ readonly effects: NodeEffects
513
+ readonly kind?: PlanNode["kind"] | undefined
514
+ readonly priority?: number | undefined
515
+ readonly conflictStrategy?: PairStrategy | undefined
516
+ readonly runtimeStrategy?: RuntimeStrategy | undefined
517
+ }
518
+ ```
519
+
520
+ What a planner hands `compile`: a node without its key. `kind` defaults to `step`, `priority` to 0, `conflictStrategy` to `serialize`, and `runtimeStrategy` to `delay-rebase`.
521
+
522
+ ### Plan.compile
523
+
524
+ ```ts
525
+ const compile: (options: {
526
+ readonly planId: string
527
+ readonly flow: string
528
+ readonly nodes: ReadonlyArray<NodeDraft>
529
+ }) => Effect.Effect<Plan, PlanError | StepKey.KeyMaterialError | Schema.SchemaError, Crypto.Crypto>
530
+ ```
531
+
532
+ Compiles drafts into a plan: topological order of material dependencies, dependency-digest substitution, overlap annotation, reader-after-writer ordering, and the plan digest. No I/O. The options are snapshotted before anything else happens, and the result is deep-frozen at generation 0 with `baseDigest` equal to `digest`. Array order follows material dependencies; scheduling follows the complete `dependsOn` graph.
533
+
534
+ Traversal uses explicit stacks. Because the conflict and reader-after-writer passes consider a quadratic number of node pairs, plans are bounded by `maximumPlanNodes` and fail with `graph_too_large` above that limit or the effect-analysis work budget. Analysis yields periodically.
535
+
536
+ ### Plan.append
537
+
538
+ ```ts
539
+ const append: (
540
+ plan: Plan,
541
+ drafts: ReadonlyArray<NodeDraft>
542
+ ) => Effect.Effect<Plan, PlanError | StepKey.KeyMaterialError | Schema.SchemaError, Crypto.Crypto>
543
+ ```
544
+
545
+ Appends an elaborated subgraph at the next generation. Nodes already in the plan keep their id, key, edges, and generation byte for byte, and the new nodes arrive pre-keyed against them. `baseDigest` does not move; `digest` does. New nodes retain material-dependency order after the recorded prefix, including when inferred edges point to later entries. An append with no drafts fails as `invalid_node`.
546
+
547
+ ### Plan.verify
548
+
549
+ ```ts
550
+ const verify: (input: unknown) => Effect.Effect<
551
+ Plan,
552
+ PlanError | StepKey.KeyMaterialError | Schema.SchemaError,
553
+ Crypto.Crypto
554
+ >
555
+ ```
556
+
557
+ Reconstructs an imported plan using compiler key, dependency, conflict and generation rules. It verifies both the approval digests and the complete node contract, then returns an immutable snapshot. Compiler-owned immutable plans take a trusted fast path. `Plan.append` verifies imported prefixes through this same boundary. Recorded material-dependency order is checked without sorting inferred edges. Existing ordinals, keys, and approval digests remain unchanged; corrupted records are refused rather than silently rewritten.
558
+
559
+ ### Plan.isVerified
560
+
561
+ ```ts
562
+ const isVerified: (input: unknown) => input is Plan
563
+ ```
564
+
565
+ Whether a value is a compiler-owned immutable snapshot that already passed `verify`. Such values take verify's trusted fast path and need no second schema validation, which is what lets [`@smthrs/plan-store`](/api/plan-store)'s `PlanStore.append` skip re-decoding them.
566
+
567
+ ### Plan.prefixDigest
568
+
569
+ ```ts
570
+ const prefixDigest: (
571
+ plan: Plan
572
+ ) => Effect.Effect<StepKey.StepKey, StepKey.KeyMaterialError | Schema.SchemaError, Crypto.Crypto>
573
+ ```
574
+
575
+ The approval digest of every generation before the newest: the digest the plan carried before its last append. `PlanStore.append` matches it against the stored envelope, so an append proves the recorded prefix is the caller's from the already-verified prefix instead of decoding and re-verifying every stored row.
576
+
577
+ ### Plan.generationNodes
578
+
579
+ ```ts
580
+ const generationNodes: (plan: Plan) => ReadonlyArray<PlanNode>
581
+ ```
582
+
583
+ The nodes added by the newest generation: what `PlanStore.append` inserts, and what a scheduler such as [`@smthrs/engine-store`](/api/engine-store)'s reports in the `subgraph-appended` record it writes to the run journal.
584
+
585
+ ### Plan.maximumPlanNodes
586
+
587
+ ```ts
588
+ const maximumPlanNodes: 10_000
589
+ ```
590
+
591
+ Maximum number of nodes retained by one compiled plan. Conflict analysis is quadratic in node count, so an explicit ceiling keeps untrusted declarations from turning planning into an unbounded CPU task.
592
+
593
+ ### Plan.PlanError
594
+
595
+ ```ts
596
+ class PlanError extends Schema.TaggedError<PlanError>()("@smthrs/plan/PlanError", {
597
+ code: Schema.Literals([
598
+ "cycle",
599
+ "unknown_dependency",
600
+ "duplicate_node",
601
+ "overlap_forbidden",
602
+ "invalid_effects",
603
+ "invalid_node",
604
+ "invalid_plan",
605
+ "graph_too_large"
606
+ ])
607
+ message: Schema.String
608
+ })
609
+ ```
610
+
611
+ A graph the compiler refuses, or a stored plan `verify` cannot accept. The code set is closed, so a caller may switch on it.
612
+
613
+ | `code` | Meaning |
614
+ | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
615
+ | `cycle` | material dependencies close a cycle, or a reader-after-writer edge would close one |
616
+ | `unknown_dependency` | a `Ref` or `Pending` names a node that is neither in the drafts nor already in the plan |
617
+ | `duplicate_node` | a draft reuses an id the plan already holds |
618
+ | `overlap_forbidden` | a `fail` pair genuinely overlaps and no dependency path orders it |
619
+ | `invalid_effects` | one path is declared as both a write and a removal |
620
+ | `invalid_node` | an empty plan id, flow, or node id, a priority that is not a safe integer, a `kind` or strategy outside its literal set, or key material or an effect declaration this release cannot decode, which includes a path that is not workspace-relative |
621
+ | `invalid_plan` | `Plan.verify` decoded a plan whose generations, approval digest, keys, effects, or ordering do not match its content; raised on `PlanStore.record`, `append`, and `get`, never by `compile` |
622
+ | `graph_too_large` | a plan exceeds `Plan.maximumPlanNodes` or the effect-analysis work budget |
623
+
624
+ Compilation walks with explicit stacks and never recurses per edge. Effect analysis caches transitive reachability in bitsets, updates it when inferring ordering edges, and yields periodically for interruption. It refuses more than 250,000 candidate pairs or 10,000,000 work units with `graph_too_large`. Work includes overlap comparisons, bitset merges and graph traversal. `verify` shares this budget and one effect expansion map and candidate index across all replayed generations. Plans above `Plan.maximumPlanNodes` are refused before effect analysis.
625
+
626
+ ### Conflict annotations
627
+
628
+ Declared write sets make overlap detectable at plan time. Writers already ordered by a dependency path, including one ordered through an ordering edge this pass just inferred, are not conflicts. Every other overlapping pair is annotated on both members with the resolved verdict.
629
+
630
+ | Verdict | Effect |
631
+ | ----------- | -------------------------------------------------------------------------------- |
632
+ | `serialize` | the default; the later writer gains an ordering edge |
633
+ | `lane` | both writers get lane annotations when either asks for one, and no ordering edge |
634
+ | `fail` | `compile` fails with `overlap_forbidden`, for flows that promise disjointness |
635
+
636
+ `fail` dominates `lane`, which dominates `serialize`. Each annotation also carries the runtime strategy the pair resolved to, where `stop-merge` dominates `delay-rebase`; that is what the scheduler does when the predicted overlap actually bites. Nodes frozen by an earlier generation are annotated on the new node only, because their rows are append-only.
637
+
638
+ ## Node
639
+
640
+ [src/Node.ts](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/plan/src/Node.ts)
641
+
642
+ The pure, pipeable authoring AST. Building a node records an inspectable, closure-free, JSON-serializable description and executes nothing.
643
+
644
+ Map transforms; branch decides. Both branch arms are evaluated once, symbolically, so the exit condition and the handoff site are visible topology before anything runs. A plan is always a DAG, so there is no loop node: repetition lives one level up, in what a flow settles with.
645
+
646
+ A payload is stored as its inert JSON mirror, and that mirror is identity material: it is hashed into the step key and persisted verbatim as plaintext in `node_json` and in the approval card. Nothing in this package redacts it, and redaction after hashing would break `Plan.verify`. Keep credentials out of payloads; a secret belongs in a layer, a capability, or the environment, resolved at dispatch. A data-valued callable `toJSON` is honoured, so a `Date` or a `URL` keys as the value it serializes to rather than as an empty object; a function or symbol member is dropped from an object and becomes `null` in an array; shared references and cycles clone as they were written. Accessors and unsupported prototypes without `toJSON` fail as `invalid_payload`, and a `toJSON` that returns its own receiver fails as `cyclic_payload` rather than collapsing to an empty object the way it once did. The clone and the input therefore key identically or refuse together.
647
+
648
+ `isNode` recognizes a node this package built by registration at construction, and a rehydrated node, an object sharing the node prototype whose own `ast` is a well-formed AST, by that shape, because `@smthrs/flow` hands an AST that crossed a serialization boundary back as a node. The `TypeId` marker is a public string any object can carry and counts for nothing on its own. Every combinator that admits a node reads its `ast` as trusted topology, so an object carrying the marker on any other prototype, one inheriting it from a node, and one whose `ast` is missing, malformed, or cyclic are all refused with the same `GraphBuildError` as any other non-node. A proxy is judged by the shape it forwards.
649
+
650
+ ### Node.Node
651
+
652
+ ```ts
653
+ interface Node<out A, out E = never, out R = never> extends Pipeable.Pipeable {
654
+ readonly [TypeId]: {
655
+ readonly _A: Types.Covariant<A>
656
+ readonly _E: Types.Covariant<E>
657
+ readonly _R: Types.Covariant<R>
658
+ }
659
+ readonly ast: Ast
660
+ }
661
+ ```
662
+
663
+ A pure graph-building value. `R` is Effect's requirement channel and it is phantom here: nothing at plan time reads it, so building a plan stays requirement-free while the type still states which implementations executing it will need.
664
+
665
+ `Node.Any` is `Node<unknown, unknown, any>`. `Node.Success<N>`, `Node.Error<N>`, and `Node.Services<N>` extract the three parameters.
666
+
667
+ ### Node.Ast
668
+
669
+ ```ts
670
+ type Ast = Succeed | Fail | All | Map | AndThen | Branch | Catch | FlowCall | ActionCall
671
+ ```
672
+
673
+ The inspectable AST a node stores: closure-free, and JSON serializable for every JSON payload an author puts in it. Every variant carries an optional `priority`.
674
+
675
+ ### Node.CallMode
676
+
677
+ ```ts
678
+ const CallMode: Schema.Literals<["inline", "boundary", "handoff"]>
679
+ type CallMode = "inline" | "boundary" | "handoff"
680
+ ```
681
+
682
+ How a flow call joins the caller's plan: `inline` splices the callee's body in, `boundary` makes it one child execution, and `handoff` names the next trampoline round. This is the one call-mode vocabulary. `Node.flowCall` takes it and [`@smthrs/flow`](/api/flow)'s graph builder switches on it, so both packages read the same three names.
683
+
684
+ ### Node.TypeId
685
+
686
+ ```ts
687
+ const TypeId: "~@smthrs/plan/Node"
688
+ ```
689
+
690
+ The runtime type identifier carried by every node, and its own type. It is a public string, so it is a hint and never a capability.
691
+
692
+ ### Node.FunctionIdentity
693
+
694
+ ```ts
695
+ type FunctionIdentity = {
696
+ readonly _tag: "FunctionIdentity"
697
+ readonly algorithm: "sha256-source-ephemeral/v4" | "sha256-source-captures/v5" | "static-node/v1"
698
+ readonly digest: string
699
+ }
700
+ ```
701
+
702
+ The serializable stand-in an AST keeps for a plan-time function: a digest of its normalized source, hashed in place of a closure that could not be shipped, stored, or compared.
703
+
704
+ ### Node.isNode
705
+
706
+ ```ts
707
+ const isNode: (value: unknown) => value is Any
708
+ ```
709
+
710
+ Checks whether a value is a node, by construction registration or by the shape a rehydrated node forwards.
711
+
712
+ ### Node.succeed
713
+
714
+ ```ts
715
+ const succeed: <A>(value: A) => Node<Succeed<A>>
716
+ ```
717
+
718
+ A node that succeeds with the constant’s inert JSON projection, typed as `Succeed<A>`. Dates produce `string | null` (an invalid date produces `null`); URLs produce strings. Callable `toJSON` results are projected recursively. Function and symbol members are omitted from objects and become `null` in arrays. Planned references resolve to their referenced result types. Use `Node.map` to reconstruct a domain value explicitly.
719
+
720
+ ### Node.fail
721
+
722
+ ```ts
723
+ const fail: <E>(error: E) => Node<never, Fail<E>>
724
+ ```
725
+
726
+ A node that fails with the constant’s inert JSON projection, in the typed error channel. `Fail<E>` is the same projection `Succeed<E>` describes, because a plan stores an error exactly as it stores a value.
727
+
728
+ It is how a plan states a refusal it already knows about, such as an unsupported input, an exhausted budget, or a compensation with nothing left to undo, without an action that exists only to fail. `Node.catch` recovers it exactly as it recovers an action’s failure, including schema filtering, and an uncaught one is the flow’s typed failure. It is a leaf: it declares no effects, and its error is keyed into its step identity, so two different refusals are two different steps.
729
+
730
+ ### Node.all
731
+
732
+ ```ts
733
+ const all: <const Nodes extends Readonly<Record<string, Any>>>(
734
+ nodes: Nodes
735
+ ) => Node<
736
+ Types.Simplify<{ readonly [K in keyof Nodes]: Success<Nodes[K]> }>,
737
+ Error<Nodes[keyof Nodes]>,
738
+ Services<Nodes[keyof Nodes]>
739
+ >
740
+ ```
741
+
742
+ Combines independent children into one node, keyed by name. Width is fixed at plan time. A non-node member throws `invalid_all_member` naming that member.
743
+
744
+ ### Node.race, Node.any, and Node.quorum
745
+
746
+ ```ts
747
+ type Members = Readonly<Record<string, Any>>
748
+ const race: <const Nodes extends Members>(
749
+ nodes: Nodes
750
+ ) => Node<Success<Nodes[keyof Nodes]>, Error<Nodes[keyof Nodes]>, Services<Nodes[keyof Nodes]>>
751
+ const any: <const Nodes extends Members>(
752
+ nodes: Nodes
753
+ ) => Node<Success<Nodes[keyof Nodes]>, Error<Nodes[keyof Nodes]>, Services<Nodes[keyof Nodes]>>
754
+ const quorum: <const Nodes extends Members>(
755
+ count: number,
756
+ nodes: Nodes
757
+ ) => Node<
758
+ Types.Simplify<{ readonly [K in keyof Nodes]?: Success<Nodes[K]> }>,
759
+ Error<Nodes[keyof Nodes]>,
760
+ Services<Nodes[keyof Nodes]>
761
+ >
762
+ ```
763
+
764
+ Joins that settle early. `race` settles with the first member to settle, success or typed failure. `any` settles with the first success and fails with the last failure when every member fails. `quorum` succeeds with exactly the first `count` successes, keyed by member, and fails with the failure that leaves too few members able to succeed.
765
+
766
+ Members still running when the join decides are interrupted. The members that decided are journaled in order, so a resumed run settles the same members and never starts the others again. An interrupted (parked) member is undecided; a defect or interpreter refusal in a member propagates without a decision. No members, a non-node member, or a quorum count outside 1 to the member count throws `invalid_join`.
767
+
768
+ ### Node.map
769
+
770
+ ```ts
771
+ const map: {
772
+ <A, B>(f: (a: A) => B): <E, R>(self: Node<A, E, R>) => Node<B, E, R>
773
+ <A, E, R, B>(self: Node<A, E, R>, f: (a: A) => B): Node<B, E, R>
774
+ }
775
+ ```
776
+
777
+ Transforms an eventual success value with a deferred pure function. The function is digested, not run: it executes later, on the real value. A `map` that decides what happens next is a `branch` written wrongly.
778
+
779
+ ### Node.andThen
780
+
781
+ ```ts
782
+ const andThen: {
783
+ <B, E2, R2>(next: Node<B, E2, R2>): <A, E, R>(self: Node<A, E, R>) => Node<B, E | E2, R | R2>
784
+ <A, E, R, B, E2, R2>(self: Node<A, E, R>, next: Node<B, E2, R2>): Node<B, E | E2, R | R2>
785
+ }
786
+ ```
787
+
788
+ Starts the entire next subtree only after the first node succeeds, without consuming its result. Failure or interruption prevents nested actions, combinations, and inline flows from starting. Independent children inside the next subtree can still run concurrently once the boundary opens. Passing a callback is a type error and throws `invalid_continuation` in JavaScript.
789
+
790
+ ### Node.bindPlanned
791
+
792
+ ```ts
793
+ const bindPlanned: {
794
+ <A, B, E2, R2>(
795
+ build: (reference: Planned.Planned<A>) => Node<B, E2, R2>
796
+ ): <E, R>(self: Node<A, E, R>) => Node<B, E | E2, R | R2>
797
+ <A, E, R, B, E2, R2>(
798
+ self: Node<A, E, R>,
799
+ build: (reference: Planned.Planned<A>) => Node<B, E2, R2>
800
+ ): Node<B, E | E2, R | R2>
801
+ }
802
+ ```
803
+
804
+ Builds a dependency at plan time using a reference to the future result. Pass it into payloads; use `Node.map` for value computation and `Node.branch` for decisions. The callback receives a symbolic reference. Enable type-aware ESLint's `@typescript-eslint/strict-boolean-expressions` to reject planned conditions; explicit Boolean coercion and reference equality still require review.
805
+
806
+ Migration: replace callback-form `Node.andThen` with `Node.bindPlanned`. Direct node sequencing remains `Node.andThen`. This source API change preserves existing AST and key formats.
807
+
808
+ `bindPlanned` builds dependencies, not a whole-subtree success barrier. Members
809
+ that do not consume the reference can start while its producer runs. Use
810
+ explicit `andThen` when no work in the next subtree may start before success.
811
+ The corrected graph normalization now carries that explicit barrier to every
812
+ descendant, including inline-flow bodies. This changes compiled keys/digests
813
+ for previously under-ordered nested sequences; re-plan affected work rather
814
+ than substituting the new graph into an already approved execution.
815
+
816
+ ### Node.BranchOptions
817
+
818
+ ```ts
819
+ interface BranchOptions<A, B1, E1, R1, B2, E2, R2> {
820
+ readonly if: (value: A) => boolean
821
+ readonly then: (value: Planned.Planned<A>) => Node<B1, E1, R1>
822
+ readonly else: (value: Planned.Planned<A>) => Node<B2, E2, R2>
823
+ }
824
+ ```
825
+
826
+ `if` runs at run time on the real value. `then` and `else` run at plan time, once each, against a `Planned` placeholder.
827
+
828
+ ### Node.branch
829
+
830
+ ```ts
831
+ const branch: {
832
+ <A, B1, E1, R1, B2, E2, R2>(
833
+ options: BranchOptions<A, B1, E1, R1, B2, E2, R2>
834
+ ): <E, R>(self: Node<A, E, R>) => Node<B1 | B2, E | E1 | E2, R | R1 | R2>
835
+ <A, E, R, B1, E1, R1, B2, E2, R2>(
836
+ self: Node<A, E, R>,
837
+ options: BranchOptions<A, B1, E1, R1, B2, E2, R2>
838
+ ): Node<B1 | B2, E | E1 | E2, R | R1 | R2>
839
+ }
840
+ ```
841
+
842
+ Decides between two arms, both of them static topology. The predicate function is snapshotted at construction; replacing `options.if` later does not change the chosen arm. Both arms contribute their requirements, because a run has to be able to take either. An arm that does not return a node throws `invalid_continuation`.
843
+
844
+ ### Node.CatchOptions
845
+
846
+ ```ts
847
+ interface CatchOptions<E, B, E2, R2 = never, Handled = E> {
848
+ readonly error?: Schema.Schema<Handled> | undefined
849
+ readonly onFailure: (error: Planned.Planned<Handled>) => Node<B, E2, R2>
850
+ }
851
+ ```
852
+
853
+ The statically planned recovery arm and the optional schema selecting which typed failures it handles.
854
+
855
+ ### Node.catch
856
+
857
+ ```ts
858
+ const catch: {
859
+ <Handled, B, E2, R2>(
860
+ options: CatchOptions<unknown, B, E2, R2, Handled> & { readonly error: Schema.Schema<Handled> }
861
+ ): <A, E, R>(self: Node<A, E, R>) => Node<A | B, E | E2, R | R2>
862
+ <E, B, E2, R2>(
863
+ options: CatchOptions<E, B, E2, R2> & { readonly error?: undefined }
864
+ ): <A, R>(self: Node<A, E, R>) => Node<A | B, E2, R | R2>
865
+ <A, E, R, Handled, B, E2, R2>(
866
+ self: Node<A, E, R>,
867
+ options: CatchOptions<E, B, E2, R2, Handled> & { readonly error: Schema.Schema<Handled> }
868
+ ): Node<A | B, E | E2, R | R2>
869
+ <A, E, R, B, E2, R2>(
870
+ self: Node<A, E, R>,
871
+ options: CatchOptions<E, B, E2, R2> & { readonly error?: undefined }
872
+ ): Node<A | B, E2, R | R2>
873
+ }
874
+ ```
875
+
876
+ Recovers from matching typed failures with static failure topology. With no schema the whole typed error channel is handled. A schema handles only values it accepts, so schema-filtered overloads retain `E | E2`: refinements can reject values without narrowing their TypeScript type. The filter’s JSON Schema and runtime function identities both enter the catch key. Uncaptured schema callbacks have process-local identity and cannot enter a stable graph. For stable custom checks, construct `SchemaAST.Filter` with a `Node.capture`-annotated `run` function and declare all semantic captures; `Schema.makeFilter` wraps its predicate in an opaque closure.
877
+
878
+ ### Node.priority
879
+
880
+ ```ts
881
+ const priority: {
882
+ (value: number): <A, E, R>(self: Node<A, E, R>) => Node<A, E, R>
883
+ <A, E, R>(self: Node<A, E, R>, value: number): Node<A, E, R>
884
+ }
885
+ ```
886
+
887
+ Attaches a scheduling priority, leaving the original node unchanged. Higher runs first among ready work, so a priority changes latency and nothing else. It never enters key material. Children inherit the value lexically when the graph is built, and a child that states its own keeps it. A value that is not a safe integer throws `invalid_priority`.
888
+
889
+ ### Node.declaredPriority
890
+
891
+ ```ts
892
+ const declaredPriority: (ast: Ast) => number | undefined
893
+ ```
894
+
895
+ Reads the priority a node carries, or `undefined` when it states none and inherits from whatever encloses it.
896
+
897
+ ### Node.capture
898
+
899
+ ```ts
900
+ const capture: <C extends Readonly<Record<string, unknown>>, Args extends ReadonlyArray<unknown>, A>(
901
+ captures: C,
902
+ operation: (this: Readonly<C>, ...args: Args) => A
903
+ ) => (...args: Args) => A
904
+ ```
905
+
906
+ Declares the inert values a plan-time function closes over, which gives that function deterministic identity instead of process-local entropy. A deeply frozen plain copy is canonicalized into function identity and bound as the callback's `this` receiver. Use a function expression to read that copy; ordinary arguments keep their positions. Caller objects remain unchanged, and mutable lexical aliases must not supply semantic state. Sealed, non-extensible, frozen and Immer ordinary data are supported. Built-in brands and Proxies are refused, and object capture requires `structuredClone`. See the [admission algorithm](https://crypto.smithers.sh/reference/api/#identitycapture). Unsupported values, accessors, exotic prototypes, symbols, cycles, and member nesting beyond 256 levels throw a `TypeError` naming the path, instead of producing an identity that cannot describe the function's behavior.
907
+
908
+ Plan uses the capture and function-identity implementation from `@smthrs/crypto/Identity`, the one implementation every node model embeds. Capturing an already-captured function preserves its original source and folds both capture sets into the identity. Wrappers can be captured and identified interchangeably by either package. A non-function operation throws `TypeError("Node.capture requires a function operation")`.
909
+
910
+ ### Node.plannedReference
911
+
912
+ ```ts
913
+ const plannedReference: (value: unknown) => {
914
+ readonly _tag: "PlannedReference"
915
+ readonly node: string
916
+ readonly path: ReadonlyArray<string>
917
+ } | undefined
918
+ ```
919
+
920
+ Reads the inert AST reference created for a planned value. Structural lookalikes remain ordinary payload data: the marker is private to the AST cloner, so this accessor is the only recognition path.
921
+
922
+ ### Node.branchSubject and Node.catchSubject
923
+
924
+ ```ts
925
+ const branchSubject: "branch/subject"
926
+ const catchSubject: "catch/subject"
927
+ ```
928
+
929
+ The node reference a branch arm's symbolic subject carries, and the prefix each `catch` mints its own token under. Arms are built before the graph assigns ids, so every reference an arm records names one of these placeholders, and graph building rewrites it to the node the arm belongs to.
930
+
931
+ ### Engine members
932
+
933
+ `flowCall`, `actionCall`, `declaration`, `continuation`, `mapper`, `predicate`, `catchFilter`, and `functionIdentity` exist for [`@smthrs/flow`](/api/flow), which owns flow and action authoring. They are supported names, not authoring API.
934
+
935
+ ```ts
936
+ const flowCall: <A = unknown, E = never, R = never>(
937
+ declaration: unknown,
938
+ flow: string,
939
+ mode: CallMode,
940
+ payload: unknown
941
+ ) => Node<A, E, R>
942
+
943
+ const actionCall: <A = unknown, E = never, R = never>(
944
+ declaration: unknown,
945
+ action: string,
946
+ payload: unknown
947
+ ) => Node<A, E, R>
948
+
949
+ const declaration: (ast: Extract<Ast, { readonly _tag: "ActionCall" | "FlowCall" }>) => unknown
950
+ const continuation: (
951
+ ast: Extract<Ast, { readonly _tag: "AndThen" }>
952
+ ) => ((value: Planned.Planned<unknown>) => unknown) | undefined
953
+ const mapper: (ast: Ast) => ((value: unknown) => unknown) | undefined
954
+ const predicate: (ast: Ast) => ((value: unknown) => boolean) | undefined
955
+ const catchFilter: (ast: Ast) => Schema.Top | undefined
956
+ const functionIdentity: (operation: unknown) => FunctionIdentity
957
+ ```
958
+
959
+ `flowCall` and `actionCall` validate nothing: an unknown tag becomes a call node the graph keeps as a leaf. `declaration`, `continuation`, `mapper`, `predicate`, and `catchFilter` answer `undefined` for the wrong variant and for an AST rehydrated from JSON, whose side tables did not survive serialization. `functionIdentity` throws a `TypeError` when handed anything but a function.
960
+
961
+ ## Planned
962
+
963
+ [src/Planned.ts](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/plan/src/Planned.ts)
964
+
965
+ A planned value may be passed into a payload field, into a branch, or into a map, and field access is allowed, because it records a reference path.
966
+
967
+ :::danger
968
+ A planned value may never be computed on.
969
+ :::
970
+
971
+ Misuse fails twice. The type is branded, so arithmetic on a planned value is a compile error; and the proxy's `Symbol.toPrimitive`, `valueOf`, `toString`, `toJSON`, application, `in`, and enumeration traps throw rather than let a plan be built around `NaN` or `"[object Object]"`, which catches template interpolation, `String(value)`, and `JSON.stringify` of a payload holding one. JavaScript exposes no trap for `Boolean(value)` or strict identity, so those cannot be refused at run time; they reveal only proxy truthiness or identity and never the planned result.
972
+
973
+ The `TypeId` symbol is interned, so a value that crossed a module boundary is still recognised. Interning is a recognition aid rather than a capability, so `reference` returns a reference only when the value stored under that symbol has the complete `{node, path}` shape.
974
+
975
+ ### Planned.Planned
976
+
977
+ ```ts
978
+ type Planned<T> =
979
+ & { readonly [TypeId]: Identity<T> }
980
+ & ([T] extends [object] ? { readonly [K in keyof T]: Planned<T[K]> } : unknown)
981
+ ```
982
+
983
+ A step result that has not been produced yet. The mapped half keeps field access typed, so `result.files` is a `Planned` of the field.
984
+
985
+ ### Planned.Reference and Planned.Identity
986
+
987
+ ```ts
988
+ interface Reference {
989
+ readonly node: string
990
+ readonly path: ReadonlyArray<string>
991
+ }
992
+
993
+ interface Identity<out T> extends Reference {
994
+ readonly _T: Types.Covariant<T>
995
+ }
996
+ ```
997
+
998
+ What a planned value points at: the node that will produce the result, and the property path read from it. `path` is empty for the result itself.
999
+
1000
+ ### Planned.TypeId
1001
+
1002
+ ```ts
1003
+ const TypeId: unique symbol // Symbol.for("@smthrs/plan/Planned")
1004
+ ```
1005
+
1006
+ The brand carried by every planned value, and the key its reference is read from.
1007
+
1008
+ ### Planned.make
1009
+
1010
+ ```ts
1011
+ const make: <T>(node: string) => Planned<T>
1012
+ ```
1013
+
1014
+ Creates the strict placeholder standing for a node's result. Planning hands one to every builder that consumes an upstream value, then reads the `Reference` back off whatever the builder passed it into.
1015
+
1016
+ ### Planned.reference
1017
+
1018
+ ```ts
1019
+ const reference: (value: unknown) => Reference | undefined
1020
+ ```
1021
+
1022
+ Reads the reference a planned value records, or `undefined` for anything else. A forged carrier is accepted only when its node and path have the complete reference shape. This is how a payload is scanned for the upstream results it consumes.
1023
+
1024
+ ### Planned.isPlanned
1025
+
1026
+ ```ts
1027
+ const isPlanned: (value: unknown) => value is Planned<unknown>
1028
+ ```
1029
+
1030
+ Checks whether a value is a planned placeholder.
1031
+
1032
+ ## FileSet
1033
+
1034
+ [src/FileSet.ts](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/plan/src/FileSet.ts)
1035
+
1036
+ The static filesystem declaration vocabulary shared by planning and execution: a workspace-relative `Pattern`, a Bazel-style `Glob`, a `TreeArtifact`, and the `Filegroup` that names a reusable collection.
1037
+
1038
+ `canonical` rewrites every separator to `/` and normalizes to Unicode NFC, and every exact-path comparison goes through it, so the backslash spelling and the NFD spelling of one workspace path overlap. `workspaceRelative` refuses absolute paths, drive-letter prefixes (`C:/x` and the drive-relative `C:x`), `..` and `.` segments, empty segments, the C0 control range, and DEL. C1 bytes stay legal, because a POSIX file name may contain them.
1039
+
1040
+ `overlaps` is conservative: `true` may over-serialize, while `false` proves that no path can belong to both declarations. It compares the `folded` form, so two spellings that differ only in letter case overlap, because a case-insensitive filesystem (default macOS APFS, Windows NTFS) opens them as one file.
1041
+
1042
+ ### The declaration types
1043
+
1044
+ ```ts
1045
+ const Pattern: Schema.String // workspace-relative; `*` and `**` match only inside a Glob
1046
+ const Glob: Schema.TaggedStruct<"Glob", { include: NonEmptyArray<Pattern>; exclude?: Array<Pattern> }>
1047
+ const TreeArtifact: Schema.TaggedStruct<"TreeArtifact", { path: Pattern }>
1048
+ const Filegroup: Schema.TaggedStruct<"Filegroup", { name: NonEmptyString; entries: Array<Entry> }>
1049
+ const ReadFilegroup: Schema.TaggedStruct<"Filegroup", { name: NonEmptyString; entries: Array<ReadEntry> }>
1050
+
1051
+ type Entry = Pattern | Glob | TreeArtifact
1052
+ type ReadEntry = Pattern | Glob
1053
+ type Declaration = Entry | Filegroup
1054
+ type ReadDeclaration = ReadEntry | ReadFilegroup
1055
+ ```
1056
+
1057
+ Each is exported as both a schema and a type. `Declaration` is what a write set accepts and `ReadDeclaration` what a read set accepts, which is how a tree artifact is kept out of a read set: a read set names what a node consumes, and a directory output is not that.
1058
+
1059
+ ### FileSet.canonical
1060
+
1061
+ ```ts
1062
+ const canonical: (path: string) => string
1063
+ ```
1064
+
1065
+ The canonical spelling of a declared path or pattern: every separator is `/`, and Unicode is normalized to NFC.
1066
+
1067
+ ### FileSet.folded
1068
+
1069
+ ```ts
1070
+ const folded: (path: string) => string
1071
+ ```
1072
+
1073
+ The `canonical` spelling with letter case folded away. Static overlap compares this form.
1074
+
1075
+ ### FileSet.workspaceRelative
1076
+
1077
+ ```ts
1078
+ const workspaceRelative: (pattern: string) => boolean
1079
+ ```
1080
+
1081
+ Whether a declared path stays inside the workspace and names it one way only. Refuses absolute paths (POSIX and drive-letter), any first segment starting with a drive letter and `:` (including the drive-relative `C:foo`), upward traversal, the aliasing forms (`.` segments, empty segments), C0 controls, and DEL.
1082
+
1083
+ ### FileSet.makeFilegroup
1084
+
1085
+ ```ts
1086
+ const makeFilegroup: (name: string, entries: ReadonlyArray<Entry>) => Filegroup
1087
+ ```
1088
+
1089
+ Creates a named filegroup.
1090
+
1091
+ ### FileSet.expand and FileSet.expandReads
1092
+
1093
+ ```ts
1094
+ const expand: (declarations: ReadonlyArray<Declaration>) => ReadonlyArray<Entry>
1095
+ const expandReads: (declarations: ReadonlyArray<ReadDeclaration>) => ReadonlyArray<ReadEntry>
1096
+ ```
1097
+
1098
+ Expands filegroups deterministically, preserving declaration order. Both plan passes expand before they compare anything.
1099
+
1100
+ ### FileSet.isGlob and FileSet.isTreeArtifact
1101
+
1102
+ ```ts
1103
+ const isGlob: (value: unknown) => value is Glob
1104
+ const isTreeArtifact: (value: unknown) => value is TreeArtifact
1105
+ ```
1106
+
1107
+ Which variant an entry is.
1108
+
1109
+ ### FileSet.matchesPattern and FileSet.matchesGlob
1110
+
1111
+ ```ts
1112
+ const matchesPattern: (pattern: string, path: string) => boolean
1113
+ const matchesGlob: (glob: Glob, path: string) => boolean
1114
+ ```
1115
+
1116
+ Bazel's `*` and `**` path semantics without permitting traversal: `*` matches within one segment, `**` matches segments, and a trailing `**` matches the rest of the path. `matchesGlob` honours the glob's `exclude` list.
1117
+
1118
+ Matching consumes the entire path, including Unicode line separators. Compiled
1119
+ matchers are cached by canonical pattern spelling. Segment matching is linear
1120
+ in the segment and pattern lengths; recursive matching visits each pattern/path
1121
+ segment pair once, without regex backtracking.
1122
+
1123
+ ### FileSet.overlaps
1124
+
1125
+ ```ts
1126
+ const overlaps: (left: Entry, right: Entry) => boolean
1127
+ ```
1128
+
1129
+ Conservative static overlap. A plain string is a literal path, even one containing `*` or `**`, as it is at execution. Exact paths and tree roots compare in `folded` form: canonical separator, NFC, and case-insensitive. A glob against an exact path folds both its `include` and `exclude` patterns. Two globs always overlap, and so do a glob and a tree artifact. A tree artifact overlaps any path beneath it. A glob tests the path bytes it is handed, so canonicalizing a measured path before matching is the caller's decision.
1130
+
1131
+ ## Effects
1132
+
1133
+ [src/Effects.ts](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/plan/src/Effects.ts)
1134
+
1135
+ The one effect envelope model. A flow declares what it may read and write, and a
1136
+ graph build checks every step beneath it against that declaration. `@smthrs/flow`
1137
+ enforces it while it builds, `@smthrs/core` re-exports it as `Effects`, and
1138
+ `@smthrs/patterns` intersects a decorator's declaration with the flow it wraps
1139
+ through it.
1140
+
1141
+ `FileSet` above is the other half of the vocabulary and stays separate: it
1142
+ describes the files a node actually touches, in Bazel-style entries a plan
1143
+ compiles conflicts from, while this module describes the AUTHORITY a flow holds,
1144
+ in the flat path grammar `covers` defines.
1145
+
1146
+ ### Effects.Declaration
1147
+
1148
+ ```ts
1149
+ interface Declaration {
1150
+ readonly reads: ReadonlyArray<string>
1151
+ readonly writes: ReadonlyArray<string>
1152
+ readonly mode: "hermetic" | "expected"
1153
+ readonly onConflict: "serialize" | "lane" | "fail"
1154
+ readonly tier?: "sealed" | "compensable" | "irreversible" | undefined
1155
+ }
1156
+ ```
1157
+
1158
+ `mode` says whether the declaration is complete (`hermetic`) or partial
1159
+ (`expected`). `onConflict` says what the planner should do about another writer
1160
+ of the same path, and its three values are `Plan.PairStrategy`. `tier` says how
1161
+ reversible the effect is, matching `KeyMaterial.kind`, and an omitted tier reads
1162
+ as `sealed`.
1163
+
1164
+ ### Effects.make
1165
+
1166
+ ```ts
1167
+ const make: (input: MakeOptions) => Declaration
1168
+ ```
1169
+
1170
+ Constructs a deterministic declaration. `MakeOptions` takes `Iterable<string>`
1171
+ for `reads` and `writes` and is otherwise identical to `Declaration`.
1172
+ Normalization is sorting and deduplication only: no separator rewriting and no
1173
+ dot-segment resolution is performed, so hand it paths that are already
1174
+ normalized.
1175
+
1176
+ ### Effects.covers
1177
+
1178
+ ```ts
1179
+ const covers: (envelope: string, path: string) => boolean
1180
+ ```
1181
+
1182
+ Whether one envelope entry covers one path. The grammar is exhaustive and
1183
+ intentionally not full minimatch: an exact path matches itself, `*` and `**`
1184
+ match everything, `prefix*` matches by string prefix, and `prefix/**` matches
1185
+ `prefix/` and everything below it but not the bare path `prefix`. A path
1186
+ containing a whole `.` or `..` segment is never covered.
1187
+
1188
+ ### Effects.narrow
1189
+
1190
+ ```ts
1191
+ const narrow: (envelope: Declaration, step: Declaration) => NarrowResult
1192
+ ```
1193
+
1194
+ Verifies that a step declaration stays within an enclosing envelope. Read and
1195
+ write paths must be covered independently, `expected` may tighten to
1196
+ `hermetic` but not the reverse, and the tier may narrow from `irreversible` to
1197
+ `compensable` to `sealed`.
1198
+
1199
+ ### Effects.NarrowResult
1200
+
1201
+ ```ts
1202
+ type NarrowResult =
1203
+ | { readonly ok: true }
1204
+ | {
1205
+ readonly ok: false
1206
+ readonly code: "effect_outside_envelope" | "effect_mode_widening" | "effect_tier_widening"
1207
+ readonly paths: ReadonlyArray<string>
1208
+ }
1209
+ ```
1210
+
1211
+ `paths` is populated for `effect_outside_envelope` and empty for the other two.
1212
+ The three codes are the three `GraphBuildError` codes a build records when a
1213
+ declaration widens the envelope that encloses it.
1214
+
1215
+ ### Effects.overlaps
1216
+
1217
+ ```ts
1218
+ const overlaps: (a: Declaration, b: Declaration) => ReadonlyArray<string>
1219
+ ```
1220
+
1221
+ Returns the concrete or narrower write declarations two declarations share,
1222
+ sorted and duplicate-free. Two declarations of the same literal path always
1223
+ overlap, including a path `covers` refuses to match because it carries a `.` or
1224
+ `..` segment: glob coverage stays strict, but two writers naming the same
1225
+ unnormalized path are still writing the same resource.
1226
+
1227
+ ### Effects.sealed
1228
+
1229
+ ```ts
1230
+ const sealed: (declaration: Declaration) => Declaration
1231
+ ```
1232
+
1233
+ Returns a `hermetic`, `sealed` copy of a declaration.
1234
+
1235
+ ### The prepared matching API
1236
+
1237
+ ```ts
1238
+ const prepareEnvelope: (envelope: Declaration) => PreparedEnvelope
1239
+ const narrowPrepared: (envelope: PreparedEnvelope, step: Declaration) => NarrowResult
1240
+
1241
+ const indexPaths: (lists: Iterable<ReadonlyArray<string>>) => PathIndex
1242
+ const rankPaths: (index: PathIndex, paths: ReadonlyArray<string>) => Ranked
1243
+ const overlapRanks: (index: PathIndex, a: Ranked, b: Ranked) => Array<number>
1244
+
1245
+ const boundedEffects: (declaration: Declaration, limit: number, refuse: () => Error) => Declaration
1246
+ const maximumPathLength: 4096
1247
+ const maximumGlobs: 128
1248
+ ```
1249
+
1250
+ What a graph build uses so one envelope costs its size once per build rather
1251
+ than once per step it encloses. `prepareEnvelope` reads an envelope into the
1252
+ form `narrowPrepared` answers from; `indexPaths`, `rankPaths`, and
1253
+ `overlapRanks` are the same decomposition for write overlap, which `overlaps`
1254
+ wraps for the pairwise case. `boundedEffects` snapshots a declaration into
1255
+ builder-owned data and throws the caller's own refusal before the copy grows
1256
+ past `limit`, past `maximumPathLength` code units in one path, or past
1257
+ `maximumGlobs` patterns in one list.
1258
+
1259
+ ## Placement
1260
+
1261
+ [src/Placement.ts](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/plan/src/Placement.ts)
1262
+
1263
+ The one placement model. [`@smthrs/core`](/api/core) re-exports it as `Placement`, [`@smthrs/flow`](/api/flow) annotates with it as `Flow.PlacementDirective`, and `@smthrs/registry` lowers a descriptor's frontmatter literal into it. A directive is serializable detail identifying a host profile; it never carries a host implementation, credentials, or any other runtime handle.
1264
+
1265
+ The four tags read `flows/core/Placement/...` because they are hashed into `KeyMaterial.placement`, and therefore into the step key of every node that declares a placement. Retagging them would re-key recorded work and miss its cached results, so the tag is treated as a wire value rather than as a location.
1266
+
1267
+ ### Placement.Placement and Placement.Options
1268
+
1269
+ ```ts
1270
+ type Placement = Data.TaggedEnum<{
1271
+ readonly "flows/core/Placement/Local": Readonly<Record<never, never>>
1272
+ readonly "flows/core/Placement/Client": Readonly<Record<never, never>>
1273
+ readonly "flows/core/Placement/Sandbox": Options
1274
+ readonly "flows/core/Placement/Remote": Options
1275
+ }>
1276
+
1277
+ interface Options {
1278
+ readonly image?: string | undefined
1279
+ readonly profile?: string | undefined
1280
+ readonly target?: string | undefined
1281
+ }
1282
+ ```
1283
+
1284
+ ### Placement.local, client, sandbox and remote
1285
+
1286
+ ```ts
1287
+ const local: () => Placement
1288
+ const client: () => Placement
1289
+ const sandbox: (options?: Options) => Placement
1290
+ const remote: (options?: Options) => Placement
1291
+ ```
1292
+
1293
+ The local process host, the viewer's browser, an isolated sandbox, and a remote control plane. `sandbox` and `remote` copy the options they are given, so a later mutation of the caller's object cannot change a recorded directive.
1294
+
1295
+ ### Placement.Annotation
1296
+
1297
+ ```ts
1298
+ const Annotation: Context.Key<Placement, Placement>
1299
+ ```
1300
+
1301
+ The annotation key a declared placement is carried under, identified by `"@smthrs/plan/Placement"`. One key: `@smthrs/core`'s `Annotations.Placement` and `@smthrs/flow`'s `Flow.Placement` are both this object.
1302
+
1303
+ ## CachePolicy
1304
+
1305
+ [src/CachePolicy.ts](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/plan/src/CachePolicy.ts)
1306
+
1307
+ The one cache-policy model. [`@smthrs/flow`](/api/flow) publishes it as `CacheEnvironment.CachePolicy` and [`@smthrs/engine-store`](/api/engine-store) reads it off a dispatched action; [`@smthrs/patterns`](/api/smithers-patterns) declares it over a flow as `WithCache.Policy`. It was two declarations in those two packages, joined by nothing but a re-typed identifier string, so a field added on one side was invisible to the other.
1308
+
1309
+ The annotation key reads `"@smthrs/flow/Action/CachePolicy"`. The engine reads stored and dispatched actions by that identifier, so retagging it would make every policy already written invisible at dispatch. The identifier is treated as a wire value rather than as a location.
1310
+
1311
+ ### CachePolicy.CacheScope
1312
+
1313
+ ```ts
1314
+ const CacheScope: Schema.Literals<["run", "flow", "shared"]>
1315
+ type CacheScope = "run" | "flow" | "shared"
1316
+ ```
1317
+
1318
+ How far a recorded sealed result may travel. `shared` is the content-addressed default: the key names the inputs and the environment, so any run on any host that would have produced the same bytes may reuse the row. `run` and `flow` fold one execution's or one flow's identity into the key, so a sibling never reads it. The three levels are the old `run | workflow | global` policy named after the current concepts.
1319
+
1320
+ ### CachePolicy.CachePolicy
1321
+
1322
+ ```ts
1323
+ const CachePolicy: Schema.Struct<{
1324
+ ttlMs: Schema.optionalKey<Schema.Int>
1325
+ scope: Schema.optionalKey<typeof CacheScope>
1326
+ }>
1327
+ ```
1328
+
1329
+ `ttlMs` is a positive whole number of milliseconds bounding the age of a row the engine may serve; past it the dispatch executes again and journals `cache-expired`, so the refusal is durable evidence a replay reads rather than a fresh clock reading. Both fields are optional and both defaults are the pre-policy behaviour: no age bound, and the reach the composition already granted.
1330
+
1331
+ ### CachePolicy.CachePolicyAnnotation and cachePolicyOf
1332
+
1333
+ ```ts
1334
+ const CachePolicyAnnotation: Context.Key<CachePolicy, CachePolicy>
1335
+ const cachePolicyOf: (annotations: Context.Context<never>) => CachePolicy | undefined
1336
+ ```
1337
+
1338
+ The key a declared policy is carried under, identified by `"@smthrs/flow/Action/CachePolicy"`, and the reader for it. One key: `@smthrs/flow`'s `CacheEnvironment.CachePolicyAnnotation` and `@smthrs/patterns`' `WithCache.CachePolicyAnnotation` are both this object.
1339
+
1340
+ ### CachePolicy.annotate
1341
+
1342
+ ```ts
1343
+ const annotate: <A extends { annotate: (key: typeof CachePolicyAnnotation, value: CachePolicy) => A }>(
1344
+ declaration: A,
1345
+ policy: CachePolicy
1346
+ ) => A
1347
+ ```
1348
+
1349
+ Attaches a policy to any declaration that carries annotations, answering a new one rather than mutating it. `@smthrs/flow` publishes it as `CacheEnvironment.withCache`.
1350
+
1351
+ ## Repetition
1352
+
1353
+ [src/Repetition.ts](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/plan/src/Repetition.ts)
1354
+
1355
+ The one bounded-repetition ceiling vocabulary. [`@smthrs/flow`](/api/flow)'s `Poll.make` takes it as `onTimeout`, for the attempt a poll gives up on, and [`@smthrs/patterns`](/api/smithers-patterns)' `Loop` takes it as `onMaxReached`, for the iteration a loop gives up on.
1356
+
1357
+ ### Repetition.AtCeiling
1358
+
1359
+ ```ts
1360
+ const AtCeiling: Schema.Literals<["fail", "return-last"]>
1361
+ type AtCeiling = "fail" | "return-last"
1362
+ ```
1363
+
1364
+ `fail` raises the repetition's own typed failure at the ceiling; `return-last` settles with the last value it produced.
1365
+
1366
+ ## GraphBuildError
1367
+
1368
+ [src/GraphBuildError.ts](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/plan/src/GraphBuildError.ts)
1369
+
1370
+ The refusals a plan-time build raises instead of producing a wrong plan. Each carries the site, `node` plus the recorded property `path`, and states the fix in `message`, because the author reading it is mid-body.
1371
+
1372
+ ### GraphBuildError.GraphBuildError
1373
+
1374
+ ```ts
1375
+ class GraphBuildError extends Schema.TaggedError<GraphBuildError>()("@smthrs/plan/GraphBuildError", {
1376
+ code: typeof GraphBuildErrorCode
1377
+ node: Schema.String
1378
+ path: Schema.Array$<Schema.String>
1379
+ message: Schema.String
1380
+ })
1381
+ ```
1382
+
1383
+ `node` is the node reference the failure belongs to: a planned value's origin node, an `all` member name, or a branch arm. `path` is the property path recorded on a planned value before it was misused, and the payload path for a payload refusal. It is empty for every other code.
1384
+
1385
+ ### GraphBuildError.GraphBuildErrorCode
1386
+
1387
+ | `code` | Meaning |
1388
+ | ----------------------------- | ---------------------------------------------------------------------------------------------- |
1389
+ | `planned_value_computed` | a body computed on a step result |
1390
+ | `invalid_all_member` | `Node.all` received a non-node member |
1391
+ | `invalid_join` | a race, any, or quorum has a non-node member, no members, or an unreachable count |
1392
+ | `invalid_continuation` | a branch arm, catch arm, or continuation did not return a node |
1393
+ | `recursion_requires_boundary` | a flow calls itself inline instead of using a trampoline handoff or an explicit child boundary |
1394
+ | `placement_requires_boundary` | an inline call's callee declares a placement the enclosing flow cannot satisfy |
1395
+ | `cyclic_payload` | a payload contains itself, so no plan could serialize or hash it |
1396
+ | `payload_too_deep` | a payload is nested past the build bound |
1397
+ | `graph_too_deep` | authored topology is nested past the build bound |
1398
+ | `duplicate_node` | two structural graph addresses resolve to one durable node id |
1399
+ | `invalid_priority` | `Node.priority` received a value that is not a safe integer |
1400
+ | `invalid_payload` | a payload member cannot be captured as inert JSON without executing code or losing identity |
1401
+ | `unstable_callback` | a callback has process-local identity in a build that requires stable callbacks |
1402
+ | `effect_outside_envelope` | a declaration reads or writes a path the enclosing effect envelope does not cover |
1403
+ | `effect_mode_widening` | an `expected` declaration sits inside a `hermetic` envelope |
1404
+ | `effect_tier_widening` | a declaration's tier is less reversible than the enclosing envelope's |
1405
+ | `capability_outside_grant` | a called flow or action requires a capability the caller does not hold, so the call drops it |
1406
+ | `write_conflict` | two work nodes overlap under `onConflict: "fail"` |
1407
+ | `missing_key_material` | a node reached key compilation without any |
1408
+ | `dependency_cycle` | a dependency set cannot be ordered |
1409
+ | `plan_too_large` | a node, edge, conflict, or effect-path limit was crossed |
1410
+ | `payload_too_large` | one plan value expands to more members than the bound |
1411
+ | `invalid_node` | a malformed node AST |
1412
+
1413
+ `GraphBuildErrorCode` is a closed schema literal, so a caller may switch on it and a new refusal is a deliberate addition rather than a new free-form string. This package raises `planned_value_computed`, `invalid_all_member`, `invalid_join`, `invalid_continuation`, `invalid_priority`, `invalid_payload`, and `cyclic_payload`. [`@smthrs/flow`](/api/flow)'s graph walk raises `recursion_requires_boundary`, `placement_requires_boundary`, `payload_too_deep`, `graph_too_deep`, `duplicate_node`, `unstable_callback`, and the four effect-authority codes. [`@smthrs/core`](/api/core)'s graph builder raises the last six, plus `payload_too_deep`, `graph_too_deep`, `duplicate_node`, `invalid_payload`, and the four effect-authority codes; it uses this error rather than a second class of its own. `unstable_callback` is raised when `Graph.build` runs with `callbackIdentity: "stable"` and a callback carries no `Node.capture` declaration; declare its complete inert captures, including the version of any imported implementation, so the callback keys by content instead of by process.
1414
+
1415
+ The four effect-authority codes are recorded while a walk checks every
1416
+ declaration against the envelope enclosing it, using the narrowing rule
1417
+ [`Effects.narrow`](#effectsnarrow) defines.
1418
+
1419
+ A `write_conflict` names the first of the overlapping pair in `node` and both in
1420
+ its `message`; `Graph.conflicts` carries the pair as data.
1421
+
1422
+ ### GraphBuildError.isFatalDiagnostic
1423
+
1424
+ ```ts
1425
+ const isFatalDiagnostic: (diagnostic: GraphBuildError) => boolean
1426
+ ```
1427
+
1428
+ Whether a recorded refusal blocks the drafts a plan is compiled from. Every code
1429
+ is fatal except `capability_outside_grant`: a capability the caller does not
1430
+ grant is DROPPED, so the callee runs with LESS authority than it asked for,
1431
+ which is the safe direction. The author still needs to see it, because a flow
1432
+ that silently loses a capability fails later at the action that needed it, so
1433
+ the build records the refusal and `Graph.drafts` still hands the drafts over.
1434
+
1435
+ ## PlanDiff
1436
+
1437
+ [src/PlanDiff.ts](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/plan/src/PlanDiff.ts)
1438
+
1439
+ The verdict is the key: two nodes with the same id and the same key are the same step. The attribution, `changed: ["body", "input[1]"]`, is a report for a human, derived by comparing declarations field by field, and is deliberately part of no digest. Labels mirror the hashed declaration: `kind` (the effect tier, which selects the key namespace), `body`, `layers`, `capabilities`, `effects`, `version`, `nondeterministic`, `placement`, and `input[n]`, including `input[n]` entries whose declaration is unchanged but whose referenced node itself re-keyed. A node re-keyed purely by an upstream edit is therefore attributed to the input position that references it, even behind an unprojected `Pending`, rather than reported as nothing changed.
1440
+
1441
+ Each compared field is projected through the same JSON mirror the keys are derived from, so two `Date` bodies a generation apart attribute to `body` rather than to nothing. The projection runs no accessor, and a field it refuses compares by an identity token scoped to that node and field, so `diff` stays a total function even for a value canonical serialization would reject.
1442
+
1443
+ ### PlanDiff.PlanDiff
1444
+
1445
+ ```ts
1446
+ interface PlanDiff {
1447
+ readonly added: ReadonlyArray<string>
1448
+ readonly removed: ReadonlyArray<string>
1449
+ readonly rekeyed: ReadonlyArray<Rekeyed>
1450
+ readonly unchanged: ReadonlyArray<string>
1451
+ }
1452
+ ```
1453
+
1454
+ What changed between two plans of the same flow, as node ids.
1455
+
1456
+ ### PlanDiff.Rekeyed
1457
+
1458
+ ```ts
1459
+ interface Rekeyed {
1460
+ readonly id: string
1461
+ readonly from: string
1462
+ readonly to: string
1463
+ readonly changed: ReadonlyArray<string>
1464
+ }
1465
+ ```
1466
+
1467
+ A node whose key moved, with the field labels that moved it. `changed` is empty only when none of the compared fields moved.
1468
+
1469
+ ### PlanDiff.diff
1470
+
1471
+ ```ts
1472
+ const diff: (previous: Plan.Plan, next: Plan.Plan) => PlanDiff
1473
+ ```
1474
+
1475
+ Compares a plan against the last plan for the same flow. Pure, total, and free of requirements.