Function: foldOps()
function foldOps(
planId: string,
ops: readonly PlanOp[],
provenance?: PlanProvenance,
): {
issues: PlanIssue[];
view: RawPlanView;
};Defined in: src/batteries/orchestration/ops.ts:165
Fold an op log into a RawPlanView, deterministically and convergently.
Parameters
| Parameter | Type | Description |
|---|---|---|
planId | string | The plan's id, carried into the view. |
ops | readonly PlanOp[] | The op log to fold. May be empty, out of order, or contain duplicates. |
provenance? | PlanProvenance | Optional lineage (clone/template), carried into the view and covered by the digest. |
Returns
{
issues: PlanIssue[];
view: RawPlanView;
}The folded view and any issues the fold surfaced.
| Name | Type | Defined in |
|---|---|---|
issues | PlanIssue[] | src/batteries/orchestration/ops.ts:169 |
view | RawPlanView | src/batteries/orchestration/ops.ts:169 |
Remarks
The plan is the fold of its op log. Two actors folding the same op set must reach the same state, and ops may arrive out of order or twice. The fold is therefore a pure function of the op SET: it sorts by the three-part key (lamport, actorId, opId) — see byKey for why all three parts are required — and applies the ops in that total order. Because the highest-key op touching any element is applied last, the result is LWW (last-writer-wins) and identical for every arrival order.
Ops are strictly read-only input. The fold never mutates the caller's op objects: on add_node and set_node_definition it copies the plain-object/array SPINE of the node or definition (carrying every encoder-owned value — Date, RegExp, Map, Set, typed arrays, bigint, NodeRef/ParamRef instances — across by reference), and set_node_field/ set_node_phase write only onto those copies. So a PlanStore can serve historical views from the same op log without a read-only projection silently altering it.
Bounds are the fold SEED, not an op. The fold starts from DEFAULT_PLAN_BOUNDS, so an empty log folds to revision 0 with a complete view and a stable digest, and the first authoring op makes revision 1. revision is the number of ops folded. set_bounds ops override the seed by LWW thereafter.
Element semantics. add_node/remove_node and add_edge/remove_edge are LWW-ELEMENT, not add-wins: the highest-key op touching an element decides whether it exists. Add-wins is deliberately NOT attempted — it needs causal context a scalar lamport cannot provide. A remove_node also records its incidentEdgeIds, so removal cascades to those edges order-independently. set_node_field is LWW per field on the same three-part key (its value may contain a NodeRef, so it accepts ArgValue); set_node_definition replaces a whole definition by LWW; set_node_phase sets a phase, with null clearing it; set_bounds overrides the seed by LWW.
The fold surfaces issues rather than throwing. It never throws on a malformed-but-well-typed log:
- An edge whose
fromortonode does not exist after folding is DROPPED and surfaced as adangling_edgeissue — a dangling edge is never what anyone wanted. - Two
add_edgeops with the SAME id but different endpoints: LWW decides, the loser is dropped, and the issue names BOTH so the author renames one. The second is not refused at append time — that would make the fold order-dependent, and two offline writers can each legally append before their logs meet. - An op referencing an unknown nodeId is surfaced as an
unknown_nodeissue, not thrown.
The digest is computed via planDigest(view) with the digest field itself empty when hashing, so it cannot depend on itself.