undo Single Collapse
Undoes ONE collapse — a single survivor/retired-member pair — without disturbing the run's other collapses. This is the targeted counterpart to a run-level undo: restore exactly the member named by CollapseUndoCommand.retiredId to its prior status, and subtract only what it (no other still-retired member of the same collapse) contributed to the survivor.
Undo is destructive, so it fails closed. Three things must be in place before it reads or writes anything, and a missing one refuses:
The context that owns the collapse. command carries it, and both propositions must live in it. An id belonging to another context throws CollapseUndoContextMismatchException with that context's graph untouched. The ids reach this function from wherever the caller found them — a URL path, a UI card — so this is the boundary that keeps one tenant's ids off another tenant's propositions.
The run's audit records. collectorRecords settles whether the collector really applied this merge; a null one throws CollapseUndoConfigurationException. The trace store cannot settle it, because it records that a collapse was proposed: the strategy writes the decision during the mark phase, before the runner has decided anything. A dry run, a skipped merge, and a merge the runner declined all leave a trace that reads exactly like an applied fold, and reversing one of those strips evidence the survivor holds for its own reasons.
A store that can subtract evidence atomically. propositions must implement ProvenanceSubtractionCapable and answer true to its ProvenanceSubtractionCapable.supportsProvenanceSubtraction; a store that cannot throws CollapseUndoConfigurationException. That interface carries the promise this depends on.
Overlap-safe: if another member of the same CollectorDecision has not yet been undone and also folded the same grounding/provenance/source id, that ref is left on the survivor — subtracting this member's copy would otherwise strip evidence a sibling merge still needs. Once that sibling is itself undone it holds its own copy again, so the last member to be undone takes the shared ref with it and the survivor lands back on what it had before the collapse. This is why each RetiredProposition carries its own folded set rather than the decision carrying one shared union.
The whole fold comes off the survivor through ProvenanceSubtractionCapable.subtractFoldedEvidence, which names the evidence refs, the grounding and the source ids to remove and takes them off in one step. A persistent backend's ordinary save appends provenance and never removes it, so an undo that only saved the reduced proposition would leave the folded evidence on the graph, and a replacing backend's save after the subtraction would write this function's copy back over evidence another writer added since. So the survivor is never saved; the write order below says why. Because the subtraction names what goes and lands in one step, evidence another extraction adds to the survivor while this undo is running survives it.
Both propositions are read before anything is written. A missing participant ends the undo with nothing changed, leaving no survivor whose evidence has been subtracted and no member stranded unrestored. So does a collapse the collector never applied.
A survivor deleted between that read and the subtraction is caught when the subtraction answers null for a proposition the store no longer has. The undo ends there, writing nothing — saving the copy it read would recreate a proposition another writer deleted, with the folded evidence still on it. Nothing is stamped, the member stays retired, and the null return says no restore happened. A caller left with a member retired into a survivor that no longer exists has to decide what that member should be; this function will not guess.
Every deletion up to the subtraction's own last look is caught this way, because ProvenanceSubtractionCapable promises a store never recreates a proposition it is subtracting from. The window that stays open is the gap between that answer and the survivor's save below, which upserts. Closing it needs a conditional write the store contract does not have, and it would have to reach every save in the chain. The residual is written up in docs/design/source-revisions.md.
Authorization is three conditions, and all must hold.
The collector applied this merge, into this survivor. Settled by collectorRecords. They carry
CollectorRun.dryRunon the run header and, on each member's record,CollectorRecord.mergedIntoId: the survivor the sweep really folded that member into, written after the merge was saved. A plain status transition, a skipped merge, and the fallback retirement a runner performs when a merge target has vanished all leave it null, so none of them can authorize an undo. A dry run's records name the target it would have used, and the header'sdryRunflag is what says nothing happened — so a preview never authorizes either.The undo has not already run. Settled by collectorRecords too: finishing an undo stamps
CollectorRecord.undoneAton the run's record for that member, and a stamped record never authorizes again. This is what makes a repeat undo a no-op even when the member has been retired again in the meantime — by a decay sweep, or by a later collector run folding it into somewhere else. Without the stamp, immortal records plus any new retirement would re-arm the original undo and let it subtract that run's evidence a second time, taking evidence the survivor had since re-gained and clobbering the newer retirement.The merge is still in force. Settled by the member's status. See isCurrentlyRetired.
Conditions 2 and 3 together make a second undo of the same collapse a no-op, however the member's status has moved since.
Three shapes of collapse are refused, all of them correctly, and all of them silent apart from a log line:
A chain-resolved merge. With stacked strategies, a member marked into B while B is itself merged into C is folded onto the terminal survivor C, and the record names C — while the trace decision still names B. Undoing against B fails the record check, and undoing against C fails the
requirebelow as a caller error. Refusing is right either way: the folded evidence keys were computed against B's evidence, so subtracting them from C would remove the wrong set.A member revived without a retry. Covered below under isCurrentlyRetired.
A shadowed decision. CollectorTraceQuery.findDecisionRetiring looks up by retired member and answers the newest one, so a decision where the id survived can no longer shadow the decision that actually retired it. What can still shadow it is a dry-run preview recorded after the applied collapse: that preview is the newer retirement, so the undo evaluates it, finds the dry-run header, and declines. Conservative, and worth knowing if an undo refuses for no visible reason.
Two things worth knowing before relying on this:
Evidence the survivor re-gained on its own is still subtracted. A collapse folds an entry, and afterwards the survivor is independently extracted with the identical entry. Structural dedup keeps one copy, so nothing distinguishes the re-gained entry from the folded one, and the undo removes it. Grounding refs and source ids have always behaved this way; evidence now matches them. An entry differing in any of the six fields — a different revision of the same source, say — is a different key and is untouched.
No transaction spans the writes, so the order is chosen to make every interruption recoverable. It runs: (1)
subtractFoldedEvidencetakes the evidence, grounding and source ids off the survivor in one step, (2) theundoneAtstamp, (3) the member's restore. The stamp sits between the survivor's write and the restore deliberately: after the restore, losing it would leave a finished undo still authorized, and a retry could not repair that because the member would already be back at its prior status.
| Interrupted after | State | What a retry does |
|---|---|---|
| nothing | untouched | the whole undo |
| (1) | survivor final, member retired, no stamp | re-derives against current evidence, so the subtraction is a no-op, then stamps and restores |
| (2) | stamped, member still retired | restores only, touching no evidence |
| (3) | complete | nothing; the stamp refuses |
Step 1 is safe to repeat because the subtraction is recomputed from the survivor's current evidence by key, so a ref that is already gone removes nothing. The survivor is never written through save, so a decorator that publishes from save announces the member's restore and nothing for the survivor; the survivor's write goes through the capability, unannounced, the way the other provenance operations do.
Return
the updated survivor and restored proposition, or null if nothing was retired under CollapseUndoCommand.retiredId (no trace of this collapse), if either proposition was already gone when this function read it, if the subtraction reports the survivor gone, or if the collapse cannot be shown to have been applied. A deletion landing after the subtraction's own answer goes undetected and the survivor is recreated; see the paragraphs above.
Parameters
the context, the survivor and the retired member this undo is for
where the collapse decision (and its retired members) is looked up
where the survivor and retired proposition are read, where the member is restored, where each sibling's current status is checked, and where the whole fold is subtracted from the survivor by name. Must be ProvenanceSubtractionCapable.
the run's audit records. Required: they are what makes "was this collapse applied, and has it been reversed already" a recorded fact.
Throws
if collectorRecords is null, or if propositions cannot subtract evidence atomically — wiring mistakes, caught before anything is read
if either proposition lives in some context other than CollapseUndoCommand.contextId — caught before anything is written, and ahead of the survivor-mismatch check, so a caller from another context learns only that it was refused
if the retired member was retired into a different survivor from the one command names — a caller error, and no missing-data case. Reached only once both propositions have been shown to belong to the commanded context, since its message quotes the survivor the decision names.
Deprecated
Unguarded: checks neither context ownership nor the collector's audit records. Build a CollapseUndoCommand and pass the CollectorRecordStore.
Replace with
import com.embabel.dice.spi.CollapseUndoCommand
undoSingleCollapse(CollapseUndoCommand(contextId, survivorId, retiredId), traceQuery, propositions, collectorRecords)The undo as it shipped before the command form: no context to check ownership against and no audit records to confirm the collapse was applied, so it restores the member and subtracts its folded refs on the trace's word alone. Kept so a caller compiled against the four-argument form keeps working; the guarded form above is what new code should call, and this one goes in the next minor release.
Return
the updated survivor and restored proposition, or null if nothing was retired under retiredId or either proposition no longer exists
Parameters
where the collapse decision (and its retired members) is looked up
where the survivor and retired proposition are read and saved
the collapse's survivor — must match the decision that retired retiredId
the one retired member to restore
Throws
if retiredId was retired into a different survivor than survivorId