You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@cassandra.apache.org by be...@apache.org on 2024/08/08 11:17:46 UTC

(cassandra-accord) branch trunk updated: CommandsForKey Improvements incl Pruning

This is an automated email from the ASF dual-hosted git repository.

benedict pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/cassandra-accord.git


The following commit(s) were added to refs/heads/trunk by this push:
     new 2297b984 CommandsForKey Improvements incl Pruning
2297b984 is described below

commit 2297b9846c1687a8b212638172f9dfee98bc024c
Author: Benedict Elliott Smith <be...@apache.org>
AuthorDate: Thu Jun 6 22:04:37 2024 +0100

    CommandsForKey Improvements incl Pruning
    
    CommandsForKey periodically self-prunes, so as to continue functioning well in-between garbage collections. Once we prune we are left with potentially incomplete information, and have to sometimes load per-command information from disk. But the payoff is ensuring CommandsForKey objects - which drive the majority of the state machine - are kept to a reasonable size.
    
    patch by Benedict; reviewed by Blake Eggleston and David Capwell
---
 accord-core/src/main/java/accord/api/Agent.java    |   16 +
 accord-core/src/main/java/accord/api/Read.java     |    1 -
 accord-core/src/main/java/accord/api/Update.java   |    1 -
 .../accord/coordinate/CoordinationAdapter.java     |    2 +-
 .../src/main/java/accord/coordinate/Infer.java     |    6 +-
 .../src/main/java/accord/coordinate/Propose.java   |    3 +
 .../java/accord/impl/AbstractSafeCommandStore.java |    9 +-
 .../java/accord/impl/InMemoryCommandStore.java     |   35 +-
 .../accord/impl/InMemorySafeCommandsForKey.java    |    6 +-
 .../src/main/java/accord/local/Command.java        |  357 ++--
 .../src/main/java/accord/local/CommandStore.java   |   83 +-
 .../src/main/java/accord/local/Commands.java       |   40 +-
 .../src/main/java/accord/local/CommandsForKey.java | 1726 --------------------
 .../src/main/java/accord/local/PreLoadContext.java |    4 +
 .../main/java/accord/local/RedundantBefore.java    |    6 +-
 .../src/main/java/accord/local/SafeCommand.java    |    6 +-
 .../main/java/accord/local/SafeCommandStore.java   |  104 +-
 .../main/java/accord/local/SafeCommandsForKey.java |   99 --
 accord-core/src/main/java/accord/local/Status.java |   59 +-
 .../main/java/accord/local/cfk/CommandsForKey.java | 1471 +++++++++++++++++
 .../accord/local/cfk/CommandsForKeyUpdate.java     |   72 +
 .../src/main/java/accord/local/cfk/NotifySink.java |  113 ++
 .../main/java/accord/local/cfk/PostProcess.java    |  245 +++
 .../src/main/java/accord/local/cfk/Pruning.java    |  332 ++++
 .../java/accord/local/cfk/SafeCommandsForKey.java  |  112 ++
 .../src/main/java/accord/local/cfk/Updating.java   |  898 ++++++++++
 .../src/main/java/accord/local/cfk/Utils.java      |  393 +++++
 .../main/java/accord/messages/BeginRecovery.java   |    9 +-
 .../src/main/java/accord/messages/CheckStatus.java |    9 +-
 .../src/main/java/accord/messages/Commit.java      |    1 +
 .../src/main/java/accord/messages/Propagate.java   |    2 +-
 .../src/main/java/accord/messages/ReadData.java    |   32 +-
 .../java/accord/messages/ReadEphemeralTxnData.java |    2 +-
 .../main/java/accord/messages/WaitOnCommit.java    |    2 +-
 .../main/java/accord/primitives/AbstractKeys.java  |   16 +
 .../src/main/java/accord/primitives/Ballot.java    |    6 +
 .../src/main/java/accord/primitives/Deps.java      |  222 +--
 .../src/main/java/accord/primitives/KeyDeps.java   |    5 +
 .../main/java/accord/primitives/LatestDeps.java    |   14 +-
 .../main/java/accord/primitives/PartialDeps.java   |   33 +-
 .../main/java/accord/primitives/PartialTxn.java    |   15 -
 .../src/main/java/accord/primitives/RangeDeps.java |   38 +-
 .../src/main/java/accord/primitives/SyncPoint.java |    4 +
 .../src/main/java/accord/utils/ArrayBuffers.java   |  141 +-
 .../src/main/java/accord/utils/Invariants.java     |   49 +-
 .../utils/MergeFewDisjointSortedListsCursor.java   |  156 ++
 .../main/java/accord/utils/RelationMultiMap.java   |    4 +-
 .../src/main/java/accord/utils/SortedArrays.java   |   83 +-
 .../{api/Read.java => utils/SortedCursor.java}     |   25 +-
 .../src/main/java/accord/utils/btree/BTree.java    |   17 +
 accord-core/src/test/java/accord/impl/IntKey.java  |    6 +
 .../test/java/accord/impl/PrefixedIntHashKey.java  |    4 +
 .../src/test/java/accord/impl/TestAgent.java       |   12 +
 .../accord/impl/basic/DelayedCommandStores.java    |    9 +-
 .../src/test/java/accord/impl/basic/Journal.java   |   12 +-
 .../src/test/java/accord/impl/list/ListAgent.java  |   12 +
 .../src/test/java/accord/local/WaitingOnTest.java  |  136 ++
 .../java/accord/local/cfk/CommandsForKeyTest.java  | 1093 +++++++++++++
 .../test/java/accord/messages/PreAcceptTest.java   |   10 +-
 .../src/test/java/accord/primitives/DepsTest.java  |  119 ++
 .../test/java/accord/primitives/KeyDepsTest.java   |   33 +
 .../src/test/java/accord/utils/AccordGens.java     |  120 +-
 accord-core/src/test/java/accord/utils/Gen.java    |   16 +
 accord-core/src/test/java/accord/utils/Gens.java   |    5 +
 .../test/java/accord/utils/ReflectionUtils.java    |   15 +
 .../src/main/java/accord/maelstrom/Json.java       |   31 +-
 .../main/java/accord/maelstrom/MaelstromAgent.java |   12 +
 .../src/test/java/accord/maelstrom/JsonTest.java   |    2 +-
 68 files changed, 6400 insertions(+), 2331 deletions(-)

diff --git a/accord-core/src/main/java/accord/api/Agent.java b/accord-core/src/main/java/accord/api/Agent.java
index 0bb21cd0..8e26d891 100644
--- a/accord-core/src/main/java/accord/api/Agent.java
+++ b/accord-core/src/main/java/accord/api/Agent.java
@@ -74,6 +74,22 @@ public interface Agent extends UncaughtExceptionListener
      */
     long preAcceptTimeout();
 
+    /**
+     * Controls pruning of CommandsForKey
+     *
+     * The timestamp delta between the prune point and any pruned TxnId. This works primarily to minimise the
+     * chance of encountering a TxnId that precedes prunedBefore.
+     */
+    long cfkHlcPruneDelta();
+
+    /**
+     * Controls pruning of CommandsForKey.
+     *
+     * The number of entries before the candidate prune point that we require before we try to prune.
+     * This only works to reduce the time wasted pruning when there is limited benefit.
+     */
+    int cfkPruneInterval();
+
     Txn emptyTxn(Txn.Kind kind, Seekables<?, ?> keysOrRanges);
 
     default EventsListener metricsEventsListener()
diff --git a/accord-core/src/main/java/accord/api/Read.java b/accord-core/src/main/java/accord/api/Read.java
index 3b77ed3c..fe0d9403 100644
--- a/accord-core/src/main/java/accord/api/Read.java
+++ b/accord-core/src/main/java/accord/api/Read.java
@@ -34,5 +34,4 @@ public interface Read
     AsyncChain<Data> read(Seekable key, SafeCommandStore commandStore, Timestamp executeAt, DataStore store);
     Read slice(Ranges ranges);
     Read merge(Read other);
-    default boolean isEqualOrFuller(Read other) { return true; }
 }
diff --git a/accord-core/src/main/java/accord/api/Update.java b/accord-core/src/main/java/accord/api/Update.java
index fa8493e5..8a56efcc 100644
--- a/accord-core/src/main/java/accord/api/Update.java
+++ b/accord-core/src/main/java/accord/api/Update.java
@@ -36,5 +36,4 @@ public interface Update
     Write apply(Timestamp executeAt, @Nullable Data data);
     Update slice(Ranges ranges);
     Update merge(Update other);
-    default boolean isEqualOrFuller(Update other) { return true; }
 }
diff --git a/accord-core/src/main/java/accord/coordinate/CoordinationAdapter.java b/accord-core/src/main/java/accord/coordinate/CoordinationAdapter.java
index 89068df7..0aced3c6 100644
--- a/accord-core/src/main/java/accord/coordinate/CoordinationAdapter.java
+++ b/accord-core/src/main/java/accord/coordinate/CoordinationAdapter.java
@@ -101,7 +101,7 @@ public interface CoordinationAdapter<R>
             else if (any.currentEpoch() >= executeAt.epoch() && any.oldestEpoch() <= txnId.epoch()) all = any.forEpochs(txnId.epoch(), executeAt.epoch());
             else all = node.topology().preciseEpochs(route, txnId.epoch(), executeAt.epoch());
 
-            adapter.stabilise(node, any, all, route, ballot, txnId, txn, executeAt, deps, callback);
+            adapter.stabilise(node, coordinates, all, route, ballot, txnId, txn, executeAt, deps, callback);
         }
 
         public static <R> void execute(CoordinationAdapter<R> adapter, Node node, Topologies any, FullRoute<?> route, ExecutePath path, TxnId txnId, Txn txn, Timestamp executeAt, Deps deps, BiConsumer<? super R, Throwable> callback)
diff --git a/accord-core/src/main/java/accord/coordinate/Infer.java b/accord-core/src/main/java/accord/coordinate/Infer.java
index e38d1502..eecae95a 100644
--- a/accord-core/src/main/java/accord/coordinate/Infer.java
+++ b/accord-core/src/main/java/accord/coordinate/Infer.java
@@ -142,10 +142,10 @@ public class Infer
             return a.compareTo(b) <= 0 ? a : b;
         }
 
-        public boolean inferInvalidWithQuorum(IsPreempted isPreempted, Known known)
+        public boolean inferInvalidWithQuorum(IsPreempted isPreempted, Known minKnown)
         {
-            return inferInvalidWithQuorum(undecided, isPreempted, !known.isDecided())
-                   || inferInvalidWithQuorum(unknown, isPreempted, !known.hasDefinitionBeenKnown());
+            return inferInvalidWithQuorum(undecided, isPreempted, !minKnown.isDecided())
+                   || inferInvalidWithQuorum(unknown, isPreempted, !minKnown.hasDefinitionBeenKnown());
         }
 
         private static boolean inferInvalidWithQuorum(InvalidIf invalidIf, IsPreempted isPreempted, boolean hasCondition)
diff --git a/accord-core/src/main/java/accord/coordinate/Propose.java b/accord-core/src/main/java/accord/coordinate/Propose.java
index b72408ee..cb5783bc 100644
--- a/accord-core/src/main/java/accord/coordinate/Propose.java
+++ b/accord-core/src/main/java/accord/coordinate/Propose.java
@@ -43,6 +43,7 @@ import accord.primitives.Txn;
 import accord.primitives.TxnId;
 import accord.topology.Shard;
 import accord.topology.Topologies;
+import accord.utils.Invariants;
 
 import static accord.coordinate.tracking.AbstractTracker.ShardOutcomes.Fail;
 import static accord.coordinate.tracking.RequestStatus.Failed;
@@ -77,6 +78,8 @@ abstract class Propose<R> implements Callback<AcceptReply>
         this.callback = callback;
         this.acceptOks = new ArrayList<>();
         this.acceptTracker = new QuorumTracker(topologies);
+        Invariants.checkState(txnId.kind().isSyncPoint() || deps.keyDeps.txnIdCount() == 0 || deps.keyDeps.txnId(deps.keyDeps.txnIdCount() - 1).compareTo(executeAt) < 0,
+                              "Attempted to propose a standard transaction with an earlier executeAt than a conflicting transaction it witnessed: %s vs executeAt: %s", deps, executeAt);
     }
 
     void start()
diff --git a/accord-core/src/main/java/accord/impl/AbstractSafeCommandStore.java b/accord-core/src/main/java/accord/impl/AbstractSafeCommandStore.java
index 4d8d2a64..f005e5b7 100644
--- a/accord-core/src/main/java/accord/impl/AbstractSafeCommandStore.java
+++ b/accord-core/src/main/java/accord/impl/AbstractSafeCommandStore.java
@@ -26,6 +26,7 @@ import com.google.common.annotations.VisibleForTesting;
 import accord.api.Key;
 import accord.api.VisibleForImplementation;
 import accord.local.*;
+import accord.local.cfk.SafeCommandsForKey;
 import accord.primitives.*;
 import accord.utils.Invariants;
 
@@ -160,15 +161,7 @@ public abstract class AbstractSafeCommandStore<CommandType extends SafeCommand,
         return context.isSubsetOf(this.context);
     }
 
-    protected abstract void invalidateSafeState();
-
     public void postExecute()
     {
     }
-
-    public void complete()
-    {
-        postExecute();
-        invalidateSafeState();
-    }
 }
diff --git a/accord-core/src/main/java/accord/impl/InMemoryCommandStore.java b/accord-core/src/main/java/accord/impl/InMemoryCommandStore.java
index 4932b181..ab1b3e2b 100644
--- a/accord-core/src/main/java/accord/impl/InMemoryCommandStore.java
+++ b/accord-core/src/main/java/accord/impl/InMemoryCommandStore.java
@@ -52,6 +52,8 @@ import accord.api.DataStore;
 import accord.api.Key;
 import accord.api.ProgressLog;
 import accord.local.CommandStores.RangesForEpoch;
+import accord.local.cfk.CommandsForKey;
+import accord.local.cfk.SafeCommandsForKey;
 import accord.primitives.AbstractKeys;
 import accord.primitives.Deps;
 import accord.primitives.PartialDeps;
@@ -254,7 +256,7 @@ public abstract class InMemoryCommandStore extends CommandStore
         return timestampsForKey.get(key);
     }
 
-    private <O> O mapReduceForKey(InMemorySafeStore safeStore, Routables<?> keysOrRanges, Ranges slice, BiFunction<accord.local.CommandsForKey, O, O> map, O accumulate)
+    private <O> O mapReduceForKey(InMemorySafeStore safeStore, Routables<?> keysOrRanges, Ranges slice, BiFunction<CommandsForKey, O, O> map, O accumulate)
     {
         switch (keysOrRanges.domain()) {
             default:
@@ -264,7 +266,7 @@ public abstract class InMemoryCommandStore extends CommandStore
                 for (Key key : keys)
                 {
                     if (!slice.contains(key)) continue;
-                    accord.local.CommandsForKey commands = safeStore.ifLoadedAndInitialised(key).current();
+                    CommandsForKey commands = safeStore.ifLoadedAndInitialised(key).current();
                     if (commands == null)
                         continue;
 
@@ -281,7 +283,7 @@ public abstract class InMemoryCommandStore extends CommandStore
                     for (Map.Entry<RoutableKey, GlobalCommandsForKey> entry : commandsForKey.subMap(range.start(), range.startInclusive(), range.end(), range.endInclusive()).entrySet())
                     {
                         GlobalCommandsForKey globalCommands = entry.getValue();
-                        accord.local.CommandsForKey commands = globalCommands.value();
+                        CommandsForKey commands = globalCommands.value();
                         if (commands == null)
                             continue;
                         accumulate = map.apply(commands, accumulate);
@@ -444,9 +446,10 @@ public abstract class InMemoryCommandStore extends CommandStore
     {
         if (store != current)
             throw illegalState("This operation has already been cleared");
+
         try
         {
-            current.complete();
+            current.postExecute();
         }
         catch (Throwable t)
         {
@@ -515,9 +518,7 @@ public abstract class InMemoryCommandStore extends CommandStore
     @Override
     public String toString()
     {
-        return getClass().getSimpleName() + "{" +
-               "id=" + id +
-               '}';
+        return getClass().getSimpleName() + "{" + "id=" + id + ",node=" + time.id().id + '}';
     }
 
     static class RangeCommand
@@ -1003,7 +1004,7 @@ public abstract class InMemoryCommandStore extends CommandStore
         }
 
         @Override
-        protected void invalidateSafeState()
+        public void postExecute()
         {
             commands.values().forEach(c -> {
                 if (c != null && c.current() != null)
@@ -1017,9 +1018,21 @@ public abstract class InMemoryCommandStore extends CommandStore
                 }
             });
 
-            commands.values().forEach(InMemorySafeCommand::invalidate);
-            timestampsForKey.values().forEach(InMemorySafeTimestampsForKey::invalidate);
-            commandsForKey.values().forEach(InMemorySafeCommandsForKey::invalidate);
+            commands.values().forEach(c -> {
+                if (c.isUnset())
+                    commandStore.commands.remove(c.txnId());
+                c.invalidate();
+            });
+            timestampsForKey.values().forEach(tfk -> {
+                if (tfk.isUnset())
+                    commandStore.timestampsForKey.remove(tfk.key());
+                tfk.invalidate();
+            });
+            commandsForKey.values().forEach(cfk -> {
+                if (cfk.isUnset())
+                    commandStore.commandsForKey.remove(cfk.key());
+                cfk.invalidate();
+            });
         }
     }
 
diff --git a/accord-core/src/main/java/accord/impl/InMemorySafeCommandsForKey.java b/accord-core/src/main/java/accord/impl/InMemorySafeCommandsForKey.java
index ed758065..14957750 100644
--- a/accord-core/src/main/java/accord/impl/InMemorySafeCommandsForKey.java
+++ b/accord-core/src/main/java/accord/impl/InMemorySafeCommandsForKey.java
@@ -20,8 +20,8 @@ package accord.impl;
 
 import accord.api.Key;
 import accord.impl.InMemoryCommandStore.GlobalCommandsForKey;
-import accord.local.CommandsForKey;
-import accord.local.SafeCommandsForKey;
+import accord.local.cfk.CommandsForKey;
+import accord.local.cfk.SafeCommandsForKey;
 
 public class InMemorySafeCommandsForKey extends SafeCommandsForKey
 {
@@ -35,7 +35,7 @@ public class InMemorySafeCommandsForKey extends SafeCommandsForKey
     }
 
     @Override
-    public accord.local.CommandsForKey current()
+    public CommandsForKey current()
     {
         return global.value();
     }
diff --git a/accord-core/src/main/java/accord/local/Command.java b/accord-core/src/main/java/accord/local/Command.java
index 51bd9410..d082b676 100644
--- a/accord-core/src/main/java/accord/local/Command.java
+++ b/accord-core/src/main/java/accord/local/Command.java
@@ -29,25 +29,29 @@ import accord.api.RoutingKey;
 import accord.api.VisibleForImplementation;
 import accord.local.Status.Durability;
 import accord.local.Status.Known;
+import accord.local.cfk.CommandsForKey;
 import accord.primitives.Ballot;
 import accord.primitives.Deps;
 import accord.primitives.FullRoute;
+import accord.primitives.KeyDeps;
 import accord.primitives.Keys;
 import accord.primitives.PartialDeps;
 import accord.primitives.PartialTxn;
 import accord.primitives.RangeDeps;
+import accord.primitives.Ranges;
 import accord.primitives.Route;
 import accord.primitives.Seekables;
 import accord.primitives.Timestamp;
 import accord.primitives.Txn;
 import accord.primitives.TxnId;
+import accord.primitives.Unseekables;
 import accord.primitives.Writes;
 import accord.utils.ImmutableBitSet;
 import accord.utils.IndexedQuadConsumer;
 import accord.utils.IndexedTriConsumer;
 import accord.utils.Invariants;
 import accord.utils.SimpleBitSet;
-import accord.utils.SortedArrays.SortedArrayList;
+
 import javax.annotation.Nullable;
 
 import static accord.local.Command.AbstractCommand.validate;
@@ -61,6 +65,8 @@ import static accord.local.Status.Invalidated;
 import static accord.local.Status.KnownExecuteAt.ExecuteAtKnown;
 import static accord.local.Status.Stable;
 import static accord.primitives.Routable.Domain.Range;
+import static accord.primitives.Routables.Slice.Minimal;
+import static accord.utils.Invariants.illegalArgument;
 import static accord.utils.Invariants.illegalState;
 import static accord.utils.SortedArrays.forEachIntersection;
 import static accord.utils.Utils.ensureImmutable;
@@ -236,13 +242,7 @@ public abstract class Command implements CommonAttributes
         }
     }
 
-    /**
-     * @return true if this command is equivalent to {@code other}; it could also be fuller - have some of its
-     *      registers be not sliced, whereas {@code other} may have them sliced
-     */
-    public abstract boolean isEqualOrFuller(Command other);
-
-    abstract static class AbstractCommand extends Command
+    public abstract static class AbstractCommand extends Command
     {
         private final TxnId txnId;
         private final SaveStatus status;
@@ -291,19 +291,6 @@ public abstract class Command implements CommonAttributes
                     && Objects.equals(durableListeners(), command.durableListeners());
         }
 
-        @Override
-        public boolean isEqualOrFuller(Command command)
-        {
-            if (this == command) return true;
-            if (command == null || getClass() != command.getClass()) return false;
-            return txnId().equals(command.txnId())
-                && saveStatus() == command.saveStatus()
-                && durability() == command.durability()
-                && Objects.equals(route(), command.route())
-                && Objects.equals(promised(), command.promised())
-                && durableListeners().containsAll(command.durableListeners());
-        }
-
         @Override
         public String toString()
         {
@@ -387,7 +374,7 @@ public abstract class Command implements CommonAttributes
                         break;
                     case ExecuteAtProposed:
                     case ExecuteAtKnown:
-                        Invariants.checkState(executeAt != null && !executeAt.equals(Timestamp.NONE));
+                        Invariants.checkState(executeAt != null && executeAt.compareTo(validate.txnId()) >= 0);
                         break;
                     case NoExecuteAt:
                         Invariants.checkState(executeAt.equals(Timestamp.NONE));
@@ -532,7 +519,7 @@ public abstract class Command implements CommonAttributes
     private static void checkSameClass(Command command, Class<? extends Command> klass, String errorMsg)
     {
         if (!isSameClass(command, klass))
-            throw new IllegalArgumentException(errorMsg + format(" expected %s got %s", klass.getSimpleName(), command.getClass().getSimpleName()));
+            throw illegalArgument(errorMsg + format(" expected %s got %s", klass.getSimpleName(), command.getClass().getSimpleName()));
     }
 
     public abstract Timestamp executeAt();
@@ -785,18 +772,6 @@ public abstract class Command implements CommonAttributes
                 && Objects.equals(result, that.result);
         }
 
-        @Override
-        public boolean isEqualOrFuller(Command c)
-        {
-            if (this == c) return true;
-            if (c == null || getClass() != c.getClass()) return false;
-            if (!super.isEqualOrFuller(c)) return false;
-            Truncated that = (Truncated) c;
-            return Objects.equals(executeAt(), that.executeAt())
-                && Objects.equals(writes(), that.writes())
-                && Objects.equals(result(), that.result());
-        }
-
         public static Truncated erased(Command command)
         {
             Durability durability = Durability.mergeAtLeast(command.durability(), UniversalOrInvalidated);
@@ -959,14 +934,6 @@ public abstract class Command implements CommonAttributes
             if (!super.equals(o)) return false;
             return Objects.equals(executesAtLeast, ((TruncatedAwaitsOnlyDeps)o).executesAtLeast);
         }
-
-        @Override
-        public boolean isEqualOrFuller(Command command)
-        {
-            if (!super.isEqualOrFuller(command)) return false;
-            return Objects.equals(executesAtLeast, ((TruncatedAwaitsOnlyDeps)command).executesAtLeast);
-        }
-
     }
 
     public static class PreAccepted extends AbstractCommand
@@ -1006,19 +973,6 @@ public abstract class Command implements CommonAttributes
                     && Objects.equals(partialDeps, that.partialDeps);
         }
 
-        @Override
-        public boolean isEqualOrFuller(Command c)
-        {
-            if (this == c) return true;
-            if (c == null || getClass() != c.getClass()) return false;
-            if (!super.isEqualOrFuller(c)) return false;
-            PreAccepted that = (PreAccepted) c;
-            if (!executeAt().equals(that.executeAt()) || !partialTxn().isEqualOrFuller(that.partialTxn()))
-                return false;
-            return (partialDeps() == null && that.partialDeps() == null)
-                || (partialDeps() != null && that.partialDeps() != null && partialDeps().isEqualOrFuller(that.partialDeps()));
-        }
-
         public static PreAccepted preAccepted(CommonAttributes common, Timestamp executeAt, Ballot promised)
         {
             return validate(new PreAccepted(common, SaveStatus.PreAccepted, promised, executeAt));
@@ -1097,16 +1051,6 @@ public abstract class Command implements CommonAttributes
             return Objects.equals(acceptedOrCommitted, that.acceptedOrCommitted);
         }
 
-        @Override
-        public boolean isEqualOrFuller(Command c)
-        {
-            if (this == c) return true;
-            if (c == null || getClass() != c.getClass()) return false;
-            if (!super.isEqualOrFuller(c)) return false;
-            Accepted that = (Accepted) c;
-            return Objects.equals(acceptedOrCommitted(), that.acceptedOrCommitted());
-        }
-
         public static Accepted accepted(CommonAttributes common, SaveStatus status, Timestamp executeAt, Ballot promised, Ballot accepted)
         {
             return validate(new Accepted(common, status, promised, executeAt, accepted));
@@ -1140,7 +1084,6 @@ public abstract class Command implements CommonAttributes
             super(common, status, promised, executeAt, accepted);
             this.waitingOn = waitingOn;
             Invariants.checkState(common.route().kind().isFullRoute(), "Expected a full route but given %s", common.route().kind());
-            if (status.hasBeen(Stable)) Invariants.checkState(waitingOn == WaitingOn.EMPTY || (waitingOn.txnIds.equals(common.partialDeps().rangeDeps.txnIds()) && waitingOn.keys.equals(common.partialDeps().keyDeps.keys())), "Deps do not match; expected (%s, %s) == (%s, %s)", waitingOn.keys, waitingOn.txnIds, common.partialDeps().keyDeps.keys(), common.partialDeps().rangeDeps.txnIds());
         }
 
         @Override
@@ -1167,16 +1110,6 @@ public abstract class Command implements CommonAttributes
             return Objects.equals(waitingOn, committed.waitingOn);
         }
 
-        @Override
-        public boolean isEqualOrFuller(Command c)
-        {
-            if (this == c) return true;
-            if (c == null || getClass() != c.getClass()) return false;
-            // TODO (required): revisit this method to make sure it is correct in context of usage
-            //   but checking fullness of WaitingOn doesn't make much sense
-            return super.isEqualOrFuller(c);
-        }
-
         private static Committed committed(Committed command, CommonAttributes common, Ballot promised, SaveStatus status, WaitingOn waitingOn)
         {
             checkPromised(command, promised);
@@ -1194,7 +1127,7 @@ public abstract class Command implements CommonAttributes
             return committed(command, common, command.promised(), status, command.waitingOn());
         }
 
-        static Committed committed(Committed command, CommonAttributes common, WaitingOn waitingOn)
+        public static Committed committed(Committed command, CommonAttributes common, WaitingOn waitingOn)
         {
             return committed(command, common, command.promised(), command.saveStatus(), waitingOn);
         }
@@ -1245,17 +1178,6 @@ public abstract class Command implements CommonAttributes
                 && Objects.equals(result, executed.result);
         }
 
-        @Override
-        public boolean isEqualOrFuller(Command c)
-        {
-            if (this == c) return true;
-            if (c == null || getClass() != c.getClass()) return false;
-            if (!super.isEqualOrFuller(c)) return false;
-            Executed executed = (Executed) c;
-            return Objects.equals(writes(), executed.writes())
-                && Objects.equals(result(), executed.result());
-        }
-
         public static Executed executed(Executed command, CommonAttributes common, SaveStatus status, Ballot promised, WaitingOn waitingOn)
         {
             checkSameClass(command, Executed.class, "Cannot update");
@@ -1295,27 +1217,61 @@ public abstract class Command implements CommonAttributes
 
     public static class WaitingOn
     {
-        public static final WaitingOn EMPTY = new WaitingOn(Keys.EMPTY, RangeDeps.NONE.txnIds(), ImmutableBitSet.EMPTY, ImmutableBitSet.EMPTY);
+        public static final WaitingOn EMPTY = new WaitingOn(Keys.EMPTY, RangeDeps.NONE, KeyDeps.NONE, ImmutableBitSet.EMPTY, ImmutableBitSet.EMPTY);
 
         public final Keys keys;
-        public final SortedArrayList<TxnId> txnIds;
+        public final RangeDeps directRangeDeps;
+        public final KeyDeps directKeyDeps;
         // waitingOn ONLY encodes both txnIds and keys
         public final ImmutableBitSet waitingOn;
         public final @Nullable ImmutableBitSet appliedOrInvalidated;
 
         public WaitingOn(WaitingOn copy)
         {
-            this(copy.keys, copy.txnIds, copy.waitingOn, copy.appliedOrInvalidated);
+            this(copy.keys, copy.directRangeDeps, copy.directKeyDeps, copy.waitingOn, copy.appliedOrInvalidated);
         }
 
-        public WaitingOn(Keys keys, SortedArrayList<TxnId> txnIds, ImmutableBitSet waitingOn, ImmutableBitSet appliedOrInvalidated)
+        public WaitingOn(Keys keys, RangeDeps directRangeDeps, KeyDeps directKeyDeps, ImmutableBitSet waitingOn, ImmutableBitSet appliedOrInvalidated)
         {
             this.keys = keys;
-            this.txnIds = txnIds;
+            this.directRangeDeps = directRangeDeps;
+            this.directKeyDeps = directKeyDeps;
             this.waitingOn = waitingOn;
             this.appliedOrInvalidated = appliedOrInvalidated;
         }
 
+        public int txnIdCount()
+        {
+            return directRangeDeps.txnIdCount() + directKeyDeps.txnIdCount();
+        }
+
+        TxnId txnId(int i)
+        {
+            int ki = i - directRangeDeps.txnIdCount();
+            if (ki < 0)
+                return directRangeDeps.txnId(i);
+
+            if (ki < directKeyDeps.txnIdCount())
+                return directKeyDeps.txnId(ki);
+
+            throw new IndexOutOfBoundsException(i + " >= " + txnIdCount());
+        }
+
+        int indexOf(TxnId txnId)
+        {
+            if (txnId.domain() == Range)
+                return directRangeDeps.indexOf(txnId);
+
+            if (!CommandsForKey.managesExecution(txnId))
+            {
+                int i = directKeyDeps.indexOf(txnId);
+                int offset = directRangeDeps.txnIdCount();
+                return i < 0 ? i - offset : i + offset;
+            }
+
+            throw new IllegalArgumentException("WaitingOn does not track this kind of TxnId: " + txnId);
+        }
+
         public Timestamp executeAtLeast()
         {
             return null;
@@ -1328,8 +1284,8 @@ public abstract class Command implements CommonAttributes
 
         public static WaitingOn none(Deps deps)
         {
-            ImmutableBitSet empty = new ImmutableBitSet(deps.txnIdCount() + deps.keyDeps.keys().size());
-            return new WaitingOn(deps.keyDeps.keys(), deps.rangeDeps.txnIds(), empty, empty);
+            ImmutableBitSet empty = new ImmutableBitSet(deps.rangeDeps.txnIdCount() + deps.directKeyDeps.txnIdCount() + deps.keyDeps.keys().size());
+            return new WaitingOn(deps.keyDeps.keys(), deps.rangeDeps, deps.directKeyDeps, empty, empty);
         }
 
         public boolean isWaiting()
@@ -1339,54 +1295,63 @@ public abstract class Command implements CommonAttributes
 
         public boolean isWaitingOnKey()
         {
-            return waitingOn.lastSetBit() >= txnIds.size();
+            return waitingOn.lastSetBit() >= txnIdCount();
         }
 
         public boolean isWaitingOnKey(int keyIndex)
         {
-            return waitingOn.get(txnIds.size() + keyIndex);
+            Invariants.checkIndex(keyIndex, keys.size());
+            return waitingOn.get(txnIdCount() + keyIndex);
         }
 
         public boolean isWaitingOnCommand()
         {
-            return waitingOn.firstSetBit() < txnIds.size();
+            return waitingOn.firstSetBit() < txnIdCount();
         }
 
         public boolean isWaitingOn(TxnId txnId)
         {
-            int index = txnIds.indexOf(txnId);
+            int index = indexOf(txnId);
             return index >= 0 && waitingOn.get(index);
         }
 
         public TxnId nextWaitingOn()
         {
-            int i = waitingOn.prevSetBit(txnIds.size());
-            return i < 0 ? null : txnIds.get(i);
+            int i = nextWaitingOnIndex();
+            return i < 0 ? null : txnId(i);
         }
 
         private int nextWaitingOnIndex()
         {
-            return waitingOn.prevSetBit(txnIds.size());
+            int directRangeTxnIdCount = directRangeDeps.txnIdCount();
+            int nextWaitingOnDirectRangeIndex = waitingOn.prevSetBit(directRangeTxnIdCount);
+            if (directKeyDeps == KeyDeps.NONE)
+                return nextWaitingOnDirectRangeIndex;
+
+            int directKeyTxnIdCount = directKeyDeps.txnIdCount();
+            int txnIdCount = directKeyTxnIdCount + directRangeTxnIdCount;
+            int nextWaitingOnDirectKeyIndex = waitingOn.prevSetBitNotBefore(txnIdCount, directRangeTxnIdCount);
+            if (nextWaitingOnDirectKeyIndex < 0)
+                return nextWaitingOnDirectRangeIndex;
+            if (nextWaitingOnDirectRangeIndex < 0)
+                return nextWaitingOnDirectKeyIndex;
+            int c = directRangeDeps.txnId(nextWaitingOnDirectRangeIndex).compareTo(directKeyDeps.txnId(nextWaitingOnDirectKeyIndex - directRangeDeps.txnIdCount()));
+            return c > 0 ? nextWaitingOnDirectRangeIndex : nextWaitingOnDirectKeyIndex;
         }
 
-        private static String toString(Keys keys, SortedArrayList<TxnId> txnIds, SimpleBitSet waitingOn)
+        @Override
+        public String toString()
         {
             List<TxnId> waitingOnTxnIds = new ArrayList<>();
             List<Key> waitingOnKeys = new ArrayList<>();
-            waitingOn.reverseForEach(waitingOnTxnIds, waitingOnKeys, txnIds, keys, (outIds, outKeys, ids, ks, i) -> {
-                if (i < ids.size()) outIds.add(ids.get(i));
-                else outKeys.add(ks.get(i - ids.size()));
+            waitingOn.reverseForEach(waitingOnTxnIds, waitingOnKeys, this, keys, (outIds, outKeys, self, ks, i) -> {
+                if (i < self.txnIdCount()) outIds.add(self.txnId(i));
+                else outKeys.add(ks.get(i - self.txnIdCount()));
             });
             Collections.reverse(waitingOnKeys);
             return "keys=" + waitingOnKeys + ", txnIds=" + waitingOnTxnIds;
         }
 
-        @Override
-        public String toString()
-        {
-            return toString(keys, txnIds, waitingOn);
-        }
-
         @Override
         public boolean equals(Object other)
         {
@@ -1396,7 +1361,8 @@ public abstract class Command implements CommonAttributes
         boolean equals(WaitingOn other)
         {
             return this.keys.equals(other.keys)
-                && txnIds.equals(other.txnIds)
+                && directKeyDeps.equals(other.directKeyDeps)
+                && directRangeDeps.equals(other.directRangeDeps)
                 && this.waitingOn.equals(other.waitingOn)
                 && Objects.equals(this.appliedOrInvalidated, other.appliedOrInvalidated);
         }
@@ -1404,7 +1370,8 @@ public abstract class Command implements CommonAttributes
         public static class Update
         {
             final Keys keys;
-            final SortedArrayList<TxnId> txnIds;
+            final RangeDeps directRangeDeps;
+            final KeyDeps directKeyDeps;
             private SimpleBitSet waitingOn;
             private @Nullable SimpleBitSet appliedOrInvalidated;
             private Timestamp executeAtLeast;
@@ -1412,7 +1379,8 @@ public abstract class Command implements CommonAttributes
             public Update(WaitingOn waitingOn)
             {
                 this.keys = waitingOn.keys;
-                this.txnIds = waitingOn.txnIds;
+                this.directRangeDeps = waitingOn.directRangeDeps;
+                this.directKeyDeps = waitingOn.directKeyDeps;
                 this.waitingOn = waitingOn.waitingOn;
                 this.appliedOrInvalidated = waitingOn.appliedOrInvalidated;
                 if (waitingOn.getClass() == WaitingOnWithExecuteAt.class)
@@ -1424,17 +1392,28 @@ public abstract class Command implements CommonAttributes
                 this(committed.waitingOn);
             }
 
-            public Update(TxnId txnId, Deps deps)
+            private Update(TxnId txnId, Keys keys, RangeDeps directRangeDeps, KeyDeps directKeyDeps)
             {
-                this(txnId, deps.keyDeps.keys(), deps.rangeDeps.txnIds());
+                this.keys = keys;
+                this.directRangeDeps = directRangeDeps;
+                this.directKeyDeps = directKeyDeps;
+                this.waitingOn = new SimpleBitSet(txnIdCount() + keys.size(), false);
+                this.appliedOrInvalidated = CommandsForKey.managesExecution(txnId) ? null : new SimpleBitSet(txnIdCount(), false);
             }
 
-            public Update(TxnId txnId, Keys keys, SortedArrayList<TxnId> txnIds)
+            public static Update initialise(TxnId txnId, Route route, Ranges ranges, Deps deps)
             {
-                this.keys = keys;
-                this.txnIds = txnIds;
-                this.waitingOn = new SimpleBitSet(txnIds.size() + keys.size(), false);
-                this.appliedOrInvalidated = txnId.domain() == Range ? new SimpleBitSet(txnIds.size(), false) : null;
+                Unseekables<?> executionParticipants = route.participants().slice(ranges, Minimal);
+                Update update = new Update(txnId, deps.keyDeps.keys(), deps.rangeDeps, deps.directKeyDeps);
+                deps.rangeDeps.forEach(executionParticipants, update, Update::initialise);
+                deps.directKeyDeps.forEach(ranges, 0, deps.directKeyDeps.txnIdCount(), update, deps.rangeDeps, (upd, rdeps, index) -> upd.initialise(index + rdeps.txnIdCount()));
+                deps.keyDeps.keys().forEach(ranges, (upd, key, index) -> upd.initialise(index + upd.txnIdCount()), update);
+                return update;
+            }
+
+            private void initialise(int i)
+            {
+                waitingOn.set(i);
             }
 
             public boolean hasChanges()
@@ -1443,9 +1422,33 @@ public abstract class Command implements CommonAttributes
                        || (appliedOrInvalidated != null && !(appliedOrInvalidated instanceof ImmutableBitSet));
             }
 
+            public TxnId txnId(int i)
+            {
+                int ki = i - directRangeDeps.txnIdCount();
+                if (ki < 0)
+                    return directRangeDeps.txnId(i);
+                if (ki < directKeyDeps.txnIdCount())
+                    return directKeyDeps.txnId(ki);
+                throw new IndexOutOfBoundsException(i + " >= " + txnIdCount());
+            }
+
+            public int indexOf(TxnId txnId)
+            {
+                if (txnId.domain() == Range)
+                    return directRangeDeps.indexOf(txnId);
+                if (!CommandsForKey.managesExecution(txnId))
+                    return directRangeDeps.txnIdCount() + directKeyDeps.indexOf(txnId);
+                throw new IllegalArgumentException("WaitingOn does not track this kind of TxnId: " + txnId);
+            }
+
+            public int txnIdCount()
+            {
+                return directRangeDeps.txnIdCount() + directKeyDeps.txnIdCount();
+            }
+
             public boolean removeWaitingOn(TxnId txnId)
             {
-                int index = txnIds.indexOf(txnId);
+                int index = indexOf(txnId);
                 if (!waitingOn.get(index))
                     return false;
 
@@ -1456,7 +1459,7 @@ public abstract class Command implements CommonAttributes
 
             public boolean isWaitingOn(TxnId txnId)
             {
-                int index = txnIds.indexOf(txnId);
+                int index = indexOf(txnId);
                 return index >= 0 && waitingOn.get(index);
             }
 
@@ -1473,7 +1476,7 @@ public abstract class Command implements CommonAttributes
                 // TODO (expected): simply remove ourselves from any CFK we aren't waiting on
                 if (index < 0) return false;
 
-                index += txnIds.size();
+                index += txnIdCount();
                 if (!waitingOn.get(index))
                     return false;
 
@@ -1484,15 +1487,10 @@ public abstract class Command implements CommonAttributes
 
             public boolean isWaitingOn(Key key)
             {
-                int index = keys.indexOf(key) + txnIds.size();
+                int index = keys.indexOf(key) + txnIdCount();
                 return index >= 0 && waitingOn.get(index);
             }
 
-            void initialiseWaiting(int index)
-            {
-                waitingOn.set(index);
-            }
-
             public boolean isWaiting()
             {
                 return !waitingOn.isEmpty();
@@ -1500,13 +1498,38 @@ public abstract class Command implements CommonAttributes
 
             public TxnId minWaitingOnTxnId()
             {
-                int index = waitingOn.firstSetBit();
-                return index < 0 || index >= txnIds.size() ? null : txnIds.get(index);
+                int index = minWaitingOnTxnIdx();
+                return index < 0 ? null : txnId(index);
+            }
+
+            public int minWaitingOnTxnIdx()
+            {
+                int directRangeTxnIdCount = directRangeDeps.txnIdCount();
+                int minWaitingOnDirectRangeIndex = waitingOn.nextSetBitBefore(0, directRangeTxnIdCount);
+                if (directKeyDeps == KeyDeps.NONE)
+                    return minWaitingOnDirectRangeIndex;
+
+                int directKeyTxnIdCount = directKeyDeps.txnIdCount();
+                int txnIdCount = directKeyTxnIdCount + directRangeTxnIdCount;
+                int minWaitingOnDirectKeyIndex = waitingOn.nextSetBitBefore(directRangeTxnIdCount, txnIdCount);
+                if (minWaitingOnDirectKeyIndex < 0)
+                    return minWaitingOnDirectRangeIndex;
+                if (minWaitingOnDirectRangeIndex < 0)
+                    return minWaitingOnDirectKeyIndex;
+                int c = directRangeDeps.txnId(minWaitingOnDirectRangeIndex).compareTo(directKeyDeps.txnId(minWaitingOnDirectKeyIndex - directRangeDeps.txnIdCount()));
+                return c < 0 ? minWaitingOnDirectRangeIndex : minWaitingOnDirectKeyIndex;
+            }
+
+            public boolean isWaitingOnDirectRangeTxnIdx(int idx)
+            {
+                Invariants.checkIndex(idx, directRangeDeps.txnIdCount());
+                return waitingOn.get(idx);
             }
 
-            public boolean isWaitingOn(int txnIdx)
+            public boolean isWaitingOnDirectKeyTxnIdx(int idx)
             {
-                return waitingOn.get(txnIdx);
+                Invariants.checkIndex(idx, directKeyDeps.txnIdCount());
+                return waitingOn.get(idx + directRangeDeps.txnIdCount());
             }
 
             public void updateExecuteAtLeast(Timestamp executeAtLeast)
@@ -1514,17 +1537,25 @@ public abstract class Command implements CommonAttributes
                 this.executeAtLeast = Timestamp.nonNullOrMax(executeAtLeast, this.executeAtLeast);
             }
 
-            boolean removeWaitingOnTxnId(int i)
+            boolean removeWaitingOnDirectRangeTxnId(int i)
             {
+                Invariants.checkIndex(i, directRangeDeps.txnIdCount());
                 return removeWaitingOn(i);
             }
 
+            boolean removeWaitingOnDirectKeyTxnId(int i)
+            {
+                Invariants.checkIndex(i, directKeyDeps.txnIdCount());
+                return removeWaitingOn(i + directRangeDeps.txnIdCount());
+            }
+
             boolean removeWaitingOnKey(int i)
             {
-                return removeWaitingOn(txnIds.size() + i);
+                Invariants.checkIndex(i, keys.size());
+                return removeWaitingOn(txnIdCount() + i);
             }
 
-            boolean removeWaitingOn(int i)
+            private boolean removeWaitingOn(int i)
             {
                 if (waitingOn.get(i))
                 {
@@ -1547,11 +1578,23 @@ public abstract class Command implements CommonAttributes
              */
             public boolean setAppliedOrInvalidated(TxnId txnId)
             {
-                int index = txnIds.indexOf(txnId);
+                int index = indexOf(txnId);
                 return setAppliedOrInvalidated(index);
             }
 
-            public boolean setAppliedOrInvalidated(int i)
+            private boolean setAppliedOrInvalidatedDirectRangeTxn(int i)
+            {
+                Invariants.checkIndex(i, directRangeDeps.txnIdCount());
+                return setAppliedOrInvalidated(i);
+            }
+
+            private boolean setAppliedOrInvalidatedDirectKeyTxn(int i)
+            {
+                Invariants.checkIndex(i, directKeyDeps.txnIdCount());
+                return setAppliedOrInvalidated(i + directRangeDeps.txnIdCount());
+            }
+
+            private boolean setAppliedOrInvalidated(int i)
             {
                 if (appliedOrInvalidated == null)
                     return removeWaitingOn(i);
@@ -1569,17 +1612,26 @@ public abstract class Command implements CommonAttributes
 
             public boolean setAppliedAndPropagate(TxnId txnId, WaitingOn propagate)
             {
-                int index = txnIds.indexOf(txnId);
+                int index = indexOf(txnId);
                 if (!setAppliedOrInvalidated(index))
                     return false;
 
                 if (propagate.appliedOrInvalidated != null && !propagate.appliedOrInvalidated.isEmpty())
                 {
-                    forEachIntersection(propagate.txnIds, txnIds,
+                    forEachIntersection(propagate.directRangeDeps.txnIds(), directRangeDeps.txnIds(),
                                         (from, to, ignore, i1, i2) -> {
                                             if (from.get(i1))
-                                                to.setAppliedOrInvalidated(i2);
+                                                to.setAppliedOrInvalidatedDirectRangeTxn(i2);
                                         }, propagate.appliedOrInvalidated, this, null);
+
+                    if (propagate.directKeyDeps != KeyDeps.NONE)
+                    {
+                        forEachIntersection(propagate.directKeyDeps.txnIds(), directKeyDeps.txnIds(),
+                                            (from, to, ignore, i1, i2) -> {
+                                                if (from.get(i1))
+                                                    to.setAppliedOrInvalidatedDirectKeyTxn(i2);
+                                            }, propagate.appliedOrInvalidated, this, null);
+                    }
                 }
 
                 return true;
@@ -1587,17 +1639,17 @@ public abstract class Command implements CommonAttributes
 
             public <P1, P2, P3, P4> void forEachWaitingOnId(P1 p1, P2 p2, P3 p3, P4 p4, IndexedQuadConsumer<P1, P2, P3, P4> forEach)
             {
-                waitingOn.reverseForEach(0, txnIds.size(), p1, p2, p3, p4, forEach);
+                waitingOn.reverseForEach(0, txnIdCount(), p1, p2, p3, p4, forEach);
             }
 
             public <P1, P2, P3, P4> void forEachWaitingOnKey(P1 p1, P2 p2, P3 p3, IndexedTriConsumer<P1, P2, P3> forEach)
             {
-                waitingOn.reverseForEach(txnIds.size(), keys.size(), p1, p2, p3, txnIds, (pp1, pp2, pp3, pp4, i) -> forEach.accept(pp1, pp2, pp3, i - pp4.size()));
+                waitingOn.reverseForEach(txnIdCount(), keys.size(), p1, p2, p3, this, (pp1, pp2, pp3, pp4, i) -> forEach.accept(pp1, pp2, pp3, i - pp4.txnIdCount()));
             }
 
             public WaitingOn build()
             {
-                WaitingOn result = new WaitingOn(keys, txnIds, ensureImmutable(waitingOn), ensureImmutable(appliedOrInvalidated));
+                WaitingOn result = new WaitingOn(keys, directRangeDeps, directKeyDeps, ensureImmutable(waitingOn), ensureImmutable(appliedOrInvalidated));
                 if (executeAtLeast == null)
                     return result;
                 return new WaitingOnWithExecuteAt(result, executeAtLeast);
@@ -1606,7 +1658,14 @@ public abstract class Command implements CommonAttributes
             @Override
             public String toString()
             {
-                return WaitingOn.toString(keys, txnIds, waitingOn);
+                List<TxnId> waitingOnTxnIds = new ArrayList<>();
+                List<Key> waitingOnKeys = new ArrayList<>();
+                waitingOn.reverseForEach(waitingOnTxnIds, waitingOnKeys, this, keys, (outIds, outKeys, self, ks, i) -> {
+                    if (i < self.txnIdCount()) outIds.add(self.txnId(i));
+                    else outKeys.add(ks.get(i - self.txnIdCount()));
+                });
+                Collections.reverse(waitingOnKeys);
+                return "keys=" + waitingOnKeys + ", txnIds=" + waitingOnTxnIds;
             }
         }
     }
@@ -1640,7 +1699,7 @@ public abstract class Command implements CommonAttributes
 
         boolean equals(WaitingOnWithExecuteAt other)
         {
-            return super.equals((WaitingOn) other) && executeAtLeast == other.executeAtLeast;
+            return super.equals(other) && executeAtLeast == other.executeAtLeast;
         }
     }
 
diff --git a/accord-core/src/main/java/accord/local/CommandStore.java b/accord-core/src/main/java/accord/local/CommandStore.java
index c7baf2f5..cdb8a467 100644
--- a/accord-core/src/main/java/accord/local/CommandStore.java
+++ b/accord-core/src/main/java/accord/local/CommandStore.java
@@ -28,7 +28,8 @@ import javax.annotation.Nullable;
 import accord.api.Agent;
 
 import accord.local.CommandStores.RangesForEpoch;
-import accord.primitives.Deps;
+import accord.primitives.KeyDeps;
+import accord.primitives.Keys;
 import accord.primitives.Range;
 import accord.primitives.Routables;
 import accord.utils.async.AsyncChain;
@@ -64,6 +65,7 @@ import accord.primitives.Timestamp;
 import accord.primitives.TxnId;
 import accord.primitives.Unseekables;
 import accord.utils.async.AsyncResults;
+import org.agrona.collections.Int2ObjectHashMap;
 
 import static accord.api.ConfigurationService.EpochReady.DONE;
 import static accord.local.KeyHistory.COMMANDS;
@@ -382,9 +384,7 @@ public abstract class CommandStore implements AgentExecutor
     @Override
     public String toString()
     {
-        return getClass().getSimpleName() + "{" +
-               "id=" + id +
-               '}';
+        return getClass().getSimpleName() + "{id=" + id + ", node=" + time.id().id + '}';
     }
 
     @Nullable
@@ -594,8 +594,68 @@ public abstract class CommandStore implements AgentExecutor
         return null != rejectBefore.foldl(participants, (rejectIfBefore, test) -> rejectIfBefore.compareTo(test) > 0 ? null : test, txnId, Objects::isNull);
     }
 
-    public final void removeRedundantDependencies(Unseekables<?> participants, Deps deps, WaitingOn.Update builder)
+    public final void removeRedundantDependencies(Unseekables<?> participants, WaitingOn.Update builder)
     {
+        // Note: we do not need to track the bootstraps we implicitly depend upon, because we will not serve any read requests until this has completed
+        //  and since we are a timestamp store, and we write only this will sort itself out naturally
+        // TODO (required): make sure we have no races on HLC around SyncPoint else this resolution may not work (we need to know the micros equivalent timestamp of the snapshot)
+        class KeyState
+        {
+            Int2ObjectHashMap<Keys> partiallyBootstrapping;
+
+            /**
+             * Are the participating ranges for the txn fully covered by bootstrapping ranges for this command store
+             */
+            boolean isFullyBootstrapping(WaitingOn.Update builder, Range range, int txnIdx)
+            {
+                if (builder.directKeyDeps.foldEachKey(txnIdx, range, true, (r0, k, p) -> p && r0.contains(k)))
+                    return true;
+
+                if (partiallyBootstrapping == null)
+                    partiallyBootstrapping = new Int2ObjectHashMap<>();
+                Keys prev = partiallyBootstrapping.get(txnIdx);
+                Keys remaining = prev;
+                if (remaining == null) remaining = builder.directKeyDeps.participatingKeys(txnIdx);
+                else Invariants.checkState(!remaining.isEmpty());
+                remaining = remaining.subtract(range);
+                if (prev == null) Invariants.checkState(!remaining.isEmpty());
+                partiallyBootstrapping.put(txnIdx, remaining);
+                return remaining.isEmpty();
+            }
+        }
+
+        KeyDeps directKeyDeps = builder.directKeyDeps;
+        if (!directKeyDeps.isEmpty())
+        {
+            redundantBefore().foldl(directKeyDeps.keys(), (e, s, d, b) -> {
+                // TODO (desired, efficiency): foldlInt so we can track the lower rangeidx bound and not revisit unnecessarily
+                // find the txnIdx below which we are known to be fully redundant locally due to having been applied or invalidated
+                int bootstrapIdx = d.txnIds().find(e.bootstrappedAt);
+                if (bootstrapIdx < 0) bootstrapIdx = -1 - bootstrapIdx;
+                int appliedIdx = d.txnIds().find(e.locallyAppliedOrInvalidatedBefore);
+                if (appliedIdx < 0) appliedIdx = -1 - appliedIdx;
+
+                // remove intersecting transactions with known redundant txnId
+                // note that we must exclude all transactions that are pre-bootstrap, and perform the more complicated dance below,
+                // as these transactions may be only partially applied, and we may need to wait for them on another key.
+                if (appliedIdx > bootstrapIdx)
+                {
+                    d.forEach(e.range, bootstrapIdx, appliedIdx, b, s, (b0, s0, txnIdx) -> {
+                        b0.removeWaitingOnDirectKeyTxnId(txnIdx);
+                    });
+                }
+
+                if (bootstrapIdx > 0)
+                {
+                    d.forEach(e.range, 0, bootstrapIdx, b, s, e.range, (b0, s0, r, txnIdx) -> {
+                        if (b0.isWaitingOnDirectKeyTxnIdx(txnIdx) && s0.isFullyBootstrapping(b0, r, txnIdx))
+                            b0.removeWaitingOnDirectKeyTxnId(txnIdx);
+                    });
+                }
+                return s;
+            }, new KeyState(), directKeyDeps, builder, ignore -> false);
+        }
+
         /**
          * If we have to handle bootstrapping ranges for range transactions, these may only partially cover the
          * transaction, in which case we should not remove the transaction as a dependency. But if it is fully
@@ -613,14 +673,14 @@ public abstract class CommandStore implements AgentExecutor
             boolean isFullyBootstrapping(int rangeTxnIdx)
             {
                 // if all deps for the txnIdx are contained in the range, don't inflate any shared object state
-                if (deps.rangeDeps.foldEachRange(rangeTxnIdx, range, true, (r1, r2, p) -> p && r1.contains(r2)))
+                if (builder.directRangeDeps.foldEachRange(rangeTxnIdx, range, true, (r1, r2, p) -> p && r1.contains(r2)))
                     return true;
 
                 if (partiallyBootstrapping == null)
                     partiallyBootstrapping = new HashMap<>();
                 Ranges prev = partiallyBootstrapping.get(rangeTxnIdx);
                 Ranges remaining = prev;
-                if (remaining == null) remaining = deps.rangeDeps.ranges(rangeTxnIdx);
+                if (remaining == null) remaining = builder.directRangeDeps.ranges(rangeTxnIdx);
                 else Invariants.checkState(!remaining.isEmpty());
                 remaining = remaining.subtract(Ranges.of(range));
                 if (prev == null) Invariants.checkState(!remaining.isEmpty());
@@ -628,7 +688,8 @@ public abstract class CommandStore implements AgentExecutor
                 return remaining.isEmpty();
             }
         }
-        RangeDeps rangeDeps = deps.rangeDeps;
+
+        RangeDeps rangeDeps = builder.directRangeDeps;
         // TODO (required, consider): slice to only those ranges we own, maybe don't even construct rangeDeps.covering()
         redundantBefore().foldl(participants, (e, s, d, b) -> {
             int bootstrapIdx = d.txnIds().find(e.bootstrappedAt);
@@ -646,7 +707,7 @@ public abstract class CommandStore implements AgentExecutor
                 // TODO (desired): move the bounds check into forEach, matching structure used for keys
                 d.forEach(e.range, b, s, (b0, s0, txnIdx) -> {
                     if (txnIdx >= s0.bootstrapIdx && txnIdx < s0.appliedIdx)
-                        b0.removeWaitingOn(txnIdx);
+                        b0.removeWaitingOnDirectRangeTxnId(txnIdx);
                 });
             }
 
@@ -657,8 +718,8 @@ public abstract class CommandStore implements AgentExecutor
                 s.range = e.range;
                 // TODO (desired): move the bounds check into forEach, matching structure used for keys
                 d.forEach(e.range, b, s, (b0, s0, txnIdx) -> {
-                    if (txnIdx < s0.bootstrapIdx && b0.isWaitingOn(txnIdx) && s0.isFullyBootstrapping(txnIdx))
-                        b0.removeWaitingOn(txnIdx);
+                    if (txnIdx < s0.bootstrapIdx && b0.isWaitingOnDirectRangeTxnIdx(txnIdx) && s0.isFullyBootstrapping(txnIdx))
+                        b0.removeWaitingOnDirectRangeTxnId(txnIdx);
                 });
             }
             return s;
diff --git a/accord-core/src/main/java/accord/local/Commands.java b/accord-core/src/main/java/accord/local/Commands.java
index 42dcb371..ecb0d6da 100644
--- a/accord-core/src/main/java/accord/local/Commands.java
+++ b/accord-core/src/main/java/accord/local/Commands.java
@@ -342,7 +342,6 @@ public class Commands
         if (!validate(command.status(), command, coordinateRanges, acceptRanges, shard, route, Add, partialTxn, Add, partialDeps, Set))
             return CommitOutcome.Insufficient;
 
-        // FIXME: split up set
         CommonAttributes attrs = set(command, coordinateRanges, acceptRanges, shard, route, partialTxn, Add, partialDeps, Set);
 
         logger.trace("{}: committed with executeAt: {}, deps: {}", txnId, executeAt, partialDeps);
@@ -648,12 +647,6 @@ public class Commands
     }
 
     private static boolean maybeExecute(SafeCommandStore safeStore, SafeCommand safeCommand, boolean alwaysNotifyListeners, boolean notifyWaitingOn)
-    {
-        return maybeExecute(safeStore, safeCommand, alwaysNotifyListeners, notifyWaitingOn, false);
-    }
-
-    // TODO (expected, API consistency): maybe split into maybeExecute and maybeApply?
-    private static boolean maybeExecute(SafeCommandStore safeStore, SafeCommand safeCommand, boolean alwaysNotifyListeners, boolean notifyWaitingOn, boolean executeAsync)
     {
         Command command = safeCommand.current();
         if (logger.isTraceEnabled())
@@ -677,15 +670,6 @@ public class Commands
             return false;
         }
 
-        if (executeAsync)
-        {
-            TxnId txnId = command.txnId();
-            safeStore.commandStore().execute(contextFor(command.txnId(), command.keysOrRanges(), KeyHistory.COMMANDS), safeStore0 -> {
-                maybeExecute(safeStore0, safeStore0.get(txnId), alwaysNotifyListeners, notifyWaitingOn, false);
-            }).begin(safeStore.agent());
-            return false;
-        }
-
         // TODO (required): slice our execute ranges based on any pre-bootstrap state
         // FIXME: need to communicate to caller that we didn't execute if we take one of the above paths
 
@@ -706,6 +690,7 @@ public class Commands
 
                 if (intersects)
                 {
+                    // TODO (now): we should set applying within apply to avoid applying multiple times
                     safeCommand.applying(safeStore);
                     safeStore.notifyListeners(safeCommand);
                     logger.trace("{}: applying", command.txnId());
@@ -739,16 +724,8 @@ public class Commands
 
         Ranges ranges = safeStore.ranges().allAt(waitingExecuteAt);
         PartialDeps deps = waiting.partialDeps();
-        WaitingOn.Update update = new WaitingOn.Update(waitingId, deps);
-        // we select range deps on actual participants rather than covered ranges,
-        // since we may otherwise adopt false dependencies for range txns
-
-        Unseekables<?> executionParticipants = route.participants().slice(ranges, Minimal);
-        deps.rangeDeps.forEach(executionParticipants, update, WaitingOn.Update::initialiseWaiting);
-        deps.keyDeps.keys().foldl(ranges, (u, rd, k, v, i) -> {
-            u.initialiseWaiting(rd.txnIdCount() + i);
-            return null;
-        }, update, deps.rangeDeps, null);
+
+        WaitingOn.Update update = WaitingOn.Update.initialise(waitingId, route, ranges, deps);
         return updateWaitingOn(safeStore, waiting, waitingExecuteAt, update, route.participants()).build();
     }
 
@@ -757,10 +734,10 @@ public class Commands
         CommandStore commandStore = safeStore.commandStore();
         TxnId minWaitingOnTxnId = update.minWaitingOnTxnId();
         if (minWaitingOnTxnId != null && commandStore.hasLocallyRedundantDependencies(update.minWaitingOnTxnId(), executeAt, participants))
-            safeStore.commandStore().removeRedundantDependencies(participants, waiting.partialDeps(), update);
+            safeStore.commandStore().removeRedundantDependencies(participants, update);
 
         update.forEachWaitingOnId(safeStore, update, waiting, executeAt, (store, upd, w, exec, i) -> {
-            SafeCommand dep = store.ifLoadedAndInitialised(upd.txnIds.get(i));
+            SafeCommand dep = store.ifLoadedAndInitialised(upd.txnId(i));
             if (dep == null || !dep.current().hasBeen(PreCommitted))
                 return;
             updateWaitingOn(store, w, exec, upd, dep);
@@ -856,7 +833,7 @@ public class Commands
         }
     }
 
-    static void removeWaitingOnKeyAndMaybeExecute(SafeCommandStore safeStore, SafeCommand safeCommand, Key key, boolean executeAsync)
+    public static void removeWaitingOnKeyAndMaybeExecute(SafeCommandStore safeStore, SafeCommand safeCommand, Key key)
     {
         if (safeCommand.current().hasBeen(Applied))
             return;
@@ -872,7 +849,7 @@ public class Commands
         waitingOn.removeWaitingOnKey(keyIndex);
         safeCommand.updateWaitingOn(waitingOn);
         if (!waitingOn.isWaiting())
-            maybeExecute(safeStore, safeCommand, false, true, executeAsync);
+            maybeExecute(safeStore, safeCommand, false, true);
     }
 
     // TODO (now): document and justify all calls
@@ -1195,10 +1172,11 @@ public class Commands
     {
         CommandStore commandStore = safeStore.commandStore();
         Command.Committed current = safeCommand.current().asCommitted();
+
         WaitingOn.Update update = new WaitingOn.Update(current.waitingOn);
         TxnId minWaitingOnTxnId = update.minWaitingOnTxnId();
         if (minWaitingOnTxnId != null && commandStore.hasLocallyRedundantDependencies(update.minWaitingOnTxnId(), current.executeAt(), current.route().participants()))
-            safeStore.commandStore().removeRedundantDependencies(current.route().participants(), current.partialDeps(), update);
+            safeStore.commandStore().removeRedundantDependencies(current.route().participants(), update);
 
         // if we are a range transaction, being redundant for this transaction does not imply we are redundant for all transactions
         if (redundant != null)
diff --git a/accord-core/src/main/java/accord/local/CommandsForKey.java b/accord-core/src/main/java/accord/local/CommandsForKey.java
deleted file mode 100644
index cc44f242..00000000
--- a/accord-core/src/main/java/accord/local/CommandsForKey.java
+++ /dev/null
@@ -1,1726 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package accord.local;
-
-import java.util.Arrays;
-import java.util.Comparator;
-import java.util.EnumMap;
-import java.util.Objects;
-import javax.annotation.Nonnull;
-import javax.annotation.Nullable;
-
-import com.google.common.annotations.VisibleForTesting;
-
-import accord.api.Key;
-
-import accord.impl.CommandsSummary;
-import accord.local.SafeCommandStore.CommandFunction;
-import accord.local.SafeCommandStore.TestDep;
-import accord.local.SafeCommandStore.TestStartedAt;
-import accord.local.SafeCommandStore.TestStatus;
-import accord.primitives.Ballot;
-import accord.primitives.Keys;
-import accord.primitives.Participants;
-import accord.primitives.Seekables;
-import accord.primitives.Timestamp;
-import accord.primitives.Txn.Kind;
-import accord.primitives.Txn.Kind.Kinds;
-import accord.primitives.TxnId;
-import accord.utils.ArrayBuffers;
-import accord.utils.Invariants;
-import accord.utils.RelationMultiMap.SortedRelationList;
-import accord.utils.SortedArrays;
-import accord.utils.SortedList;
-
-import static accord.local.CommandsForKey.InternalStatus.ACCEPTED;
-import static accord.local.CommandsForKey.InternalStatus.APPLIED;
-import static accord.local.CommandsForKey.InternalStatus.COMMITTED;
-import static accord.local.CommandsForKey.InternalStatus.PREACCEPTED;
-import static accord.local.CommandsForKey.InternalStatus.STABLE;
-import static accord.local.CommandsForKey.InternalStatus.HISTORICAL;
-import static accord.local.CommandsForKey.InternalStatus.INVALID_OR_TRUNCATED;
-import static accord.local.CommandsForKey.InternalStatus.TRANSITIVELY_KNOWN;
-import static accord.local.CommandsForKey.InternalStatus.from;
-import static accord.local.CommandsForKey.Unmanaged.Pending.APPLY;
-import static accord.local.CommandsForKey.Unmanaged.Pending.COMMIT;
-import static accord.local.SafeCommandStore.TestDep.ANY_DEPS;
-import static accord.local.SafeCommandStore.TestDep.WITH;
-import static accord.local.SaveStatus.LocalExecution.WaitingToApply;
-import static accord.local.SaveStatus.LocalExecution.WaitingToExecute;
-import static accord.primitives.Txn.Kind.Kinds.AnyGloballyVisible;
-import static accord.utils.ArrayBuffers.cachedTxnIds;
-import static accord.utils.Invariants.illegalState;
-import static accord.utils.Invariants.isParanoid;
-import static accord.utils.SortedArrays.Search.CEIL;
-import static accord.utils.SortedArrays.Search.FAST;
-
-/**
- * A specialised collection for efficiently representing and querying everything we need for making coordination
- * and recovery decisions about a key's command conflicts.
- *
- * Every command we know about that is not shard-redundant is listed in the TxnId[] collection, which is sorted by TxnId.
- * This list implies the contents of the deps of all commands in the collection - it is assumed that in the normal course
- * of events every transaction will include the full set of TxnId we know. We only encode divergences from this, stored
- * in each command's {@code missing} collection.
- *
- * We then go one step further, exploiting the fact that the missing collection exists solely to implement recovery,
- * and so we elide from this missing collection any TxnId we have recorded as Committed or higher.
- * Any recovery coordinator that contacts this replica will report that the command has been agreed to execute,
- * and so will not need to decipher any fast-path decisions. So the missing collection is redundant, as no command's deps
- * will not be queried for this TxnId's presence/absence.
- * TODO (expected) this logic applies equally well to Accepted
- *
- * The goal with these behaviours is that this missing collection will ordinarily be empty, and represented by the exact
- * same NO_TXNIDS array instance as every other command.
- *
- * We also exploit the property that most commands will also agree to execute at their proposed TxnId. If we have
- * no missing collection to encode, and no modified executeAt, we store a global NoInfo object that takes up no
- * space on heap. These NoInfo objects permit further efficiencies, as we may perform class-pointer comparisons
- * before querying any contents to skip uninteresting contents, permitting fast iteration of the collection's contents.
- *
- * We also impose the condition that every TxnId is uniquely represented in the collection, so any executeAt and missing
- * collection that represents the same value as the TxnId must be the same object present in the main TxnId[].
- *
- * This collection also implements transitive dependency elision.
- * When evaluating mapReduceActive, we first establish the last-executing Stable write command (i.e. those whose deps
- * are considered durably decided, and so must wait for all commands Committed with a lower executeAt).
- * We then elide any Committed command that has a lower executeAt than this command.
- *
- * Both commands must be known at a majority, but neither might be Committed at any other replica.
- * Either command may therefore be recovered.
- * If the later command is recovered, this replica will report its Stable deps thereby recovering them.
- * If this replica is not contacted, some other replica must participate that either has taken the same action as this replica,
- * or else does not know the later command is Stable, and so will report the earlier command as a dependency again.
- * If the earlier command is recovered, this replica will report that it is Committed, and so will not consult
- * this replica's collection to decipher any fast path decision. Any other replica must either do the same, or else
- * will correctly record this transaction as present in any relevant deps of later transactions.
- *
- * TODO (expected): optimisations:
- *    3) consider storing a prefix of TxnId that are all NoInfo PreApplied encoded as a BitStream as only required for computing missing collection
- *    4) consider storing (or caching) an int[] of records with an executeAt that occurs out of order, sorted by executeAt
- *
- * TODO (expected): maintain separate redundantBefore and closedBefore timestamps, latter implied by any exclusivesyncpoint;
- *                  advance former based on Applied status of all TxnId before the latter
- * TODO (expected): track whether a TxnId is a write on this key only for execution (rather than globally)
- * TODO (expected): merge with TimestampsForKey
- * TODO (expected): save bytes by encoding InternalStatus in TxnId.flags()
- * TODO (expected): migrate to BTree
- * TODO (expected): remove a command that is committed to not intersect with the key for this store (i.e. if accepted in a later epoch than committed on, so ownership changes)
- * TODO (expected): mark a command as notified once ready-to-execute or applying
- * TODO (required): randomised testing
- * TODO (required): linearizability violation detection
- * TODO (required): account for whether transactions should witness each other for determining the missing collection
- * TODO (required): enforce that a transaction is not added with a key dependency on a SyncPoint or ExclusiveSyncPoint;
- *                  also impose this invariant elsewhere, and create a compile-time dependency between these code locations
- */
-public class CommandsForKey implements CommandsSummary
-{
-    private static final boolean PRUNE_TRANSITIVE_DEPENDENCIES = true;
-    public static final RedundantBefore.Entry NO_REDUNDANT_BEFORE = new RedundantBefore.Entry(null, Long.MIN_VALUE, Long.MAX_VALUE, TxnId.NONE, TxnId.NONE, TxnId.NONE, null);
-    public static final TxnId[] NO_TXNIDS = new TxnId[0];
-    public static final TxnInfo[] NO_INFOS = new TxnInfo[0];
-    public static final Unmanaged[] NO_PENDING_UNMANAGED = new Unmanaged[0];
-
-    public static class Unmanaged implements Comparable<Unmanaged>
-    {
-        public enum Pending { COMMIT, APPLY }
-
-        public final Pending pending;
-        public final Timestamp waitingUntil;
-        public final TxnId txnId;
-
-        public Unmanaged(Pending pending, TxnId txnId, Timestamp waitingUntil)
-        {
-            this.pending = pending;
-            this.txnId = txnId;
-            this.waitingUntil = waitingUntil;
-        }
-
-        @Override
-        public int compareTo(Unmanaged that)
-        {
-            if (this.pending != that.pending) return this.pending == COMMIT ? -1 : 1;
-            int c = this.waitingUntil.compareTo(that.waitingUntil);
-            if (c == 0) c = this.txnId.compareTo(that.txnId);
-            return c;
-        }
-
-        @Override
-        public boolean equals(Object o)
-        {
-            if (this == o) return true;
-            if (o == null || getClass() != o.getClass()) return false;
-            Unmanaged unmanaged = (Unmanaged) o;
-            return pending == unmanaged.pending && waitingUntil.equals(unmanaged.waitingUntil) && txnId.equals(unmanaged.txnId);
-        }
-
-        @Override
-        public int hashCode()
-        {
-            return Objects.hash(pending, waitingUntil, txnId);
-        }
-
-        @Override
-        public String toString()
-        {
-            return "Pending{" + txnId + " until:" + waitingUntil + " " + pending + "}";
-        }
-    }
-
-    public static class SerializerSupport
-    {
-        public static CommandsForKey create(Key key, TxnInfo[] txns, Unmanaged[] unmanageds)
-        {
-            return new CommandsForKey(key, NO_REDUNDANT_BEFORE, txns, unmanageds);
-        }
-    }
-
-    public enum InternalStatus
-    {
-        TRANSITIVELY_KNOWN(false, false), // (unwitnessed; no need for mapReduce to witness)
-        HISTORICAL(false, false),
-        PREACCEPTED(false),
-        ACCEPTED(true),
-        COMMITTED(true),
-        STABLE(true),
-        APPLIED(true),
-        INVALID_OR_TRUNCATED(false);
-
-        static final EnumMap<SaveStatus, InternalStatus> convert = new EnumMap<>(SaveStatus.class);
-        static final InternalStatus[] VALUES = values();
-        static
-        {
-            convert.put(SaveStatus.PreAccepted, PREACCEPTED);
-            convert.put(SaveStatus.AcceptedInvalidateWithDefinition, PREACCEPTED);
-            convert.put(SaveStatus.Accepted, ACCEPTED);
-            convert.put(SaveStatus.AcceptedWithDefinition, ACCEPTED);
-            convert.put(SaveStatus.PreCommittedWithDefinition, PREACCEPTED);
-            convert.put(SaveStatus.PreCommittedWithAcceptedDeps, ACCEPTED);
-            convert.put(SaveStatus.PreCommittedWithDefinitionAndAcceptedDeps, ACCEPTED);
-            convert.put(SaveStatus.Committed, COMMITTED);
-            convert.put(SaveStatus.Stable, STABLE);
-            convert.put(SaveStatus.ReadyToExecute, STABLE);
-            convert.put(SaveStatus.PreApplied, STABLE);
-            convert.put(SaveStatus.Applying, STABLE);
-            convert.put(SaveStatus.Applied, APPLIED);
-            convert.put(SaveStatus.TruncatedApplyWithDeps, INVALID_OR_TRUNCATED);
-            convert.put(SaveStatus.TruncatedApplyWithOutcome, INVALID_OR_TRUNCATED);
-            convert.put(SaveStatus.TruncatedApply, INVALID_OR_TRUNCATED);
-            convert.put(SaveStatus.ErasedOrInvalidated, INVALID_OR_TRUNCATED);
-            convert.put(SaveStatus.Erased, INVALID_OR_TRUNCATED);
-            convert.put(SaveStatus.Invalidated, INVALID_OR_TRUNCATED);
-        }
-
-        public final boolean hasInfo;
-        // TODO (desired): cleanup expectation logic re: accepted invalidate
-        final InternalStatus expectMatch;
-
-        InternalStatus(boolean hasInfo)
-        {
-            this(hasInfo, true);
-        }
-
-        InternalStatus(boolean hasInfo, boolean expectMatch)
-        {
-            this.hasInfo = hasInfo;
-            this.expectMatch = expectMatch ? this : null;
-        }
-
-        boolean hasExecuteAt()
-        {
-            return hasInfo;
-        }
-
-        boolean hasDeps()
-        {
-            return hasInfo;
-        }
-
-        boolean hasStableDeps()
-        {
-            return this == STABLE || this == APPLIED;
-        }
-
-        public Timestamp depsKnownBefore(TxnId txnId, @Nullable Timestamp executeAt)
-        {
-            switch (this)
-            {
-                default: throw new AssertionError("Unhandled InternalStatus: " + this);
-                case TRANSITIVELY_KNOWN:
-                case INVALID_OR_TRUNCATED:
-                case HISTORICAL:
-                    throw new AssertionError("Invalid InternalStatus to know deps");
-
-                case PREACCEPTED:
-                case ACCEPTED:
-                    return txnId;
-
-                case APPLIED:
-                case STABLE:
-                case COMMITTED:
-                    return executeAt == null ? txnId : executeAt;
-            }
-        }
-
-        @VisibleForTesting
-        public static InternalStatus from(SaveStatus status)
-        {
-            return convert.get(status);
-        }
-
-        public static InternalStatus get(int ordinal)
-        {
-            return VALUES[ordinal];
-        }
-    }
-
-    public static class TxnInfo extends TxnId
-    {
-        public final InternalStatus status;
-        public final Timestamp executeAt;
-
-        TxnInfo(TxnId txnId, InternalStatus status, Timestamp executeAt)
-        {
-            super(txnId);
-            this.status = status;
-            this.executeAt = executeAt == txnId ? this : executeAt;
-        }
-
-        public static TxnInfo create(@Nonnull TxnId txnId, InternalStatus status)
-        {
-            return new TxnInfo(txnId, status, txnId);
-        }
-
-        public static TxnInfo create(@Nonnull TxnId txnId, InternalStatus status, @Nonnull Timestamp executeAt)
-        {
-            Invariants.checkState(executeAt == txnId || !executeAt.equals(txnId));
-            return new TxnInfo(txnId, status, executeAt);
-        }
-
-        public static TxnInfo create(@Nonnull TxnId txnId, InternalStatus status, @Nonnull Timestamp executeAt, @Nonnull TxnId[] missing)
-        {
-            Invariants.checkState(executeAt == txnId || !executeAt.equals(txnId));
-            if (missing == NO_TXNIDS) return new TxnInfo(txnId, status, executeAt);
-            return new TxnInfoWithMissing(txnId, status, executeAt, missing);
-        }
-
-        public static TxnInfo createMock(TxnId txnId, InternalStatus status, @Nullable Timestamp executeAt, @Nullable TxnId[] missing)
-        {
-            Invariants.checkState(executeAt == null || executeAt == txnId || !executeAt.equals(txnId));
-            Invariants.checkArgument(missing == null || missing == NO_TXNIDS);
-            if (missing == NO_TXNIDS) return new TxnInfo(txnId, status, executeAt);
-            return new TxnInfoWithMissing(txnId, status, executeAt, missing);
-        }
-
-        Timestamp depsKnownBefore()
-        {
-            return status.depsKnownBefore(this, executeAt);
-        }
-
-        public TxnInfo update(TxnId[] newMissing)
-        {
-            return newMissing == NO_TXNIDS ? new TxnInfo(this, status, executeAt)
-                                           : new TxnInfoWithMissing(this, status, executeAt, newMissing);
-        }
-
-        @Override
-        public boolean equals(Object o)
-        {
-            if (this == o) return true;
-            if (o == null || getClass() != o.getClass()) return false;
-            TxnInfo info = (TxnInfo) o;
-            return status == info.status
-                   && (executeAt == this ? info.executeAt == info : Objects.equals(executeAt, info.executeAt))
-                   && Arrays.equals(missing(), info.missing());
-        }
-
-        TxnId asPlainTxnId()
-        {
-            return new TxnId(this);
-        }
-
-        public TxnId[] missing()
-        {
-            return NO_TXNIDS;
-        }
-
-        @Override
-        public int hashCode()
-        {
-            throw new UnsupportedOperationException();
-        }
-
-        @Override
-        public String toString()
-        {
-            return "Info{" +
-                   "txnId=" + toPlainString() +
-                   ", status=" + status +
-                   ", executeAt=" + (executeAt == this ? toPlainString() : executeAt.toString()) +
-                   '}';
-        }
-
-        public String toPlainString()
-        {
-            return super.toString();
-        }
-    }
-
-    public static class TxnInfoWithMissing extends TxnInfo
-    {
-        public final TxnId[] missing;
-
-        TxnInfoWithMissing(TxnId txnId, InternalStatus status, Timestamp executeAt, TxnId[] missing)
-        {
-            super(txnId, status, executeAt);
-            this.missing = missing;
-        }
-
-        public TxnId[] missing()
-        {
-            return missing;
-        }
-
-        @Override
-        public String toString()
-        {
-            return "Info{" +
-                   "txnId=" + toPlainString() +
-                   ", status=" + status +
-                   ", executeAt=" + (this == executeAt ? toPlainString() : executeAt) +
-                   ", missing=" + Arrays.toString(missing) +
-                   '}';
-        }
-    }
-
-    private final Key key;
-    private final RedundantBefore.Entry redundantBefore;
-    // all transactions, sorted by TxnId
-    private final TxnInfo[] txns;
-    // next and nextWrite are only non-null if they and their dependencies are all committed
-    private final TxnInfo minUncommitted, next, nextWrite;
-    // transactions that are committed or stable plus MAYBE Applied, keyed by executeAt
-    private final TxnInfo[] committed;
-    private final Unmanaged[] unmanageds;
-
-    CommandsForKey(Key key, RedundantBefore.Entry redundantBefore, TxnInfo[] txns, Unmanaged[] unmanageds)
-    {
-        this.key = key;
-        this.redundantBefore = Invariants.nonNull(redundantBefore);
-        this.txns = txns;
-        this.unmanageds = unmanageds;
-        if (isParanoid()) Invariants.checkArgument(SortedArrays.isSortedUnique(txns));
-
-        // TODO (required): update these efficiently
-        int countCommitted = 0;
-        TxnInfo minUncommitted = null, nextWrite = null, next = null;
-        for (int i = 0 ; i < txns.length ; ++i)
-        {
-            TxnInfo txn = txns[i];
-            if (txn.status == INVALID_OR_TRUNCATED) continue;
-
-            if (txn.status.compareTo(COMMITTED) >= 0)
-            {
-                if (txn.status.compareTo(APPLIED) < 0)
-                {
-                    if (txn.kind().isWrite() && (nextWrite == null || nextWrite.executeAt.compareTo(txn.executeAt) > 0))
-                        nextWrite = txn;
-                    if (next == null || next.executeAt.compareTo(txn.executeAt) > 0)
-                        next = txn;
-                }
-                ++countCommitted;
-            }
-            else if (minUncommitted == null) minUncommitted = txn;
-        }
-        if (minUncommitted != null)
-        {
-            // TODO (expected): discount minUncommitted if not a dependency of next/nextWrite
-            if (next != null && minUncommitted.compareTo(next.executeAt) < 0)
-                next = null;
-            if (nextWrite != null && minUncommitted.compareTo(nextWrite.executeAt) < 0)
-                nextWrite = null;
-        }
-        this.minUncommitted = minUncommitted;
-        this.nextWrite = nextWrite;
-        this.next = next;
-        this.committed = new TxnInfo[countCommitted];
-        countCommitted = 0;
-        for (int i = 0 ; i < txns.length ; ++i)
-        {
-            if (txns[i].status.compareTo(COMMITTED) >= 0 && txns[i].status != INVALID_OR_TRUNCATED)
-                committed[countCommitted++] = txns[i];
-        }
-        Arrays.sort(committed, Comparator.comparing(c -> c.executeAt));
-    }
-
-    public CommandsForKey(Key key)
-    {
-        this.key = key;
-        this.redundantBefore = NO_REDUNDANT_BEFORE;
-        this.txns = NO_INFOS;
-        this.committed = NO_INFOS;
-        this.minUncommitted = null;
-        this.nextWrite = null;
-        this.next = null;
-        this.unmanageds = NO_PENDING_UNMANAGED;
-    }
-
-    @Override
-    public String toString()
-    {
-        return "CommandsForKey@" + System.identityHashCode(this) + '{' + key + '}';
-    }
-
-    public Key key()
-    {
-        return key;
-    }
-
-    public int size()
-    {
-        return txns.length;
-    }
-
-    public int unmanagedCount()
-    {
-        return unmanageds.length;
-    }
-
-    public int indexOf(TxnId txnId)
-    {
-        return Arrays.binarySearch(txns, txnId);
-    }
-
-    public TxnId txnId(int i)
-    {
-        return txns[i];
-    }
-
-    public TxnInfo get(int i)
-    {
-        return txns[i];
-    }
-
-    public Unmanaged getUnmanaged(int i)
-    {
-        return unmanageds[i];
-    }
-
-    public TxnInfo get(TxnId txnId)
-    {
-        int i = indexOf(txnId);
-        return i < 0 ? null : txns[i];
-    }
-
-    public RedundantBefore.Entry redundantBefore()
-    {
-        return redundantBefore;
-    }
-
-    public TxnId locallyRedundantBefore()
-    {
-        return redundantBefore.locallyRedundantBefore();
-    }
-
-    public TxnId shardRedundantBefore()
-    {
-        return redundantBefore.shardRedundantBefore();
-    }
-
-    /**
-     * All commands before/after (exclusive of) the given timestamp
-     * <p>
-     * Note that {@code testDep} applies only to commands that MAY have the command in their deps; if specified any
-     * commands that do not know any deps will be ignored, as will any with an executeAt prior to the txnId.
-     * <p>
-     */
-    public <P1, T> T mapReduceFull(TxnId testTxnId,
-                                   Kinds testKind,
-                                   TestStartedAt testStartedAt,
-                                   TestDep testDep,
-                                   TestStatus testStatus,
-                                   CommandFunction<P1, T, T> map, P1 p1, T initialValue)
-    {
-        int start, end;
-        boolean isKnown;
-        {
-            int insertPos = Arrays.binarySearch(txns, testTxnId);
-            isKnown = insertPos >= 0;
-            if (!isKnown && testDep == WITH) return initialValue;
-            if (!isKnown) insertPos = -1 - insertPos;
-            switch (testStartedAt)
-            {
-                default: throw new AssertionError("Unhandled TestStartedAt: " + testTxnId);
-                case STARTED_BEFORE: start = 0; end = insertPos; break;
-                case STARTED_AFTER: start = insertPos; end = txns.length; break;
-                case ANY: start = 0; end = txns.length;
-            }
-        }
-
-        for (int i = start; i < end ; ++i)
-        {
-            TxnInfo txn = txns[i];
-            if (!testKind.test(txn.kind())) continue;
-            InternalStatus status = txn.status;
-            switch (testStatus)
-            {
-                default: throw new AssertionError("Unhandled TestStatus: " + testStatus);
-                case IS_PROPOSED:
-                    if (status == ACCEPTED || status == COMMITTED) break;
-                    else continue;
-                case IS_STABLE:
-                    if (status.compareTo(STABLE) >= 0 && status.compareTo(INVALID_OR_TRUNCATED) < 0) break;
-                    else continue;
-                case ANY_STATUS:
-                    if (status == TRANSITIVELY_KNOWN)
-                        continue;
-            }
-
-            Timestamp executeAt = txn.executeAt;
-            if (testDep != ANY_DEPS)
-            {
-                if (!status.hasInfo)
-                    continue;
-
-                if (executeAt.compareTo(testTxnId) <= 0)
-                    continue;
-
-                boolean hasAsDep = Arrays.binarySearch(txn.missing(), testTxnId) < 0;
-                if (hasAsDep != (testDep == WITH))
-                    continue;
-            }
-
-            initialValue = map.apply(p1, key, txn.asPlainTxnId(), executeAt, initialValue);
-        }
-        return initialValue;
-    }
-
-    public <P1, T> T mapReduceActive(Timestamp startedBefore,
-                                     Kinds testKind,
-                                     CommandFunction<P1, T, T> map, P1 p1, T initialValue)
-    {
-        Timestamp maxCommittedBefore;
-        {
-            int i = SortedArrays.binarySearch(committed, 0, committed.length, startedBefore, (f, v) -> f.compareTo(v.executeAt), FAST);
-            if (i < 0) i = -2 - i;
-            else --i;
-            while (i >= 0 && !committed[i].kind().isWrite()) --i;
-            maxCommittedBefore = i < 0 ? null : committed[i].executeAt;
-        }
-        int start = 0, end = insertPos(start, startedBefore);
-
-        for (int i = start; i < end ; ++i)
-        {
-            TxnInfo txn = txns[i];
-            if (!testKind.test(txn.kind()))
-                continue;
-
-            switch (txn.status)
-            {
-                case COMMITTED:
-                case STABLE:
-                case APPLIED:
-                    // TODO (expected): prove the correctness of this approach
-                    if (!PRUNE_TRANSITIVE_DEPENDENCIES || maxCommittedBefore == null || txn.executeAt.compareTo(maxCommittedBefore) >= 0)
-                        break;
-                case TRANSITIVELY_KNOWN:
-                case INVALID_OR_TRUNCATED:
-                    continue;
-            }
-
-            initialValue = map.apply(p1, key, txn.asPlainTxnId(), txn.executeAt, initialValue);
-        }
-        return initialValue;
-    }
-
-    public CommandsForKey update(Command prev, Command next)
-    {
-        return update(prev, next, true);
-    }
-
-    CommandsForKey update(Command prev, Command next, boolean hasPrev)
-    {
-        InternalStatus newStatus = InternalStatus.from(next.saveStatus());
-        if (newStatus == null)
-            return this;
-
-        TxnId txnId = next.txnId();
-        int pos = Arrays.binarySearch(txns, txnId);
-        CommandsForKey result;
-        if (pos < 0)
-        {
-            pos = -1 - pos;
-            if (newStatus.hasInfo) result = insert(pos, txnId, newStatus, next);
-            else result = insert(pos, txnId, TxnInfo.create(txnId, newStatus, txnId));
-        }
-        else
-        {
-            // update
-            TxnInfo cur = txns[pos];
-            if (hasPrev)
-            {
-                if (newStatus.compareTo(cur.status) <= 0)
-                {
-                    // TODO (required): this validation is not safe for replay where we may have to "catch up" commands that are behind CFK
-                    // we can redundantly update the same transaction via notifyWaitingOnCommit since updates to CFK may be asynchronous
-                    // (particularly for invalidations). So we should expect that we might already represent the latest information for this transaction.
-                    // TODO (desired): consider only accepting this for Invalidation
-                    // TODO (desired): also clean-up special casing for AcceptedInvalidate, which exists because it currently has no effect on the CFK state
-                    //    so it could be any of Transitively Known, Historic, PreAccept or Accept
-                    Invariants.checkState(cur.status == newStatus || next.status() == Status.AcceptedInvalidate, "Attempted update to CommandsForKey with %s, implying stale status; found %s", next, cur);
-                    if (!newStatus.hasInfo || next.acceptedOrCommitted().equals(prev.acceptedOrCommitted()))
-                        return this;
-                }
-
-                InternalStatus prevStatus = prev == null ? null : InternalStatus.from(prev.saveStatus());
-                Invariants.checkState(cur.status.expectMatch == prevStatus || cur.status.expectMatch == null && !prev.keysOrRanges().contains(key) || (prev != null && prev.status() == Status.AcceptedInvalidate));
-            }
-            else if (newStatus.compareTo(cur.status) <= 0)
-            {
-                Invariants.checkState(cur.status == newStatus || next.status() == Status.AcceptedInvalidate);
-                if (!newStatus.hasInfo)
-                    return this;
-            }
-
-            if (newStatus.hasInfo) result = update(pos, txnId, cur, newStatus, next);
-            else result = update(pos, txnId, cur, TxnInfo.create(txnId, newStatus, txnId));
-        }
-
-        return result;
-    }
-
-    public static boolean needsUpdate(Command prev, Command updated)
-    {
-        if (!updated.txnId().kind().isGloballyVisible())
-            return false;
-
-        SaveStatus prevStatus;
-        Ballot prevAcceptedOrCommitted;
-        if (prev == null)
-        {
-            prevStatus = SaveStatus.NotDefined;
-            prevAcceptedOrCommitted = Ballot.ZERO;
-        }
-        else
-        {
-            prevStatus = prev.saveStatus();
-            prevAcceptedOrCommitted = prev.acceptedOrCommitted();
-        }
-
-        return needsUpdate(prevStatus, prevAcceptedOrCommitted, updated.saveStatus(), updated.acceptedOrCommitted());
-    }
-
-    public static boolean needsUpdate(SaveStatus prevStatus, Ballot prevAcceptedOrCommitted, SaveStatus updatedStatus, Ballot updatedAcceptedOrCommitted)
-    {
-        InternalStatus prev = InternalStatus.from(prevStatus);
-        InternalStatus updated = InternalStatus.from(updatedStatus);
-        return updated != prev || (updated != null && updated.hasInfo && !prevAcceptedOrCommitted.equals(updatedAcceptedOrCommitted));
-    }
-
-    private CommandsForKey insert(int insertPos, TxnId insertTxnId, InternalStatus newStatus, Command command)
-    {
-        Object newInfo = computeInsert(insertPos, insertTxnId, newStatus, command);
-        if (newInfo.getClass() != InfoWithAdditions.class)
-            return insert(insertPos, insertTxnId, (TxnInfo)newInfo);
-
-        InfoWithAdditions newInfoWithAdditions = (InfoWithAdditions) newInfo;
-        TxnInfo[] newTxns = new TxnInfo[txns.length + newInfoWithAdditions.additionCount + 1];
-        insertWithAdditions(insertPos, insertTxnId, newInfoWithAdditions, newTxns);
-        return update(newTxns);
-    }
-
-    private CommandsForKey update(int updatePos, TxnId plainTxnId, TxnInfo prevInfo, InternalStatus newStatus, Command command)
-    {
-        Object newInfo = computeUpdate(updatePos, plainTxnId, newStatus, command);
-        if (newInfo.getClass() != InfoWithAdditions.class)
-            return update(updatePos, plainTxnId, prevInfo, (TxnInfo)newInfo);
-
-        InfoWithAdditions newInfoWithAdditions = (InfoWithAdditions) newInfo;
-        TxnInfo[] newTxns = new TxnInfo[txns.length + newInfoWithAdditions.additionCount];
-        updateWithAdditions(updatePos, plainTxnId, newInfoWithAdditions, newTxns);
-        if (prevInfo.status.compareTo(COMMITTED) < 0 && newStatus.compareTo(COMMITTED) >= 0)
-            removeMissing(newTxns, plainTxnId);
-        return update(newTxns);
-    }
-
-    private void updateWithAdditions(int updatePos, TxnId updateTxnId, InfoWithAdditions withInfo, TxnInfo[] newInfos)
-    {
-        updateOrInsertWithAdditions(updatePos, updatePos, updateTxnId, withInfo, newInfos);
-    }
-
-    private void insertWithAdditions(int pos, TxnId updateTxnId, InfoWithAdditions withInfo, TxnInfo[] newInfos)
-    {
-        updateOrInsertWithAdditions(pos, -1, updateTxnId, withInfo, newInfos);
-    }
-
-    private void updateOrInsertWithAdditions(int sourceInsertPos, int sourceUpdatePos, TxnId updatePlainTxnId, InfoWithAdditions withInfo, TxnInfo[] newTxns)
-    {
-        TxnId[] additions = withInfo.additions;
-        int additionCount = withInfo.additionCount;
-        int additionInsertPos = Arrays.binarySearch(additions, 0, additionCount, updatePlainTxnId);
-        additionInsertPos = Invariants.checkArgument(-1 - additionInsertPos, additionInsertPos < 0);
-        int targetInsertPos = sourceInsertPos + additionInsertPos;
-
-        // additions plus the updateTxnId when necessary
-        TxnId[] missingSource = additions;
-        boolean insertSelfMissing = sourceUpdatePos < 0 && withInfo.info.status.compareTo(COMMITTED) < 0;
-
-        // the most recently constructed pure insert missing array, so that it may be reused if possible
-        int i = 0, j = 0, missingCount = 0, missingLimit = additionCount, count = 0;
-        while (i < txns.length)
-        {
-            if (count == targetInsertPos)
-            {
-                newTxns[count] = withInfo.info;
-                if (i == sourceUpdatePos) ++i;
-                else if (insertSelfMissing) ++missingCount;
-                ++count;
-                continue;
-            }
-
-            int c = j == additionCount ? -1 : txns[i].compareTo(additions[j]);
-            if (c < 0)
-            {
-                TxnInfo txn = txns[i];
-                if (i == sourceUpdatePos)
-                {
-                    txn = withInfo.info;
-                }
-                else if (txn.status.hasDeps())
-                {
-                    Timestamp depsKnownBefore = txn.depsKnownBefore();
-                    if (insertSelfMissing && missingSource == additions && (missingCount != j || (depsKnownBefore != txn && depsKnownBefore.compareTo(updatePlainTxnId) > 0)))
-                    {
-                        missingSource = insertMissing(additions, additionCount, updatePlainTxnId, additionInsertPos);
-                        ++missingLimit;
-                    }
-
-                    int to = to(txn, depsKnownBefore, missingSource, missingCount, missingLimit);
-                    if (to > 0)
-                    {
-                        TxnId[] prevMissing = txn.missing();
-                        TxnId[] newMissing = mergeAndFilterMissing(txn, prevMissing, missingSource, to);
-                        if (newMissing != prevMissing)
-                            txn = txn.update(newMissing);
-                    }
-                }
-                newTxns[count] = txn;
-                i++;
-            }
-            else if (c > 0)
-            {
-                TxnId txnId = additions[j++];
-                newTxns[count] = TxnInfo.create(txnId, TRANSITIVELY_KNOWN, txnId);
-                ++missingCount;
-            }
-            else
-            {
-                throw illegalState(txns[i] + " should be an insertion, but found match when merging with origin");
-            }
-            count++;
-        }
-
-        if (j < additionCount)
-        {
-            if (count <= targetInsertPos)
-            {
-                while (count < targetInsertPos)
-                {
-                    TxnId txnId = additions[j++];
-                    newTxns[count++] = TxnInfo.create(txnId, TRANSITIVELY_KNOWN, txnId);
-                }
-                newTxns[targetInsertPos] = withInfo.info;
-                count = targetInsertPos + 1;
-            }
-            while (j < additionCount)
-            {
-                TxnId txnId = additions[j++];
-                newTxns[count++] = TxnInfo.create(txnId, TRANSITIVELY_KNOWN, txnId);
-            }
-        }
-        else if (count == targetInsertPos)
-        {
-            newTxns[targetInsertPos] = withInfo.info;
-        }
-
-        cachedTxnIds().forceDiscard(additions, additionCount);
-    }
-
-    private CommandsForKey update(int pos, TxnId plainTxnId, TxnInfo curInfo, TxnInfo newInfo)
-    {
-        if (curInfo == newInfo)
-            return this;
-
-        TxnInfo[] newTxns = txns.clone();
-        newTxns[pos] = newInfo;
-        if (curInfo.status.compareTo(COMMITTED) < 0 && newInfo.status.compareTo(COMMITTED) >= 0)
-            removeMissing(newTxns, plainTxnId);
-        return update(newTxns);
-    }
-
-    /**
-     * Insert a new txnId and info
-     */
-    private CommandsForKey insert(int pos, TxnId insertPlainTxnId, TxnInfo insert)
-    {
-        TxnInfo[] newTxns = new TxnInfo[txns.length + 1];
-        if (insert.status.compareTo(COMMITTED) >= 0)
-        {
-            System.arraycopy(txns, 0, newTxns, 0, pos);
-            newTxns[pos] = insert;
-            System.arraycopy(txns, pos, newTxns, pos + 1, txns.length - pos);
-        }
-        else
-        {
-            insertInfoAndOneMissing(pos, insertPlainTxnId, insert, txns, newTxns, 1);
-        }
-        return update(newTxns);
-    }
-
-    /**
-     * Insert a new txnId and info, then insert the txnId into the missing collection of any command that should have already caused us to witness it
-     */
-    private static void insertInfoAndOneMissing(int insertPos, TxnId insertedPlainTxnId, TxnInfo inserted, TxnInfo[] oldTxns, TxnInfo[] newTxns, int offsetAfterInsertPos)
-    {
-        TxnId[] oneMissing = null;
-        for (int i = 0 ; i < insertPos ; ++i)
-        {
-            TxnInfo txn = oldTxns[i];
-            if (txn.status.hasDeps())
-            {
-                Timestamp depsKnownBefore = txn.depsKnownBefore();
-                if (depsKnownBefore != null && depsKnownBefore.compareTo(inserted) > 0 && txn.kind().witnesses(inserted))
-                {
-                    TxnId[] missing = txn.missing();
-                    if (missing == NO_TXNIDS)
-                        missing = oneMissing = ensureOneMissing(insertedPlainTxnId, oneMissing);
-                    else
-                        missing = SortedArrays.insert(missing, insertedPlainTxnId, TxnId[]::new);
-
-                    txn = txn.update(missing);
-                }
-            }
-            newTxns[i] = txn;
-        }
-
-        newTxns[insertPos] = inserted;
-
-        for (int i = insertPos; i < oldTxns.length ; ++i)
-        {
-            int newIndex = i + offsetAfterInsertPos;
-            TxnInfo txn = oldTxns[i];
-            if (txn.status.hasDeps())
-            {
-                if (txn.kind().witnesses(inserted))
-                {
-                    TxnId[] missing = txn.missing();
-                    if (missing == NO_TXNIDS)
-                        missing = oneMissing = ensureOneMissing(insertedPlainTxnId, oneMissing);
-                    else
-                        missing = SortedArrays.insert(missing, insertedPlainTxnId, TxnId[]::new);
-
-                    txn = txn.update(missing);
-                }
-            }
-
-            newTxns[newIndex] = txn;
-        }
-    }
-
-    private static void removeMissing(TxnInfo[] txns, TxnId removeTxnId)
-    {
-        for (int i = 0 ; i < txns.length ; ++i)
-        {
-            TxnInfo txn = txns[i];
-            TxnId[] missing = txn.missing();
-            if (missing == NO_TXNIDS) continue;
-
-            int j = Arrays.binarySearch(missing, removeTxnId);
-            if (j < 0) continue;
-
-            if (missing.length == 1)
-            {
-                missing = NO_TXNIDS;
-            }
-            else
-            {
-                int length = missing.length;
-                TxnId[] newMissing = new TxnId[length - 1];
-                System.arraycopy(missing, 0, newMissing, 0, j);
-                System.arraycopy(missing, j + 1, newMissing, j, length - (1 + j));
-                missing = newMissing;
-            }
-
-            txns[i] = txn.update(missing);
-        }
-    }
-
-    private static TxnId[] ensureOneMissing(TxnId txnId, TxnId[] oneMissing)
-    {
-        return oneMissing != null ? oneMissing : new TxnId[] { txnId };
-    }
-
-    private static TxnId[] insertMissing(TxnId[] additions, int additionCount, TxnId updateTxnId, int additionInsertPos)
-    {
-        TxnId[] result = new TxnId[additionCount + 1];
-        System.arraycopy(additions, 0, result, 0, additionInsertPos);
-        System.arraycopy(additions, additionInsertPos, result, additionInsertPos + 1, additionCount - additionInsertPos);
-        result[additionInsertPos] = updateTxnId;
-        return result;
-    }
-
-    private static TxnId[] mergeAndFilterMissing(TxnId owner, TxnId[] current, TxnId[] additions, int additionCount)
-    {
-        Kinds kinds = owner.kind().witnesses();
-        int additionLength = additionCount;
-        for (int i = additionCount - 1 ; i >= 0 ; --i)
-        {
-            if (!kinds.test(additions[i].kind()))
-                --additionCount;
-        }
-
-        if (additionCount == 0)
-            return current;
-
-        TxnId[] buffer = cachedTxnIds().get(current.length + additionCount);
-        int i = 0, j = 0, count = 0;
-        while (i < additionLength && j < current.length)
-        {
-            if (kinds.test(additions[i].kind()))
-            {
-                int c = additions[i].compareTo(current[j]);
-                if (c < 0) buffer[count++] = additions[i++];
-                else buffer[count++] = current[j++];
-            }
-            else i++;
-        }
-        while (i < additionLength)
-        {
-            if (kinds.test(additions[i].kind()))
-                buffer[count++] = additions[i];
-            i++;
-        }
-        while (j < current.length)
-        {
-            buffer[count++] = current[j++];
-        }
-        Invariants.checkState(count == additionCount + current.length);
-        return cachedTxnIds().completeAndDiscard(buffer, count);
-    }
-
-    private static int to(TxnId txnId, Timestamp depsKnownBefore, TxnId[] missingSource, int missingCount, int missingLimit)
-    {
-        if (depsKnownBefore == txnId) return missingCount;
-        int to = Arrays.binarySearch(missingSource, 0, missingLimit, depsKnownBefore);
-        if (to < 0) to = -1 - to;
-        return to;
-    }
-
-    static class InfoWithAdditions
-    {
-        final TxnInfo info;
-        final TxnId[] additions;
-        final int additionCount;
-
-        InfoWithAdditions(TxnInfo info, TxnId[] additions, int additionCount)
-        {
-            this.info = info;
-            this.additions = additions;
-            this.additionCount = additionCount;
-        }
-    }
-
-    private Object computeInsert(int insertPos, TxnId txnId, InternalStatus newStatus, Command command)
-    {
-        return computeInfoAndAdditions(insertPos, -1, txnId, newStatus, command);
-    }
-
-    private Object computeUpdate(int updatePos, TxnId txnId, InternalStatus newStatus, Command command)
-    {
-        return computeInfoAndAdditions(updatePos, updatePos, txnId, newStatus, command);
-    }
-
-    private Object computeInfoAndAdditions(int insertPos, int updatePos, TxnId txnId, InternalStatus newStatus, Command command)
-    {
-        Timestamp executeAt = txnId;
-        if (newStatus.hasInfo)
-        {
-            executeAt = command.executeAt();
-            if (executeAt.equals(txnId)) executeAt = txnId;
-        }
-        Timestamp depsKnownBefore = newStatus.depsKnownBefore(txnId, executeAt);
-        return computeInfoAndAdditions(insertPos, updatePos, txnId, newStatus, executeAt, depsKnownBefore, command.partialDeps().keyDeps.txnIds(key));
-    }
-
-    private Object computeInfoAndAdditions(int insertPos, int updatePos, TxnId plainTxnId, InternalStatus newStatus, Timestamp executeAt, Timestamp depsKnownBefore, SortedList<TxnId> deps)
-    {
-        int depsKnownBeforePos;
-        if (depsKnownBefore == plainTxnId)
-        {
-            depsKnownBeforePos = insertPos;
-        }
-        else
-        {
-            depsKnownBeforePos = Arrays.binarySearch(txns, insertPos, txns.length, depsKnownBefore);
-            Invariants.checkState(depsKnownBeforePos < 0);
-            depsKnownBeforePos = -1 - depsKnownBeforePos;
-        }
-
-        TxnId[] additions = NO_TXNIDS, missing = NO_TXNIDS;
-        int additionCount = 0, missingCount = 0;
-
-        int depsIndex = deps.find(redundantBefore.shardRedundantBefore());
-        if (depsIndex < 0) depsIndex = -1 - depsIndex;
-        int txnIdsIndex = 0;
-        while (txnIdsIndex < depsKnownBeforePos && depsIndex < deps.size())
-        {
-            TxnInfo t = txns[txnIdsIndex];
-            TxnId d = deps.get(depsIndex);
-            int c = t.compareTo(d);
-            if (c == 0)
-            {
-                ++txnIdsIndex;
-                ++depsIndex;
-            }
-            else if (c < 0)
-            {
-                // we expect to be missing ourselves
-                // we also permit any transaction we have recorded as COMMITTED or later to be missing, as recovery will not need to consult our information
-                if (txnIdsIndex != updatePos && t.status.compareTo(COMMITTED) < 0 && plainTxnId.kind().witnesses(t))
-                {
-                    if (missingCount == missing.length)
-                        missing = cachedTxnIds().resize(missing, missingCount, Math.max(8, missingCount * 2));
-                    missing[missingCount++] = t.asPlainTxnId();
-                }
-                txnIdsIndex++;
-            }
-            else
-            {
-                if (additionCount >= additions.length)
-                    additions = cachedTxnIds().resize(additions, additionCount, Math.max(8, additionCount * 2));
-
-                additions[additionCount++] = d;
-                depsIndex++;
-            }
-        }
-
-        while (txnIdsIndex < depsKnownBeforePos)
-        {
-            if (txnIdsIndex != updatePos && txns[txnIdsIndex].status.compareTo(COMMITTED) < 0)
-            {
-                TxnId txnId = txns[txnIdsIndex].asPlainTxnId();
-                if ((plainTxnId.kind().witnesses(txnId)))
-                {
-                    if (missingCount == missing.length)
-                        missing = cachedTxnIds().resize(missing, missingCount, Math.max(8, missingCount * 2));
-                    missing[missingCount++] = txnId;
-                }
-            }
-            txnIdsIndex++;
-        }
-
-        while (depsIndex < deps.size())
-        {
-            if (additionCount >= additions.length)
-                additions = cachedTxnIds().resize(additions, additionCount, Math.max(8, additionCount * 2));
-            additions[additionCount++] = deps.get(depsIndex++);
-        }
-
-        TxnInfo info = TxnInfo.create(plainTxnId, newStatus, executeAt, cachedTxnIds().completeAndDiscard(missing, missingCount));
-        if (additionCount == 0)
-            return info;
-
-        return new InfoWithAdditions(info, additions, additionCount);
-    }
-
-    private CommandsForKey update(TxnInfo[] newTxns)
-    {
-        return new CommandsForKey(key, redundantBefore, newTxns, unmanageds);
-    }
-
-    // directly notify the progress log of any command we're now awaiting the coordination of (as the lowest TxnId not committed)
-    public CommandsForKey notifyAndUpdatePending(SafeCommandStore safeStore, Command command, CommandsForKey prevCfk)
-    {
-        return notifyAndUpdatePending(safeStore, command.txnId(), from(command.saveStatus()), command.executeAt(), prevCfk);
-    }
-
-    public CommandsForKey notifyAndUpdatePending(SafeCommandStore safeStore, TxnId updatedTxnId, InternalStatus newStatus, Timestamp newExecuteAt, CommandsForKey prevCfk)
-    {
-        TxnInfo prevInfo = prevCfk.get(updatedTxnId);
-
-        switch (newStatus)
-        {
-            default: throw new AssertionError("Unhandled InternalStatus: " + newStatus);
-            case TRANSITIVELY_KNOWN:
-            case PREACCEPTED:
-            case HISTORICAL:
-            case ACCEPTED:
-                break;
-
-            case STABLE:
-            case COMMITTED:
-                int compareWithNext = nextWrite == null ? -1 : newExecuteAt.compareTo(nextWrite.executeAt);
-                if (compareWithNext <= 0)
-                {
-                    if (newStatus == COMMITTED)
-                        break;
-
-                    // the updated transaction itself might be executable
-                    notify(safeStore, AnyGloballyVisible, next == null ? Timestamp.NONE : next.executeAt, newExecuteAt);
-                }
-                else
-                {
-                    // somebody waiting on us might now be ready to execute, but only if we execute after them and were not previously committed, but WERE previously known
-                    // if the next command executes after us, or executes before we were started
-                    // then our change in status cannot have triggered any change in immediate execution dependencies
-                    if (prevInfo == null || prevInfo.status == COMMITTED || nextWrite.executeAt.compareTo(updatedTxnId) < 0 || newExecuteAt.equals(updatedTxnId))
-                        break;
-
-                    notify(safeStore, updatedTxnId.kind().witnessedBy(), next.executeAt, nextWrite.executeAt);
-                }
-                break;
-
-            case APPLIED:
-            case INVALID_OR_TRUNCATED:
-                if (next != null)
-                    notify(safeStore, updatedTxnId.kind().witnessedBy(), next.executeAt, nextWrite != null ? nextWrite.executeAt : Timestamp.MAX);
-                break;
-        }
-
-        Unmanaged[] pending = this.unmanageds;
-        if (minUncommitted != null && (prevCfk.minUncommitted == null || !prevCfk.minUncommitted.equals(minUncommitted)))
-            notifyWaitingOnCommit(safeStore, minUncommitted);
-        if (newStatus.compareTo(COMMITTED) >= 0 && (prevInfo == null || prevInfo.status.compareTo(COMMITTED) < 0))
-            pending = notifyUnmanaged(safeStore, COMMIT, pending, minUncommitted == null ? Timestamp.MAX : minUncommitted, this);
-        if (minUncommitted == null || next != null)
-            pending = notifyUnmanaged(safeStore, APPLY, pending, next == null ? Timestamp.MAX : next.executeAt, this);
-
-        return new CommandsForKey(key, redundantBefore, txns, pending);
-    }
-
-    public CommandsForKey notifyAndUpdatePending(SafeCommandStore safeStore, CommandsForKey prevCfk)
-    {
-        Unmanaged[] pending = this.unmanageds;
-        if (minUncommitted != null && (prevCfk.minUncommitted == null || !prevCfk.minUncommitted.equals(minUncommitted)))
-            notifyWaitingOnCommit(safeStore, minUncommitted);
-        if (minUncommitted == null || next != null)
-            pending = notifyUnmanaged(safeStore, APPLY, pending, next == null ? Timestamp.MAX : next.executeAt, this);
-
-        return new CommandsForKey(key, redundantBefore, txns, pending);
-    }
-
-    private void notifyWaitingOnCommit(SafeCommandStore safeStore, TxnInfo uncommitted)
-    {
-        if (redundantBefore.endEpoch <= uncommitted.epoch())
-            return;
-
-        TxnId txnId = uncommitted.asPlainTxnId();
-        if (uncommitted.status.compareTo(PREACCEPTED) < 0)
-        {
-            Key key = this.key;
-            Keys keys = Keys.of(key);
-            safeStore = safeStore; // make unsafe for compiler to permit in lambda
-            safeStore.commandStore().execute(PreLoadContext.contextFor(txnId, keys, KeyHistory.COMMANDS), safeStore0 -> {
-                SafeCommand safeCommand0 = safeStore0.get(txnId);
-                safeCommand0.initialise();
-                Command command = safeCommand0.current();
-                Seekables<?, ?> keysOrRanges = command.keysOrRanges();
-                if (keysOrRanges == null || !keysOrRanges.contains(key))
-                {
-                    CommonAttributes.Mutable attrs = command.mutable();
-                    if (command.additionalKeysOrRanges() == null) attrs.additionalKeysOrRanges(keys);
-                    else attrs.additionalKeysOrRanges(keys.with((Keys)command.additionalKeysOrRanges()));
-                    safeCommand0.update(safeStore0, command.updateAttributes(attrs));
-                }
-                if (command.hasBeen(Status.Committed))
-                {
-                    // if we're committed but not invalidated, that means EITHER we have raced with a commit+
-                    // OR we adopted as a dependency a
-                    safeStore0.get(key).update(safeStore0, safeCommand0.current());
-                }
-                else
-                {
-                    // TODO (desired): we could complicate our state machine to replicate PreCommitted here, so we can simply wait for ReadyToExclude
-                    safeStore0.progressLog().waiting(txnId, WaitingToExecute, null, keys.toParticipants());
-                }
-             }).begin(safeStore.agent());
-        }
-        else
-        {
-            safeStore.progressLog().waiting(txnId, WaitingToExecute, null, Keys.of(key).toParticipants());
-        }
-    }
-
-    private static Unmanaged[] notifyUnmanaged(SafeCommandStore safeStore, Unmanaged.Pending pendingStatus, Unmanaged[] pending, Timestamp timestamp, CommandsForKey cfk)
-    {
-        switch (pendingStatus)
-        {
-            default: throw new AssertionError("Unhandled PendingRangeTransaction.Pending: " + pendingStatus);
-            case COMMIT:
-            {
-                // notify commit uses exclusive bounds, as we use minUncommitted
-                int end = findPendingCommit(pending, timestamp);
-                if (end < 0) end = -1 - end;
-
-                Unmanaged[] addPending = new Unmanaged[end];
-                int addPendingCount = 0;
-                for (int i = 0 ; i < end ; ++i)
-                {
-                    TxnId txnId = pending[i].txnId;
-                    SafeCommand safeCommand = safeStore.ifLoadedAndInitialised(txnId);
-                    if (safeCommand != null)
-                    {
-                        addPending[addPendingCount] = updatePending(safeStore, safeCommand, cfk);
-                        if (addPending[addPendingCount] != null) ++addPendingCount;
-                    }
-                    else updatePendingAsync(safeStore, txnId, cfk.key);
-                }
-
-                Arrays.sort(addPending, 0, addPendingCount);
-                return SortedArrays.linearUnion(pending, end, pending.length, addPending, 0, addPendingCount, Unmanaged::compareTo, ArrayBuffers.uncached(Unmanaged[]::new));
-            }
-            case APPLY:
-            {
-                int start = -1 - SortedArrays.binarySearch(pending, 0, pending.length, timestamp, (f, v) -> v.pending == APPLY ? -1 : 1, FAST);
-                int end = findPendingApply(pending, timestamp);
-                if (end < 0) end = -1 - end;
-
-                if (start == end)
-                    return pending;
-
-                for (int i = start; i < end ; ++i)
-                    removeWaitingOn(safeStore, pending[i].txnId, cfk.key);
-
-                Unmanaged[] newPending = new Unmanaged[pending.length - (end - start)];
-                System.arraycopy(pending, 0, newPending, 0, start);
-                System.arraycopy(pending, end, newPending, start, newPending.length - start);
-                return newPending;
-            }
-        }
-    }
-
-    private static void updatePendingAsync(SafeCommandStore safeStore, TxnId txnId, Key key)
-    {
-        PreLoadContext context = PreLoadContext.contextFor(txnId, Keys.of(key), KeyHistory.COMMANDS);
-        safeStore.commandStore().execute(context, safeStore0 -> {
-            SafeCommandsForKey safeCommandsForKey = safeStore0.get(key);
-            CommandsForKey prev = safeCommandsForKey.current();
-            Unmanaged addPending = updatePending(safeStore0, safeStore0.get(txnId), prev);
-            if (addPending != null)
-            {
-                Unmanaged[] newPending = SortedArrays.insert(prev.unmanageds, addPending, Unmanaged[]::new);
-                safeCommandsForKey.set(new CommandsForKey(prev.key, prev.redundantBefore, prev.txns, newPending));
-            }
-        }).begin(safeStore.agent());
-    }
-
-    private static Unmanaged updatePending(SafeCommandStore safeStore, SafeCommand safeCommand, CommandsForKey cfk)
-    {
-        if (safeCommand.current().hasBeen(Status.Truncated))
-            return null;
-
-        Command.Committed command = safeCommand.current().asCommitted();
-        TxnId waitingTxnId = command.txnId();
-        Timestamp waitingExecuteAt = command.executeAt();
-
-        SortedRelationList<TxnId> txnIds = command.partialDeps().keyDeps.txnIds(cfk.key);
-        int i = txnIds.find(cfk.shardRedundantBefore());
-        if (i < 0) i = -1 - i;
-
-        if (i < txnIds.size())
-        {
-            boolean readyToExecute = true;
-            Timestamp executesAt = null;
-            int j = SortedArrays.binarySearch(cfk.txns, 0, cfk.txns.length, txnIds.get(i), Timestamp::compareTo, FAST);
-            if (j < 0) j = -1 -j;
-            while (i < txnIds.size() && j < cfk.txns.length)
-            {
-                int c = txnIds.get(i).compareTo(cfk.txns[j]);
-                if (c > 0) ++j;
-                else if (c < 0) throw illegalState("Transaction not found: " + cfk.txns[j]);
-                else
-                {
-                    TxnInfo txn = cfk.txns[j];
-                    if (waitingTxnId.kind().awaitsOnlyDeps() || txn.executeAt.compareTo(waitingExecuteAt) < 0)
-                    {
-                        readyToExecute &= txn.status.compareTo(APPLIED) >= 0;
-                        executesAt = Timestamp.nonNullOrMax(executesAt, txn.executeAt);
-                    }
-                    ++i;
-                    ++j;
-                }
-            }
-
-            if (!readyToExecute)
-            {
-                if (waitingTxnId.kind().awaitsOnlyDeps() && executesAt != null)
-                {
-                    if (executesAt instanceof TxnInfo) executesAt = ((TxnInfo) executesAt).asPlainTxnId();
-                    if (executesAt.compareTo(command.waitingOn.executeAtLeast(Timestamp.NONE)) > 0)
-                    {
-                        Command.WaitingOn.Update waitingOn = new Command.WaitingOn.Update(command.waitingOn);
-                        waitingOn.updateExecuteAtLeast(executesAt);
-                        safeCommand.updateWaitingOn(waitingOn);
-                    }
-                }
-                return new Unmanaged(APPLY, command.txnId(), executesAt);
-            }
-        }
-
-        removeWaitingOn(safeStore, safeCommand, cfk.key, true);
-        return null;
-    }
-
-    private static int findPendingCommit(Unmanaged[] pending, Timestamp txnId)
-    {
-        return SortedArrays.binarySearch(pending, 0, pending.length, txnId, (f, v) -> {
-            if (v.pending == APPLY) return -1;
-            return f.compareTo(v.waitingUntil);
-        }, CEIL);
-    }
-
-    private static int findPendingApply(Unmanaged[] pending, Timestamp txnId)
-    {
-        return SortedArrays.binarySearch(pending, 0, pending.length, txnId, (f, v) -> {
-            if (v.pending == COMMIT) return 1;
-            return f.compareTo(v.waitingUntil);
-        }, CEIL);
-    }
-
-    public CommandsForKey registerUnmanaged(SafeCommandStore safeStore, SafeCommand safeCommand)
-    {
-        if (safeCommand.current().hasBeen(Status.Truncated))
-            return this;
-
-        Command.Committed command = safeCommand.current().asCommitted();
-        TxnId waitingTxnId = command.txnId();
-        Timestamp waitingExecuteAt = command.executeAt();
-
-        SortedRelationList<TxnId> txnIds = command.partialDeps().keyDeps.txnIds(key);
-        TxnId[] missing = cachedTxnIds().get(8);
-        int missingCount = 0;
-        int i = txnIds.find(shardRedundantBefore());
-        if (i < 0) i = -1 - i;
-        if (i < txnIds.size())
-        {
-            boolean readyToExecute = true;
-            boolean waitingToApply = true;
-            Timestamp executesAt = null;
-            int j = SortedArrays.binarySearch(txns, 0, txns.length, txnIds.get(i), Timestamp::compareTo, FAST);
-            if (j < 0) j = -1 -j;
-            while (i < txnIds.size())
-            {
-                int c = j == txns.length ? -1 : txnIds.get(i).compareTo(txns[j]);
-                if (c == 0)
-                {
-                    TxnInfo txn = txns[j];
-                    if (txn.status.compareTo(COMMITTED) < 0) readyToExecute = waitingToApply = false;
-                    else if (waitingTxnId.kind().awaitsOnlyDeps() || txn.executeAt.compareTo(waitingExecuteAt) < 0)
-                    {
-                        readyToExecute &= txn.status.compareTo(APPLIED) >= 0;
-                        executesAt = Timestamp.nonNullOrMax(executesAt, txn.executeAt);
-                    }
-                    ++i;
-                    ++j;
-                }
-                else if (c > 0) ++j;
-                else
-                {
-                    readyToExecute = waitingToApply = false;
-                    if (missingCount == missing.length)
-                        missing = cachedTxnIds().resize(missing, missingCount, missingCount * 2);
-                    missing[missingCount++] = txnIds.get(i++);
-                }
-            }
-
-            if (!readyToExecute)
-            {
-                TxnInfo[] newTxns = txns;
-                if (missingCount > 0)
-                {
-                    newTxns = new TxnInfo[missingCount + txns.length];
-                    i = 0;
-                    j = 0;
-                    int count = 0;
-                    while (i < missingCount && j < txns.length)
-                    {
-                        int c = missing[i].compareTo(txns[j]);
-                        if (c < 0) newTxns[count++] = TxnInfo.create(missing[i++], TRANSITIVELY_KNOWN);
-                        else newTxns[count++] = txns[j++];
-                    }
-                    while (i < missingCount)
-                        newTxns[count++] = TxnInfo.create(missing[i++], TRANSITIVELY_KNOWN);
-                    while (j < txns.length)
-                        newTxns[count++] = txns[j++];
-                }
-
-                Unmanaged newPendingRecord;
-                if (waitingToApply)
-                {
-                    if (waitingTxnId.kind().awaitsOnlyDeps() && executesAt != null)
-                    {
-                        if (executesAt.compareTo(command.waitingOn.executeAtLeast(Timestamp.NONE)) > 0)
-                        {
-                            Command.WaitingOn.Update waitingOn = new Command.WaitingOn.Update(command.waitingOn);
-                            waitingOn.updateExecuteAtLeast(executesAt);
-                            safeCommand.updateWaitingOn(waitingOn);
-                        }
-                    }
-                    if (executesAt != null)
-                        if (executesAt instanceof TxnInfo) executesAt = ((TxnInfo) executesAt).asPlainTxnId();
-
-                    newPendingRecord = new Unmanaged(APPLY, command.txnId(), executesAt);
-                }
-                else newPendingRecord = new Unmanaged(COMMIT, command.txnId(), txnIds.get(txnIds.size() - 1));
-                Unmanaged[] newPending = SortedArrays.insert(unmanageds, newPendingRecord, Unmanaged[]::new);
-                return new CommandsForKey(key, redundantBefore, newTxns, newPending);
-            }
-        }
-
-        removeWaitingOn(safeStore, safeCommand, key, true);
-        return this;
-    }
-
-    // minVisitIndex is the index we guarantee to visit from contiguously, i.e. ignoring any jumps backwards due to executeAt
-    private void notify(SafeCommandStore safeStore, Kinds kinds, Timestamp from, Timestamp to)
-    {
-        int start = SortedArrays.binarySearch(committed, 0, committed.length, from, (f, v) -> f.compareTo(v.executeAt), FAST);
-        if (start < 0) start = -1 - start;
-        int end = SortedArrays.binarySearch(committed, start, committed.length, to, (f, v) -> f.compareTo(v.executeAt), FAST);
-        if (end < 0) end = -1 - end;
-        else ++end;
-
-        notify(safeStore, kinds, start, end);
-    }
-
-    private void notify(SafeCommandStore safeStore, Kinds kinds, int start, int end)
-    {
-        Participants<?> asParticipants = null;
-        int uncommittedIndex = minUncommitted == null ? txns.length : -1;
-        int unappliedReadCount = 0, unappliedWriteCount = 0, unappliedSyncPointCount = 0;
-
-        for (int i = start ; i < end ; ++i)
-        {
-            TxnInfo txn = committed[i];
-            Kind kind = txn.kind();
-            switch (txn.status)
-            {
-                case APPLIED:
-                case INVALID_OR_TRUNCATED:
-                    continue;
-
-                case COMMITTED:
-                    // cannot execute as dependencies not stable, so notify progress log to get or decide stable deps
-                    if (kinds.test(kind))
-                    {
-                        if (asParticipants == null)
-                            asParticipants = Keys.of(key).toParticipants();
-                        TxnId txnId = new TxnId(txn);
-                        safeStore.progressLog().waiting(txnId, WaitingToExecute, null, asParticipants);
-                    }
-                    break;
-
-                case STABLE:
-                    if (uncommittedIndex < 0)
-                        uncommittedIndex = SortedArrays.binarySearch(txns, 0, txns.length, minUncommitted, Timestamp::compareTo, FAST);
-
-                    int nextUncommittedIndex = SortedArrays.exponentialSearch(txns, uncommittedIndex, txns.length, txn.executeAt, Timestamp::compareTo, FAST);
-                    if (nextUncommittedIndex < 0) nextUncommittedIndex = -1 -nextUncommittedIndex;
-                    while (uncommittedIndex < nextUncommittedIndex)
-                    {
-                        TxnInfo backfillTxn = txns[uncommittedIndex++];
-                        if (backfillTxn.status.compareTo(COMMITTED) >= 0) continue;
-                        Kind backfillKind = backfillTxn.kind();
-                        switch (backfillKind)
-                        {
-                            default: throw new AssertionError("Unhandled Txn.Kind: " + backfillKind);
-                            case LocalOnly:
-                            case EphemeralRead:
-                                throw illegalState("Invalid Txn.Kind for CommandsForKey: " + backfillKind);
-
-                            case Write:
-                                ++unappliedWriteCount;
-                                break;
-
-                            case Read:
-                                ++unappliedReadCount;
-                                break;
-
-                            case SyncPoint:
-                            case ExclusiveSyncPoint:
-                                ++unappliedSyncPointCount;
-                                break;
-                        }
-                    }
-
-                    int expectMissingCount = 0;
-                    switch (txn.kind())
-                    {
-                        default: throw new AssertionError("Unhandled Txn.Kind: " + kind);
-                        case LocalOnly:
-                        case EphemeralRead:
-                            throw illegalState("Invalid Txn.Kind for CommandsForKey: " + kind);
-
-                        case SyncPoint:
-                        case ExclusiveSyncPoint:
-                            expectMissingCount += unappliedSyncPointCount;
-
-                        case Write:
-                            expectMissingCount += unappliedReadCount;
-
-                        case Read:
-                            expectMissingCount += unappliedWriteCount;
-                    }
-
-                    // We remove committed transactions from the missing set, since they no longer need them there
-                    // So the missing collection represents only those uncommitted transaction ids that a transaction
-                    // witnesses/conflicts with. So we may simply count all of those we know of with a lower TxnId,
-                    // and if the count is the same then we are not awaiting any of them for execution and can remove
-                    // this command's dependency on this key for execution.
-                    TxnId[] missing = txn.missing();
-                    int missingCount = missing.length;
-                    if (missingCount > 0 && minUncommitted != null)
-                    {
-                        int missingFrom = SortedArrays.binarySearch(missing, 0, missing.length, minUncommitted, TxnId::compareTo, FAST);
-                        if (missingFrom < 0) missingFrom = -1 - missingFrom;
-                        missingCount -= missingFrom;
-                    }
-                    if (expectMissingCount == missingCount)
-                    {
-                        TxnId txnId = new TxnId(txn);
-                        removeWaitingOn(safeStore, txnId, key);
-                        if (asParticipants == null)
-                            asParticipants = Keys.of(key).toParticipants();
-                        safeStore.progressLog().waiting(txnId, WaitingToApply, null, asParticipants);
-                    }
-            }
-
-            switch (kind)
-            {
-                default: throw new AssertionError("Unhandled Txn.Kind: " + kind);
-                case LocalOnly:
-                case EphemeralRead:
-                    throw illegalState("Invalid Txn.Kind for CommandsForKey: " + kind);
-
-                case Write:
-                    ++unappliedWriteCount;
-                    break;
-
-                case Read:
-                    ++unappliedReadCount;
-                    break;
-
-                case SyncPoint:
-                case ExclusiveSyncPoint:
-                    ++unappliedSyncPointCount;
-                    break;
-            }
-        }
-    }
-
-    private static void removeWaitingOn(SafeCommandStore safeStore, TxnId txnId, Key key)
-    {
-        SafeCommand safeCommand = safeStore.ifLoadedAndInitialised(txnId);
-        if (safeCommand != null) removeWaitingOn(safeStore, safeCommand, key, true);
-        else
-        {
-            PreLoadContext context = PreLoadContext.contextFor(txnId);
-            safeStore.commandStore().execute(context, safeStore0 -> removeWaitingOn(safeStore0, safeStore0.get(txnId), key, false))
-                     .begin(safeStore.agent());
-        }
-    }
-
-    private static void removeWaitingOn(SafeCommandStore safeStore, SafeCommand safeCommand, Key key, boolean executeAsync)
-    {
-        Commands.removeWaitingOnKeyAndMaybeExecute(safeStore, safeCommand, key, executeAsync);
-    }
-
-    public CommandsForKey withRedundantBefore(RedundantBefore.Entry newRedundantBefore)
-    {
-        Invariants.checkArgument(newRedundantBefore.shardRedundantBefore().compareTo(shardRedundantBefore()) >= 0, "Expect new RedundantBefore.Entry shardAppliedOrInvalidatedBefore to be ahead of existing one");
-
-        if (newRedundantBefore.equals(redundantBefore))
-            return this;
-
-        TxnInfo[] newTxns = txns;
-        if (newRedundantBefore.shardRedundantBefore().compareTo(shardRedundantBefore()) >= 0)
-        {
-            int pos = insertPos(0, newRedundantBefore.shardRedundantBefore());
-            if (pos != 0)
-            {
-                newTxns = Arrays.copyOfRange(txns, pos, txns.length);
-                for (int i = 0 ; i < newTxns.length ; ++i)
-                {
-                    TxnInfo txn = newTxns[i];
-                    TxnId[] missing = txn.missing();
-                    if (missing == NO_TXNIDS) continue;
-                    int j = Arrays.binarySearch(missing, newRedundantBefore.shardRedundantBefore());
-                    if (j < 0) j = -1 - j;
-                    if (j <= 0) continue;
-                    missing = j == missing.length ? NO_TXNIDS : Arrays.copyOfRange(missing, j, missing.length);
-                    newTxns[i] = txn.update(missing);
-                }
-            }
-        }
-
-        // TODO (expected): filter pending unmanageds
-        return new CommandsForKey(key, newRedundantBefore, newTxns, unmanageds);
-    }
-
-    public CommandsForKey registerHistorical(TxnId txnId)
-    {
-        if (txnId.compareTo(shardRedundantBefore()) < 0)
-            return this;
-
-        int i = Arrays.binarySearch(txns, txnId);
-        if (i >= 0)
-            return txns[i].status.compareTo(HISTORICAL) >= 0 ? this : update(i, txnId, txns[i], TxnInfo.create(txnId, HISTORICAL, txnId));
-
-        return insert(-1 - i, txnId, TxnInfo.create(txnId, HISTORICAL, txnId));
-    }
-
-    private int insertPos(int min, Timestamp timestamp)
-    {
-        int i = Arrays.binarySearch(txns, min, txns.length, timestamp);
-        if (i < 0) i = -1 -i;
-        return i;
-    }
-
-    public TxnId findFirst()
-    {
-        return txns.length > 0 ? txns[0] : null;
-    }
-
-    @Override
-    public boolean equals(Object o)
-    {
-        if (this == o) return true;
-        if (o == null || getClass() != o.getClass()) return false;
-        CommandsForKey that = (CommandsForKey) o;
-        return Objects.equals(key, that.key)
-               && Arrays.equals(txns, that.txns)
-               && Arrays.equals(unmanageds, that.unmanageds);
-    }
-
-    @Override
-    public int hashCode()
-    {
-        throw new UnsupportedOperationException();
-    }
-}
diff --git a/accord-core/src/main/java/accord/local/PreLoadContext.java b/accord-core/src/main/java/accord/local/PreLoadContext.java
index 45de8ff3..25c68efd 100644
--- a/accord-core/src/main/java/accord/local/PreLoadContext.java
+++ b/accord-core/src/main/java/accord/local/PreLoadContext.java
@@ -19,10 +19,13 @@
 package accord.local;
 
 import accord.api.Key;
+import accord.local.cfk.CommandsForKey;
 import accord.primitives.Keys;
 import accord.primitives.Seekables;
 import accord.primitives.TxnId;
 import com.google.common.collect.Iterators;
+
+import accord.utils.Invariants;
 import net.nicoulaj.compilecommand.annotations.Inline;
 
 import com.google.common.collect.Sets;
@@ -134,6 +137,7 @@ public interface PreLoadContext
 
     static PreLoadContext contextFor(TxnId primary, Collection<TxnId> additional, Seekables<?, ?> keys, KeyHistory keyHistory)
     {
+        Invariants.checkState(!additional.contains(primary));
         return new PreLoadContext()
         {
             @Override
diff --git a/accord-core/src/main/java/accord/local/RedundantBefore.java b/accord-core/src/main/java/accord/local/RedundantBefore.java
index 0189e74c..dbdfa000 100644
--- a/accord-core/src/main/java/accord/local/RedundantBefore.java
+++ b/accord-core/src/main/java/accord/local/RedundantBefore.java
@@ -109,6 +109,8 @@ public class RedundantBefore extends ReducingRangeMap<RedundantBefore.Entry>
             this.shardAppliedOrInvalidatedBefore = shardAppliedOrInvalidatedBefore;
             this.bootstrappedAt = bootstrappedAt;
             this.staleUntilAtLeast = staleUntilAtLeast;
+            Invariants.checkArgument(locallyAppliedOrInvalidatedBefore.equals(TxnId.NONE) || locallyAppliedOrInvalidatedBefore.domain().isRange());
+            Invariants.checkArgument(shardAppliedOrInvalidatedBefore.equals(TxnId.NONE) || shardAppliedOrInvalidatedBefore.domain().isRange());
         }
 
         public static Entry reduce(Entry a, Entry b)
@@ -460,9 +462,9 @@ public class RedundantBefore extends ReducingRangeMap<RedundantBefore.Entry>
         return staleRanges;
     }
 
-    static class Builder extends AbstractIntervalBuilder<RoutingKey, Entry, RedundantBefore>
+    public static class Builder extends AbstractIntervalBuilder<RoutingKey, Entry, RedundantBefore>
     {
-        protected Builder(boolean inclusiveEnds, int capacity)
+        public Builder(boolean inclusiveEnds, int capacity)
         {
             super(inclusiveEnds, capacity);
         }
diff --git a/accord-core/src/main/java/accord/local/SafeCommand.java b/accord-core/src/main/java/accord/local/SafeCommand.java
index 0a0cb6e3..dbc4b54b 100644
--- a/accord-core/src/main/java/accord/local/SafeCommand.java
+++ b/accord-core/src/main/java/accord/local/SafeCommand.java
@@ -56,11 +56,7 @@ public abstract class SafeCommand
         return txnId;
     }
 
-    // TODO (expected): it isn't ideal to pass keysOrRanges for the special case of Accept. We can either:
-    //   1 - remove the special case that permits accept without the definition
-    //   2 - store some pseudo transaction with only the keys
-    //   3 - just come up with something a bit neater
-    <C extends Command> C update(SafeCommandStore safeStore, C update)
+    public <C extends Command> C update(SafeCommandStore safeStore, C update)
     {
         Command prev = current();
         if (prev == update)
diff --git a/accord-core/src/main/java/accord/local/SafeCommandStore.java b/accord-core/src/main/java/accord/local/SafeCommandStore.java
index 7aea6790..0cea6eb3 100644
--- a/accord-core/src/main/java/accord/local/SafeCommandStore.java
+++ b/accord-core/src/main/java/accord/local/SafeCommandStore.java
@@ -27,6 +27,8 @@ import accord.api.Key;
 import accord.api.ProgressLog;
 import accord.api.RoutingKey;
 import accord.impl.ErasedSafeCommand;
+import accord.local.cfk.CommandsForKey;
+import accord.local.cfk.SafeCommandsForKey;
 import accord.primitives.Deps;
 import accord.primitives.EpochSupplier;
 import accord.primitives.Keys;
@@ -141,7 +143,7 @@ public abstract class SafeCommandStore
      *
      * This permits efficient operation when a transaction involved in processing another transaction happens to be in memory.
      */
-    public final SafeCommand ifLoadedAndInitialised(TxnId txnId)
+    public SafeCommand ifLoadedAndInitialised(TxnId txnId)
     {
         SafeCommand safeCommand = getInternalIfLoadedAndInitialised(txnId);
         if (safeCommand == null)
@@ -155,6 +157,11 @@ public abstract class SafeCommandStore
         return maybeTruncate(safeCommand, safeCommand.current(), null, null);
     }
 
+    public SafeCommand unsafeGet(TxnId txnId)
+    {
+        return get(txnId);
+    }
+
     protected SafeCommandsForKey maybeTruncate(SafeCommandsForKey safeCfk)
     {
         RedundantBefore.Entry entry = commandStore().redundantBefore().get(safeCfk.key().toUnseekable());
@@ -194,11 +201,6 @@ public abstract class SafeCommandStore
     protected abstract SafeCommandsForKey getInternalIfLoadedAndInitialised(Key key);
     public abstract boolean canExecuteWith(PreLoadContext context);
 
-    public void load(Command loaded)
-    {
-        update(null, loaded);
-    }
-
     protected void update(Command prev, Command updated)
     {
         updateMaxConflicts(prev, updated);
@@ -219,58 +221,64 @@ public abstract class SafeCommandStore
         commandStore().updateMaxConflicts(prev, updated);
     }
 
-    private void updateCommandsForKey(Command prev, Command updated)
+    private void updateCommandsForKey(Command prev, Command next)
     {
-        if (!CommandsForKey.needsUpdate(prev, updated))
+        if (!CommandsForKey.needsUpdate(prev, next))
             return;
 
-        TxnId txnId = updated.txnId();
-        Keys keys;
-        if (txnId.domain().isKey() && txnId.kind().isGloballyVisible())
+        TxnId txnId = next.txnId();
+        if (CommandsForKey.manages(txnId)) updateManagedCommandsForKey(this, prev, next);
+        if (!CommandsForKey.managesExecution(txnId) && next.hasBeen(Status.Stable) && !next.hasBeen(Status.Truncated) && !prev.hasBeen(Status.Stable))
+            updateUnmanagedExecutionCommandsForKey(this, next);
+    }
+
+    private static void updateManagedCommandsForKey(SafeCommandStore safeStore, Command prev, Command next)
+    {
+        TxnId txnId = next.txnId();
+        Keys keys = (Keys)next.keysOrRanges();
+        if (keys == null || next.hasBeen(Status.Truncated)) keys = (Keys)prev.keysOrRanges();
+        if (keys == null)
+            return;
+
+        // TODO (required): additionalKeysOrRanges may not be being handled entirely correctly here, though it may not matter.
+        //    Once committed without a given key, we should be effectively erasing the command from that CFK
+        PreLoadContext context = PreLoadContext.contextFor(txnId, keys, COMMANDS);
+        // TODO (expected): execute immediately for any keys we already have loaded, and save only those we haven't for later
+        if (safeStore.canExecuteWith(context))
         {
-            keys = (Keys)updated.keysOrRanges();
-            if (keys == null || updated.hasBeen(Status.Truncated)) keys = (Keys)prev.keysOrRanges();
-            if (keys == null)
-                return;
-
-            PreLoadContext context = PreLoadContext.contextFor(txnId, keys, COMMANDS);
-            // TODO (expected): execute immediately for any keys we already have loaded, and save only those we haven't for later
-            if (canExecuteWith(context))
+            for (Key key : keys)
             {
-                for (Key key : keys)
-                {
-                    get(key).update(this, prev, updated);
-                }
-            }
-            else
-            {
-                commandStore().execute(context, safeStore -> safeStore.updateCommandsForKey(prev, updated))
-                              .begin(commandStore().agent);
+                safeStore.get(key).update(safeStore, next);
             }
         }
         else
         {
-            if (!updated.hasBeen(Status.Stable) || prev.hasBeen(Status.Stable) || updated.hasBeen(Status.Truncated))
-                return;
-
-            keys = updated.asCommitted().waitingOn.keys;
-            // TODO (required): consider how execution works for transactions that await future deps and where the command store inherits additional keys in execution epoch
-            Ranges ranges = ranges().allAt(updated.executeAt());
-            PreLoadContext context = PreLoadContext.contextFor(txnId, keys, COMMANDS);
-            // TODO (expected): execute immediately for any keys we already have loaded, and save only those we haven't for later
-            if (canExecuteWith(context))
-            {
-                Routables.foldl(keys, ranges, (self, t, key, o, i) -> {
-                    self.get(key).registerUnmanaged(self, self.get(t));
-                    return null;
-                }, this, txnId, null, i->false);
-            }
-            else
-            {
-                commandStore().execute(context, safeStore -> safeStore.updateCommandsForKey(prev, updated))
-                              .begin(commandStore().agent);
-            }
+            safeStore = safeStore; // prevent accidental usage inside lambda
+            safeStore.commandStore().execute(context, safeStore0 -> updateManagedCommandsForKey(safeStore0, prev, next))
+                          .begin(safeStore.commandStore().agent);
+        }
+    }
 
+    private static void updateUnmanagedExecutionCommandsForKey(SafeCommandStore safeStore, Command next)
+    {
+        TxnId txnId = next.txnId();
+        Keys keys = next.asCommitted().waitingOn.keys;
+        // TODO (required): consider how execution works for transactions that await future deps and where the command store inherits additional keys in execution epoch
+        Ranges ranges = safeStore.ranges().allAt(next.executeAt());
+        PreLoadContext context = PreLoadContext.contextFor(txnId, keys, COMMANDS);
+        // TODO (expected): execute immediately for any keys we already have loaded, and save only those we haven't for later
+        if (safeStore.canExecuteWith(context))
+        {
+            Routables.foldl(keys, ranges, (ss, t, key, o, i) -> {
+                ss.get(key).registerUnmanaged(ss, ss.get(t));
+                return null;
+            }, safeStore, txnId, null, i->false);
+        }
+        else
+        {
+            safeStore = safeStore;
+            safeStore.commandStore().execute(context, safeStore0 -> updateUnmanagedExecutionCommandsForKey(safeStore0, next))
+                          .begin(safeStore.commandStore().agent);
         }
     }
 
diff --git a/accord-core/src/main/java/accord/local/SafeCommandsForKey.java b/accord-core/src/main/java/accord/local/SafeCommandsForKey.java
deleted file mode 100644
index b4e74e4d..00000000
--- a/accord-core/src/main/java/accord/local/SafeCommandsForKey.java
+++ /dev/null
@@ -1,99 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package accord.local;
-
-import accord.api.Key;
-import accord.impl.SafeState;
-import accord.primitives.TxnId;
-
-import static accord.local.CommandsForKey.InternalStatus.HISTORICAL;
-
-public abstract class SafeCommandsForKey implements SafeState<CommandsForKey>
-{
-    private final Key key;
-
-    public SafeCommandsForKey(Key key)
-    {
-        this.key = key;
-    }
-
-    protected abstract void set(CommandsForKey update);
-
-    public Key key()
-    {
-        return key;
-    }
-
-    CommandsForKey update(SafeCommandStore safeStore, Command prevCommand, Command nextCommand)
-    {
-        CommandsForKey prevCfk = current();
-        CommandsForKey nextCfk = prevCfk.update(prevCommand, nextCommand);
-        if (nextCfk == prevCfk)
-            return prevCfk;
-        set(nextCfk.notifyAndUpdatePending(safeStore, nextCommand, prevCfk));
-        return nextCfk;
-    }
-
-    CommandsForKey update(SafeCommandStore safeStore, Command nextCommand)
-    {
-        CommandsForKey prevCfk = current();
-        CommandsForKey nextCfk = prevCfk.update(null, nextCommand, false);
-        if (nextCfk == prevCfk)
-            return prevCfk;
-        set(nextCfk.notifyAndUpdatePending(safeStore, nextCommand, prevCfk));
-        return nextCfk;
-    }
-
-    CommandsForKey registerUnmanaged(SafeCommandStore safeStore, SafeCommand unmanaged)
-    {
-        CommandsForKey prev = current();
-        CommandsForKey next = prev.registerUnmanaged(safeStore, unmanaged);
-        if (next != prev)
-            set(next.notifyAndUpdatePending(safeStore, prev));
-        return next;
-    }
-
-    public CommandsForKey registerHistorical(SafeCommandStore safeStore, TxnId txnId)
-    {
-        CommandsForKey prev = current();
-        CommandsForKey next = prev.registerHistorical(txnId);
-        if (next != prev)
-            set(next.notifyAndUpdatePending(safeStore, txnId, HISTORICAL, txnId, prev));
-        return next;
-    }
-
-    public CommandsForKey updateRedundantBefore(SafeCommandStore safeStore, RedundantBefore.Entry redundantBefore)
-    {
-        CommandsForKey prev = current();
-        CommandsForKey next = prev.withRedundantBefore(redundantBefore);
-        if (next != prev)
-            set(next.notifyAndUpdatePending(safeStore, prev));
-        return next;
-    }
-
-    public void initialize()
-    {
-        set(new CommandsForKey(key));
-    }
-
-    public void refresh(SafeCommandStore safeStore)
-    {
-        updateRedundantBefore(safeStore, safeStore.commandStore().redundantBefore().get(key));
-    }
-}
diff --git a/accord-core/src/main/java/accord/local/Status.java b/accord-core/src/main/java/accord/local/Status.java
index 3719993c..c80918fa 100644
--- a/accord-core/src/main/java/accord/local/Status.java
+++ b/accord-core/src/main/java/accord/local/Status.java
@@ -159,7 +159,7 @@ public enum Status
 
         public Known atLeast(Known that)
         {
-            // TODO (expected): validate no inconsistent combos
+            Invariants.checkArgument(compatibleWith(that));
             KnownRoute maxRoute = route.atLeast(that.route);
             Definition maxDefinition = definition.atLeast(that.definition);
             KnownExecuteAt maxExecuteAt = executeAt.atLeast(that.executeAt);
@@ -168,8 +168,25 @@ public enum Status
             return selectOrCreate(that, maxRoute, maxDefinition, maxExecuteAt, maxDeps, maxOutcome);
         }
 
+        public Known min(Known that)
+        {
+            Invariants.checkArgument(compatibleWith(that));
+            KnownRoute minRoute = min(route, that.route);
+            Definition minDefinition = min(definition, that.definition);
+            KnownExecuteAt minExecuteAt = min(executeAt, that.executeAt);
+            KnownDeps minDeps = min(deps, that.deps);
+            Outcome minOutcome = min(outcome, that.outcome);
+            return selectOrCreate(that, minRoute, minDefinition, minExecuteAt, minDeps, minOutcome);
+        }
+
+        static <E extends Enum<E>> E min(E a, E b)
+        {
+            return a.compareTo(b) <= 0 ? a : b;
+        }
+
         public Known reduce(Known that)
         {
+            Invariants.checkArgument(compatibleWith(that));
             KnownRoute maxRoute = route.reduce(that.route);
             Definition minDefinition = definition.reduce(that.definition);
             KnownExecuteAt maxExecuteAt = executeAt.reduce(that.executeAt);
@@ -188,6 +205,13 @@ public enum Status
             return selectOrCreate(maxRoute, minDefinition, maxExecuteAt, minDeps, maxOutcome);
         }
 
+        boolean compatibleWith(Known that)
+        {
+            return executeAt.compatibleWith(that.executeAt)
+                   && deps.compatibleWith(that.deps)
+                   && outcome.compatibleWith(that.outcome);
+        }
+
         @Nonnull
         private Known selectOrCreate(Known with, KnownRoute maxRoute, Definition maxDefinition, KnownExecuteAt maxExecuteAt, KnownDeps maxDeps, Outcome maxOutcome)
         {
@@ -320,6 +344,10 @@ public enum Status
             return definition.isKnown();
         }
 
+        // TODO (expected): this isn't exactly correct, though it is probably fine:
+        //  outcome.isDecided includes Erased and Invalidated; Invalidated is definitely fine because we only use this to infer invalidation
+        //  whereas for Erased, we are inaccurate in the safer direction (saying it is Decided to avoid inferring Invalidated when we don't know),
+        //  but it would be good to tidy this up - either by clarifying the semantics or the naming
         public boolean hasDefinitionBeenKnown()
         {
             return definition.isKnown() || outcome.isDecided();
@@ -534,6 +562,15 @@ public enum Status
         {
             return this == ExecuteAtUnknown;
         }
+
+        public boolean compatibleWith(KnownExecuteAt that)
+        {
+            if (this == that) return true;
+            int c = compareTo(that);
+            KnownExecuteAt max = c >= 0 ? this : that;
+            KnownExecuteAt min = c <= 0 ? this : that;
+            return max != NoExecuteAt || min != ExecuteAtKnown;
+        }
     }
 
     public enum KnownDeps
@@ -633,6 +670,15 @@ public enum Status
         {
             return this == NoDeps ? NoDeps : DepsUnknown;
         }
+
+        public boolean compatibleWith(KnownDeps that)
+        {
+            if (this == that) return true;
+            int c = compareTo(that);
+            KnownDeps max = c >= 0 ? this : that;
+            KnownDeps min = c <= 0 ? this : that;
+            return max != NoDeps || (min != DepsCommitted && min != DepsKnown);
+        }
     }
 
     /**
@@ -646,7 +692,7 @@ public enum Status
         DefinitionUnknown,
 
         /**
-         * The definition was known, but has been erased
+         * The definition was perhaps known previously, but has since been erased
          */
         DefinitionErased,
 
@@ -786,6 +832,15 @@ public enum Status
         {
             return this != Unknown;
         }
+
+        public boolean compatibleWith(Outcome that)
+        {
+            if (this == that) return true;
+            int c = compareTo(that);
+            Outcome max = c >= 0 ? this : that;
+            Outcome min = c <= 0 ? this : that;
+            return max != Invalidated || (min != Apply && min != WasApply);
+        }
     }
 
     public enum LogicalEpoch
diff --git a/accord-core/src/main/java/accord/local/cfk/CommandsForKey.java b/accord-core/src/main/java/accord/local/cfk/CommandsForKey.java
new file mode 100644
index 00000000..162b4fe8
--- /dev/null
+++ b/accord-core/src/main/java/accord/local/cfk/CommandsForKey.java
@@ -0,0 +1,1471 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package accord.local.cfk;
+
+import java.util.Arrays;
+import java.util.EnumMap;
+import java.util.Objects;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import com.google.common.annotations.VisibleForTesting;
+
+import accord.api.Key;
+
+import accord.api.VisibleForImplementation;
+import accord.impl.CommandsSummary;
+import accord.local.Command;
+import accord.local.RedundantBefore;
+import accord.local.SafeCommand;
+import accord.local.SafeCommandStore;
+import accord.local.SafeCommandStore.CommandFunction;
+import accord.local.SafeCommandStore.TestDep;
+import accord.local.SafeCommandStore.TestStartedAt;
+import accord.local.SafeCommandStore.TestStatus;
+import accord.local.SaveStatus;
+import accord.local.Status;
+import accord.primitives.Ballot;
+import accord.primitives.Keys;
+import accord.primitives.Participants;
+import accord.primitives.Timestamp;
+import accord.primitives.Txn.Kind;
+import accord.primitives.Txn.Kind.Kinds;
+import accord.primitives.TxnId;
+import accord.utils.Invariants;
+import accord.utils.SortedArrays;
+import accord.utils.btree.BTree;
+
+import static accord.local.cfk.CommandsForKey.InternalStatus.ACCEPTED;
+import static accord.local.cfk.CommandsForKey.InternalStatus.APPLIED;
+import static accord.local.cfk.CommandsForKey.InternalStatus.COMMITTED;
+import static accord.local.cfk.CommandsForKey.InternalStatus.PREACCEPTED_OR_ACCEPTED_INVALIDATE;
+import static accord.local.cfk.CommandsForKey.InternalStatus.STABLE;
+import static accord.local.cfk.CommandsForKey.InternalStatus.HISTORICAL;
+import static accord.local.cfk.CommandsForKey.InternalStatus.INVALID_OR_TRUNCATED_OR_UNMANAGED_COMMITTED;
+import static accord.local.cfk.CommandsForKey.InternalStatus.TRANSITIVELY_KNOWN;
+import static accord.local.cfk.Pruning.isWaitingOnPruned;
+import static accord.local.cfk.Pruning.loadingPrunedFor;
+import static accord.local.cfk.Updating.insertOrUpdate;
+import static accord.local.SafeCommandStore.TestDep.ANY_DEPS;
+import static accord.local.SafeCommandStore.TestDep.WITH;
+import static accord.local.SaveStatus.LocalExecution.WaitingToApply;
+import static accord.local.SaveStatus.LocalExecution.WaitingToExecute;
+import static accord.local.cfk.Utils.removeRedundantMissing;
+import static accord.primitives.Txn.Kind.Kinds.AnyGloballyVisible;
+import static accord.primitives.Txn.Kind.Write;
+import static accord.utils.Invariants.Paranoia.LINEAR;
+import static accord.utils.Invariants.Paranoia.NONE;
+import static accord.utils.Invariants.Paranoia.SUPERLINEAR;
+import static accord.utils.Invariants.ParanoiaCostFactor.LOW;
+import static accord.utils.Invariants.checkNonNegative;
+import static accord.utils.Invariants.illegalState;
+import static accord.utils.Invariants.isParanoid;
+import static accord.utils.Invariants.testParanoia;
+import static accord.utils.SortedArrays.Search.FAST;
+
+/**
+ * <h2>Introduction</h2>
+ * A specialised collection for efficiently representing and querying everything we need for making coordination
+ * and recovery decisions about a key's command conflicts.
+ *
+ * Every command we know about that is not shard-redundant is listed in the {@code byId} collection, which is sorted by {@code TxnId}.
+ * This includes all transitive dependencies we have witnessed via other transactions, but not witnessed directly.
+ *
+ * <h2>Contents</h2>
+ * This collection tracks various transactions differently:
+ * - Range transactions are tracked ONLY as dependencies; if no managed key transactions witness a range transaction
+ *   it will not be tracked here. The dependencies of range transactions are not themselves tracked at all.
+ *   A range transaction that depends on some key for execution will be registered as an unmanaged transaction
+ *   to track when it may be executed.
+ * - Key (Exclusive)?SyncPoints are tracked fully until execution; we fully encode their dependencies, and track their lifecycle.
+ *   This permits them to be consulted for recovery. Once they are stable, they will be registered as unmanaged transactions for execution.
+ * - Key Reads and Writes are first class citizens. We fully encode their dependencies, track their lifecycle and also
+ *   directly manage their execution.
+ *
+ * <h2>Dependency Encoding</h2>
+ * The byId list implies the contents of the deps of all commands in the collection - that is, it is assumed that in
+ * the normal course of events every transaction will include the full set of {@code TxnId} we know that would be
+ * witnessed by the command. We only encode divergences from this, stored in each command's {@code missing} collection.
+ *
+ * We then go one step further, exploiting the fact that the missing collection exists solely to implement recovery,
+ * and so we elide from this missing collection any {@code TxnId} we have recorded as {@code Committed} or higher.
+ * Any recovery coordinator that contacts this replica will report that the command has been agreed to execute,
+ * and so will not need to decipher any fast-path decisions. So the missing collection is redundant, as no command's deps
+ * will need to be queried for this TxnId's presence/absence.
+ * TODO (expected) this logic applies equally well to Accepted
+ *
+ * The goal with these behaviours is that this missing collection will ordinarily be empty, occupying no space.
+ *
+ * <h2>Garbage Collection</h2>
+ * This collection is trimmed by two mechanisms: pruning applied transactions and removing redundant transactions.
+ *
+ * 1) redundantBefore represents the global lower bound covering the key for transactions all being applied or invalidated,
+ * so we know that nothing with a lower TxnId should ever need to be computed, taken as a dependency or recovered. So
+ * we may simply erase these.
+ *
+ * 2) prunedBefore represents a local bound that permits us to optimistically remove data from the CommandsForKey
+ * that may need to be loaded again later. Specifically, we pick an applied {@code TxnId} that we will retain, and we
+ * remove from the {@code CommandsForKey} any transaction with a lower {@code TxnId} and {@code executeAt} that is also
+ * applied or invalidated.
+ *
+ * [We only do this if there also exists some later transactions we are not pruning that collectively have a superset of
+ * its witnessed collection, so that recovery decisions will be unaffected by the removal of the transaction.]
+ *
+ * The complexity here is that, by virtue of being a local decision point, we cannot guarantee that no coordinator will
+ * contact us in future with either a new TxnId that is lower than this, or a dependency collection containing a TxnId
+ * we have already processed.
+ *
+ * [We pick a TxnId stale by some time bound so that we can expect that any earlier already-applied TxnId will
+ * not be included in a future set of dependencies - we expect that "transitive dependency elision" will ordinarily filter
+ * it; but it might not on all replicas.]
+ *
+ * The difficulty is that we cannot immediately distinguish these two cases, and so on encountering a TxnId that is
+ * less than our {@code prunedBefore} we must load the local command state for the TxnId. If we have not witnessed the
+ * TxnId then we know it is a new transitive dependency. If we have witnessed it, and it is applied, then we load it
+ * into the {@code CommandsForKey} until we next prune to avoid reloading it repeatedly. This is managed with the
+ * {@link #loadingPruned} btree collection.
+ *
+ * <h2>Transitive Dependency Elision</h2>
+ * {@code CommandsForKey} also implements transitive dependency elision.
+ * When evaluating {@code mapReduceActive}, we first establish the last-executing Stable write command (i.e. those whose deps
+ * are considered durably decided, and so must wait for all commands {@code Committed} with a lower {@code executeAt}).
+ * We then elide any {@code Committed} command that has a lower executeAt than this command that would be witnessed by that command.
+ *
+ * Both commands must be known at a majority, but neither might be {@code Committed} at any other replica.
+ * Either command may therefore be recovered.
+ * If the later command is recovered, this replica will report its Stable deps, thereby recovering them.
+ * If this replica is not contacted, some other replica must participate that either has taken the same action as this replica,
+ * or else does not know the later command is Stable, and so will report the earlier command as a dependency again.
+ * If the earlier command is recovered, this replica will report that it is {@code Committed}, and so will not consult
+ * this replica's collection to decipher any fast path decision. Any other replica must either do the same, or else
+ * will correctly record this transaction as present in any relevant deps of later transactions.
+ *
+ * TODO (expected): maintain separate redundantBefore and closedBefore timestamps, latter implied by any exclusivesyncpoint;
+ *                  advance former based on Applied status of all TxnId before the latter
+ * TODO (desired):  track whether a TxnId is a write on this key only for execution (rather than globally)
+ * TODO (expected): merge with TimestampsForKey
+ * TODO (desired):  save space by encoding InternalStatus in TxnId.flags(), so that when executeAt==txnId we can save 8 bytes per entry
+ * TODO (expected): remove a command that is committed to not intersect with the key for this store (i.e. if accepted in a later epoch than committed on, so ownership changes)
+ * TODO (expected): avoid updating transactions we don't manage the execution of - perhaps have a dedicated InternalStatus
+ * TODO (expected): minimise repeated notification, either by logic or marking a command as notified once ready-to-execute
+ * TODO (required): linearizability violation detection
+ * TODO (desired): introduce a new status or other fast and simple mechanism for filtering treatment of range or unmanaged transactions
+ */
+public class CommandsForKey extends CommandsForKeyUpdate implements CommandsSummary
+{
+    private static final boolean ELIDE_TRANSITIVE_DEPENDENCIES = true;
+
+    public static final RedundantBefore.Entry NO_REDUNDANT_BEFORE = new RedundantBefore.Entry(null, Long.MIN_VALUE, Long.MAX_VALUE, TxnId.NONE, TxnId.NONE, TxnId.NONE, null);
+    public static final TxnId[] NO_TXNIDS = new TxnId[0];
+    public static final TxnInfo NO_INFO = new TxnInfo(TxnId.NONE, HISTORICAL, TxnId.NONE);
+    public static final TxnInfo[] NO_INFOS = new TxnInfo[0];
+    public static final Unmanaged[] NO_PENDING_UNMANAGED = new Unmanaged[0];
+
+    /**
+     * Transactions that are witnessed by {@code CommandsForKey} for dependency management
+     * (essentially all globally visible key transactions).
+     */
+    public static boolean manages(TxnId txnId)
+    {
+        return txnId.domain().isKey() && txnId.kind().isGloballyVisible();
+    }
+
+    /**
+     * Transactions whose execution will be wholly managed by {@code CommandsForKey} (essentially reads and writes).
+     *
+     * Other transactions that depend on these transactions need only adopt a dependency on the {@code Key} to represent
+     * all of these transactions; the {@code CommandsForKey} will then notify when they have executed.
+     */
+    public static boolean managesExecution(TxnId txnId)
+    {
+        return Write.witnesses(txnId.kind()) && txnId.domain().isKey();
+    }
+
+    public static boolean needsUpdate(Command prev, Command updated)
+    {
+        SaveStatus prevStatus;
+        Ballot prevAcceptedOrCommitted;
+        if (prev == null)
+        {
+            prevStatus = SaveStatus.NotDefined;
+            prevAcceptedOrCommitted = Ballot.ZERO;
+        }
+        else
+        {
+            prevStatus = prev.saveStatus();
+            prevAcceptedOrCommitted = prev.acceptedOrCommitted();
+        }
+
+        return needsUpdate(prevStatus, prevAcceptedOrCommitted, updated.saveStatus(), updated.acceptedOrCommitted());
+    }
+
+    public static boolean needsUpdate(SaveStatus prevStatus, Ballot prevAcceptedOrCommitted, SaveStatus updatedStatus, Ballot updatedAcceptedOrCommitted)
+    {
+        InternalStatus prev = InternalStatus.from(prevStatus);
+        InternalStatus updated = InternalStatus.from(updatedStatus);
+        return updated != prev || (updated != null && updated.hasExecuteAtOrDeps && !prevAcceptedOrCommitted.equals(updatedAcceptedOrCommitted));
+    }
+
+    public static class SerializerSupport
+    {
+        public static CommandsForKey create(Key key, TxnInfo[] txns, Unmanaged[] unmanageds, TxnId prunedBefore)
+        {
+            return new CommandsForKey(key, NO_REDUNDANT_BEFORE, prunedBefore, Pruning.LoadingPruned.empty(), txns, unmanageds);
+        }
+    }
+
+    /**
+     * An object representing the basic CommandsForKey state, extending TxnId to save memory and improve locality.
+     */
+    public static class TxnInfo extends TxnId
+    {
+        public final InternalStatus status;
+        public final Timestamp executeAt;
+
+        TxnInfo(TxnId txnId, InternalStatus status, Timestamp executeAt)
+        {
+            super(txnId);
+            this.status = status;
+            this.executeAt = executeAt == txnId ? this : executeAt;
+        }
+
+        public static TxnInfo create(@Nonnull TxnId txnId, InternalStatus status)
+        {
+            return new TxnInfo(txnId, status, txnId);
+        }
+
+        public static TxnInfo create(@Nonnull TxnId txnId, InternalStatus status, Command command)
+        {
+            Timestamp executeAt = txnId;
+            if (status.hasExecuteAt()) executeAt = command.executeAt();
+            Ballot ballot;
+            if (!status.hasBallot || (ballot = command.acceptedOrCommitted()).equals(Ballot.ZERO))
+                return new TxnInfo(txnId, status, executeAt);
+            return new TxnInfoExtra(txnId, status, executeAt, NO_TXNIDS, ballot);
+        }
+
+        public static TxnInfo create(@Nonnull TxnId txnId, InternalStatus status, @Nonnull Timestamp executeAt, @Nonnull Ballot ballot)
+        {
+            Invariants.checkState(executeAt == txnId || !executeAt.equals(txnId));
+            Invariants.checkState(status.hasExecuteAtOrDeps || executeAt == txnId);
+            Invariants.checkState(status.hasBallot || ballot == Ballot.ZERO);
+            if (!status.hasBallot || ballot.equals(Ballot.ZERO)) return new TxnInfo(txnId, status, executeAt);
+            return new TxnInfoExtra(txnId, status, executeAt, NO_TXNIDS, ballot);
+        }
+
+        public static TxnInfo create(@Nonnull TxnId txnId, InternalStatus status, @Nonnull Timestamp executeAt, @Nonnull TxnId[] missing, @Nonnull Ballot ballot)
+        {
+            Invariants.checkState(executeAt == txnId || !executeAt.equals(txnId));
+            Invariants.checkState(status.hasExecuteAtOrDeps || executeAt == txnId);
+            Invariants.checkState(status.hasBallot || ballot == Ballot.ZERO);
+            Invariants.checkState(status.hasExecuteAtOrDeps || missing == NO_TXNIDS);
+            if (missing == NO_TXNIDS && (!status.hasBallot || ballot == Ballot.ZERO))
+                return new TxnInfo(txnId, status, executeAt);
+            Invariants.checkState(missing.length > 0 || missing == NO_TXNIDS);
+            return new TxnInfoExtra(txnId, status, executeAt, missing, ballot);
+        }
+
+        public static TxnInfo createMock(TxnId txnId, InternalStatus status, @Nullable Timestamp executeAt, @Nullable TxnId[] missing, @Nullable Ballot ballot)
+        {
+            Invariants.checkState(executeAt == null || executeAt == txnId || !executeAt.equals(txnId));
+            Invariants.checkArgument(missing == null || missing == NO_TXNIDS);
+            if (missing == NO_TXNIDS && ballot == Ballot.ZERO) return new TxnInfo(txnId, status, executeAt);
+            return new TxnInfoExtra(txnId, status, executeAt, missing, ballot);
+        }
+
+        Timestamp depsKnownBefore()
+        {
+            return status.depsKnownBefore(this, executeAt);
+        }
+
+        Timestamp witnessedAfter()
+        {
+            return status.witnessedAfter(this, executeAt);
+        }
+
+        public TxnInfo update(TxnId[] newMissing)
+        {
+            Invariants.checkState(status.hasExecuteAtOrDeps);
+            return newMissing == NO_TXNIDS
+                   ? new TxnInfo(this, status, executeAt)
+                   : new TxnInfoExtra(this, status, executeAt, newMissing, Ballot.ZERO);
+        }
+
+        @Override
+        public boolean equals(Object o)
+        {
+            if (this == o) return true;
+            if (o == null || getClass() != o.getClass()) return false;
+            TxnInfo info = (TxnInfo) o;
+            return status == info.status
+                   && (executeAt == this ? info.executeAt == info : Objects.equals(executeAt, info.executeAt))
+                   && Arrays.equals(missing(), info.missing());
+        }
+
+        TxnId plainTxnId()
+        {
+            return new TxnId(this);
+        }
+
+        Timestamp plainExecuteAt()
+        {
+            return executeAt == this ? plainTxnId() : executeAt;
+        }
+
+        /**
+         * Any uncommitted transactions the owning CommandsForKey is aware of, that could have been included in our
+         * dependencies but weren't.
+         * <p>
+         * That is to say, any TxnId < depsKnownBefore() we have otherwise witnessed that were not witnessed by this transaction.
+         */
+        public TxnId[] missing()
+        {
+            return NO_TXNIDS;
+        }
+
+        public Ballot ballot()
+        {
+            return Ballot.ZERO;
+        }
+
+        @Override
+        public int hashCode()
+        {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public String toString()
+        {
+            return "Info{" +
+                   "txnId=" + toPlainString() +
+                   ", status=" + status +
+                   ", executeAt=" + plainExecuteAt() +
+                   '}';
+        }
+
+        public String toPlainString()
+        {
+            return super.toString();
+        }
+
+        public int compareExecuteAt(TxnInfo that)
+        {
+            return this.executeAt.compareTo(that.executeAt);
+        }
+
+        Timestamp executeAtIfKnownElseTxnId()
+        {
+            return status == INVALID_OR_TRUNCATED_OR_UNMANAGED_COMMITTED ? this : executeAt;
+        }
+    }
+
+    public static class TxnInfoExtra extends TxnInfo
+    {
+        /**
+         * {@link TxnInfo#missing()}
+         */
+        public final TxnId[] missing;
+        public final Ballot ballot;
+
+        TxnInfoExtra(TxnId txnId, InternalStatus status, Timestamp executeAt, TxnId[] missing, Ballot ballot)
+        {
+            super(txnId, status, executeAt);
+            this.missing = missing;
+            this.ballot = ballot;
+        }
+
+        /**
+         * {@link TxnInfo#missing()}
+         */
+        @Override
+        public TxnId[] missing()
+        {
+            return missing;
+        }
+
+        @Override
+        public Ballot ballot()
+        {
+            return ballot;
+        }
+
+        public TxnInfo update(TxnId[] newMissing)
+        {
+            if (newMissing == missing)
+                return this;
+
+            return newMissing == NO_TXNIDS && ballot == Ballot.ZERO
+                   ? new TxnInfo(this, status, executeAt)
+                   : new TxnInfoExtra(this, status, executeAt, newMissing, ballot);
+        }
+
+        @Override
+        public String toString()
+        {
+            return "Info{" +
+                   "txnId=" + toPlainString() +
+                   ", status=" + status +
+                   ", executeAt=" + plainExecuteAt() +
+                   (ballot != Ballot.ZERO ? ", ballot=" + ballot : "") +
+                   ", missing=" + Arrays.toString(missing) +
+                   '}';
+        }
+    }
+
+    /**
+     * A transaction whose key-dependencies for execution are not natively managed by this class.
+     * This essentially supports all range commands and key sync points for managing their key execution dependencies.
+     * <p>
+     * We maintain a sorted list of waiting transactions; we gate this by simple TxnId and executeAt bounds in two phases:
+     * 1) pick the highest dependency TxnId on the key; wait for all <= TxnId to commit
+     * 2) pick the highest executeAt dependency on the key that executes before the Unmanaged txn, and wait for it and all earlier txn to Apply
+     *
+     * NOTE: this can create false dependencies on commit phase for earlier TxnId, which could delay transaction execution.
+     * This is probably an acceptable trade-off given this vastly simplifies execution and reduces the state we have to manage;
+     * we mostly use this for sync point execution; and because Commit should always complete promptly.
+     * However, we could improve this in future if we want to, by integrating this execution logic with the execution
+     * of regular reads and writes now we anyway often represent these transactions in the TxnInfo collections.
+     */
+    public static class Unmanaged implements Comparable<Unmanaged>
+    {
+        public enum Pending { COMMIT, APPLY }
+
+        public final Pending pending;
+        public final Timestamp waitingUntil;
+        public final TxnId txnId;
+
+        public Unmanaged(Pending pending, TxnId txnId, Timestamp waitingUntil)
+        {
+            this.pending = pending;
+            this.txnId = txnId;
+            this.waitingUntil = waitingUntil;
+        }
+
+        @Override
+        public int compareTo(Unmanaged that)
+        {
+            if (this.pending != that.pending) return this.pending.compareTo(that.pending);
+            int c = this.waitingUntil.compareTo(that.waitingUntil);
+            if (c == 0) c = this.txnId.compareTo(that.txnId);
+            return c;
+        }
+
+        @Override
+        public boolean equals(Object o)
+        {
+            if (this == o) return true;
+            if (o == null || getClass() != o.getClass()) return false;
+            Unmanaged unmanaged = (Unmanaged) o;
+            return pending == unmanaged.pending && waitingUntil.equals(unmanaged.waitingUntil) && txnId.equals(unmanaged.txnId);
+        }
+
+        @Override
+        public int hashCode()
+        {
+            return Objects.hash(pending, waitingUntil, txnId);
+        }
+
+        @Override
+        public String toString()
+        {
+            return "Pending{" + txnId + " until:" + waitingUntil + " " + pending + "}";
+        }
+    }
+
+    public enum InternalStatus
+    {
+        TRANSITIVELY_KNOWN(false, false), // (unwitnessed; no need for mapReduce to witness)
+        HISTORICAL(false, false),
+        PREACCEPTED_OR_ACCEPTED_INVALIDATE(false, true),
+        ACCEPTED(true, true),
+        COMMITTED(true, true),
+        STABLE(true, false),
+        APPLIED(true, false),
+        INVALID_OR_TRUNCATED_OR_UNMANAGED_COMMITTED(false, false);
+
+        static final EnumMap<SaveStatus, InternalStatus> convert = new EnumMap<>(SaveStatus.class);
+        static final InternalStatus[] VALUES = values();
+
+        static
+        {
+            convert.put(SaveStatus.PreAccepted, PREACCEPTED_OR_ACCEPTED_INVALIDATE);
+            convert.put(SaveStatus.AcceptedInvalidateWithDefinition, PREACCEPTED_OR_ACCEPTED_INVALIDATE);
+            convert.put(SaveStatus.Accepted, ACCEPTED);
+            convert.put(SaveStatus.AcceptedWithDefinition, ACCEPTED);
+            convert.put(SaveStatus.PreCommittedWithDefinition, PREACCEPTED_OR_ACCEPTED_INVALIDATE);
+            convert.put(SaveStatus.PreCommittedWithAcceptedDeps, ACCEPTED);
+            convert.put(SaveStatus.PreCommittedWithDefinitionAndAcceptedDeps, ACCEPTED);
+            convert.put(SaveStatus.Committed, COMMITTED);
+            convert.put(SaveStatus.Stable, STABLE);
+            convert.put(SaveStatus.ReadyToExecute, STABLE);
+            convert.put(SaveStatus.PreApplied, STABLE);
+            convert.put(SaveStatus.Applying, STABLE);
+            convert.put(SaveStatus.Applied, APPLIED);
+            convert.put(SaveStatus.TruncatedApplyWithDeps, INVALID_OR_TRUNCATED_OR_UNMANAGED_COMMITTED);
+            convert.put(SaveStatus.TruncatedApplyWithOutcome, INVALID_OR_TRUNCATED_OR_UNMANAGED_COMMITTED);
+            convert.put(SaveStatus.TruncatedApply, INVALID_OR_TRUNCATED_OR_UNMANAGED_COMMITTED);
+            convert.put(SaveStatus.ErasedOrInvalidated, INVALID_OR_TRUNCATED_OR_UNMANAGED_COMMITTED);
+            convert.put(SaveStatus.Erased, INVALID_OR_TRUNCATED_OR_UNMANAGED_COMMITTED);
+            convert.put(SaveStatus.Invalidated, INVALID_OR_TRUNCATED_OR_UNMANAGED_COMMITTED);
+        }
+
+        public final boolean hasInfo;
+        public final boolean hasExecuteAtOrDeps;
+        public final boolean hasBallot;
+
+        InternalStatus(boolean hasExecuteAtOrDeps, boolean hasBallot)
+        {
+            this.hasExecuteAtOrDeps = hasExecuteAtOrDeps;
+            this.hasBallot = hasBallot;
+            this.hasInfo = hasExecuteAtOrDeps | hasBallot;
+        }
+
+        boolean hasExecuteAt()
+        {
+            return hasExecuteAtOrDeps;
+        }
+
+        boolean hasDeps()
+        {
+            return hasExecuteAtOrDeps;
+        }
+
+        boolean hasStableDeps()
+        {
+            return this == STABLE || this == APPLIED;
+        }
+
+        public boolean isCommitted()
+        {
+            return this == COMMITTED | this == STABLE | this == APPLIED;
+        }
+
+        public Timestamp depsKnownBefore(TxnId txnId, Timestamp executeAt)
+        {
+            switch (this)
+            {
+                default: throw new AssertionError("Unhandled InternalStatus: " + this);
+                case TRANSITIVELY_KNOWN:
+                case INVALID_OR_TRUNCATED_OR_UNMANAGED_COMMITTED:
+                case HISTORICAL:
+                    throw new AssertionError("Invalid InternalStatus to know deps");
+
+                case PREACCEPTED_OR_ACCEPTED_INVALIDATE:
+                case ACCEPTED:
+                    return txnId;
+
+                case APPLIED:
+                case STABLE:
+                case COMMITTED:
+                    return executeAt;
+            }
+        }
+
+        public Timestamp witnessedAfter(TxnId txnId, Timestamp executeAt)
+        {
+            switch (this)
+            {
+                default: throw new AssertionError("Unhandled InternalStatus: " + this);
+                case INVALID_OR_TRUNCATED_OR_UNMANAGED_COMMITTED:
+                case TRANSITIVELY_KNOWN:
+                case HISTORICAL:
+                case PREACCEPTED_OR_ACCEPTED_INVALIDATE:
+                case ACCEPTED:
+                    return txnId;
+
+                case APPLIED:
+                case STABLE:
+                case COMMITTED:
+                    return executeAt;
+            }
+        }
+
+        @VisibleForTesting
+        public static InternalStatus from(SaveStatus status)
+        {
+            return convert.get(status);
+        }
+
+        public static InternalStatus get(int ordinal)
+        {
+            return VALUES[ordinal];
+        }
+    }
+
+    final Key key;
+    final RedundantBefore.Entry redundantBefore;
+    final TxnInfo prunedBefore;
+    // a btree keyed by TxnId we have encountered since pruning that occur before prunedBefore;
+    // mapping to those TxnId that had witnessed this potentially-pruned TxnId.
+    final Object[] loadingPruned;
+    // all transactions, sorted by TxnId
+    final TxnInfo[] byId;
+    // reads and writes ONLY that are committed or stable or applied, keyed by executeAt
+    // TODO (required): validate that it is always a prefix that is Applied (i.e. never a gap)
+    // TODO (desired): filter transactions whose execution we don't manage
+    final TxnInfo[] committedByExecuteAt;
+    final int minUndecidedById, maxAppliedWriteByExecuteAt;
+    final Unmanaged[] unmanageds;
+
+    CommandsForKey(Key key, RedundantBefore.Entry redundantBefore, TxnInfo prunedBefore, Object[] loadingPruned, TxnInfo[] byId, TxnInfo[] committedByExecuteAt, int minUndecidedById, int maxAppliedWriteByExecuteAt, Unmanaged[] unmanageds)
+    {
+        this.key = key;
+        this.redundantBefore = Invariants.nonNull(redundantBefore);
+        this.prunedBefore = Invariants.nonNull(prunedBefore);
+        this.loadingPruned = loadingPruned;
+        this.byId = byId;
+        this.committedByExecuteAt = committedByExecuteAt;
+        this.minUndecidedById = minUndecidedById;
+        this.maxAppliedWriteByExecuteAt = maxAppliedWriteByExecuteAt;
+        this.unmanageds = unmanageds;
+        checkIntegrity();
+    }
+
+    CommandsForKey(Key key, RedundantBefore.Entry redundantBefore, TxnId prunedBefore, Object[] loadingPruned, TxnInfo[] byId, Unmanaged[] unmanageds)
+    {
+        this.key = key;
+        this.redundantBefore = Invariants.nonNull(redundantBefore);
+        this.prunedBefore = redundantBefore.shardRedundantBefore().compareTo(prunedBefore) >= 0
+                            ? NO_INFO : prunedBefore.equals(TxnId.NONE)
+                                        ? NO_INFO : Invariants.nonNull(get(prunedBefore, byId));
+        this.loadingPruned = loadingPruned;
+        this.byId = byId;
+        this.unmanageds = unmanageds;
+
+        int countCommitted = 0;
+        int minUndecided = -1;
+        for (int i = 0; i < byId.length ; ++i)
+        {
+            TxnInfo txn = byId[i];
+            if (txn.status == INVALID_OR_TRUNCATED_OR_UNMANAGED_COMMITTED) continue;
+            if (txn.status.compareTo(COMMITTED) >= 0) ++countCommitted;
+            else if (minUndecided == -1 && managesExecution(txn)) minUndecided = i;
+        }
+        this.minUndecidedById = minUndecided;
+        this.committedByExecuteAt = new TxnInfo[countCommitted];
+        countCommitted = 0;
+        for (TxnInfo txn : byId)
+        {
+            if (txn.status.compareTo(COMMITTED) >= 0 && txn.status != INVALID_OR_TRUNCATED_OR_UNMANAGED_COMMITTED)
+                committedByExecuteAt[countCommitted++] = txn;
+        }
+        Arrays.sort(committedByExecuteAt, TxnInfo::compareExecuteAt);
+        int maxAppliedByExecuteAt = committedByExecuteAt.length;
+        while (--maxAppliedByExecuteAt >= 0)
+        {
+            TxnInfo txn = committedByExecuteAt[maxAppliedByExecuteAt];
+            if (txn.status == APPLIED && txn.kind() == Write)
+                break;
+        }
+        this.maxAppliedWriteByExecuteAt = maxAppliedByExecuteAt;
+
+        checkIntegrity();
+    }
+
+    CommandsForKey(CommandsForKey copy, Object[] loadingPruned, Unmanaged[] unmanageds)
+    {
+        this.key = copy.key;
+        this.redundantBefore = copy.redundantBefore;
+        this.prunedBefore = copy.prunedBefore;
+        this.loadingPruned = loadingPruned;
+        this.byId = copy.byId;
+        this.committedByExecuteAt = copy.committedByExecuteAt;
+        this.minUndecidedById = copy.minUndecidedById;
+        this.maxAppliedWriteByExecuteAt = copy.maxAppliedWriteByExecuteAt;
+        this.unmanageds = unmanageds;
+
+        checkIntegrity();
+    }
+
+    public CommandsForKey(Key key)
+    {
+        this.key = key;
+        this.redundantBefore = NO_REDUNDANT_BEFORE;
+        this.prunedBefore = NO_INFO;
+        this.loadingPruned = Pruning.LoadingPruned.empty();
+        this.byId = NO_INFOS;
+        this.committedByExecuteAt = NO_INFOS;
+        this.minUndecidedById = this.maxAppliedWriteByExecuteAt = -1;
+        this.unmanageds = NO_PENDING_UNMANAGED;
+    }
+
+    @Override
+    public String toString()
+    {
+        return "CommandsForKey@" + System.identityHashCode(this) + '{' + key + '}';
+    }
+
+    public Key key()
+    {
+        return key;
+    }
+
+    public int size()
+    {
+        return byId.length;
+    }
+
+    public boolean isLoadingPruned()
+    {
+        return !BTree.isEmpty(loadingPruned);
+    }
+
+    @VisibleForImplementation
+    public int unmanagedCount()
+    {
+        return unmanageds.length;
+    }
+
+    public int indexOf(TxnId txnId)
+    {
+        return Arrays.binarySearch(byId, txnId);
+    }
+
+    public TxnId txnId(int i)
+    {
+        return byId[i];
+    }
+
+    public TxnInfo get(int i)
+    {
+        return byId[i];
+    }
+
+    @VisibleForImplementation
+    public Unmanaged getUnmanaged(int i)
+    {
+        return unmanageds[i];
+    }
+
+    public TxnInfo get(TxnId txnId)
+    {
+        int i = indexOf(txnId);
+        return i < 0 ? null : byId[i];
+    }
+
+    private static TxnInfo get(TxnId txnId, TxnInfo[] txns)
+    {
+        int i = Arrays.binarySearch(txns, txnId);
+        return i < 0 ? null : txns[i];
+    }
+
+    public RedundantBefore.Entry redundantBefore()
+    {
+        return redundantBefore;
+    }
+
+    public TxnId prunedBefore()
+    {
+        return prunedBefore;
+    }
+
+    public TxnId locallyRedundantBefore()
+    {
+        return redundantBefore.locallyRedundantBefore();
+    }
+
+    public TxnId shardRedundantBefore()
+    {
+        return redundantBefore.shardRedundantBefore();
+    }
+
+    /**
+     * All commands before/after (exclusive of) the given timestamp, excluding those that are redundant,
+     * or have locally applied prior to some other command that is stable, will be returned by the collection.
+     *
+     * Note that if the command has already applied locally, this method may erroneously treat the command as
+     * being unwitnessed by some following/dependent command.
+     * <p>
+     * Note that {@code testDep} applies only to commands that MAY have the command in their deps; if specified any
+     * commands that do not know any deps will be ignored, as will any with an executeAt prior to the txnId.
+     * <p>
+     */
+    public <P1, T> T mapReduceFull(TxnId testTxnId,
+                                   Kinds testKind,
+                                   TestStartedAt testStartedAt,
+                                   TestDep testDep,
+                                   TestStatus testStatus,
+                                   CommandFunction<P1, T, T> map, P1 p1, T initialValue)
+    {
+        int start, end, loadingIndex = 0;
+        // if this is null the TxnId is known in byId
+        // otherwise, it must be non-null and represents the transactions (if any) that have requested it be loaded due to being pruned
+        TxnId[] loadingFor = null;
+        {
+            int insertPos = Arrays.binarySearch(byId, testTxnId);
+            if (insertPos < 0)
+            {
+                loadingFor = NO_TXNIDS;
+                insertPos = -1 - insertPos;
+                switch (testDep)
+                {
+                    default: throw new AssertionError("Unhandled TestDep: " + testDep);
+                    case ANY_DEPS:
+                        break;
+
+                    case WITH:
+                        if (testTxnId.compareTo(prunedBefore) >= 0)
+                            return initialValue;
+
+                        loadingFor = loadingPrunedFor(loadingPruned, testTxnId, NO_TXNIDS);
+                        break;
+
+                    case WITHOUT:
+                        if (testTxnId.compareTo(prunedBefore) < 0)
+                            loadingFor = loadingPrunedFor(loadingPruned, testTxnId, NO_TXNIDS);
+                }
+            }
+
+            switch (testStartedAt)
+            {
+                default: throw new AssertionError("Unhandled TestStartedAt: " + testTxnId);
+                case STARTED_BEFORE: start = 0; end = insertPos; break;
+                case STARTED_AFTER: start = insertPos; end = byId.length; break;
+                case ANY: start = 0; end = byId.length;
+            }
+        }
+
+        for (int i = start; i < end ; ++i)
+        {
+            TxnInfo txn = byId[i];
+            if (!testKind.test(txn.kind())) continue;
+            InternalStatus status = txn.status;
+            switch (testStatus)
+            {
+                default: throw new AssertionError("Unhandled TestStatus: " + testStatus);
+                case IS_PROPOSED:
+                    if (status == ACCEPTED || status == COMMITTED) break;
+                    else continue;
+                case IS_STABLE:
+                    if (status.compareTo(STABLE) >= 0 && status.compareTo(INVALID_OR_TRUNCATED_OR_UNMANAGED_COMMITTED) < 0) break;
+                    else continue;
+                case ANY_STATUS:
+                    if (status == TRANSITIVELY_KNOWN)
+                        continue;
+            }
+
+            Timestamp executeAt = txn.executeAt;
+            if (testDep != ANY_DEPS)
+            {
+                if (!status.hasExecuteAtOrDeps)
+                    continue;
+
+                if (executeAt.compareTo(testTxnId) <= 0)
+                    continue;
+
+                boolean hasAsDep;
+                if (loadingFor == null)
+                {
+                    TxnId[] missing = txn.missing();
+                    hasAsDep = missing == NO_TXNIDS || Arrays.binarySearch(txn.missing(), testTxnId) < 0;
+                }
+                else if (loadingFor == NO_TXNIDS)
+                {
+                    hasAsDep = false;
+                }
+                else
+                {
+                    // we could use expontentialSearch and moving index for improved algorithmic complexity,
+                    // but since should be rarely taken path probably not worth code complexity
+                    loadingIndex = SortedArrays.exponentialSearch(loadingFor, loadingIndex, loadingFor.length, txn);
+                    if (hasAsDep = (loadingIndex >= 0)) ++loadingIndex;
+                    else loadingIndex = -1 - loadingIndex;
+                }
+
+                if (hasAsDep != (testDep == WITH))
+                    continue;
+            }
+
+            initialValue = map.apply(p1, key, txn.plainTxnId(), executeAt, initialValue);
+        }
+        return initialValue;
+    }
+
+    public <P1, T> T mapReduceActive(Timestamp startedBefore,
+                                     Kinds testKind,
+                                     CommandFunction<P1, T, T> map, P1 p1, T initialValue)
+    {
+        int end = insertPos(startedBefore);
+        Timestamp maxCommittedWriteBefore;
+        {
+            int from = 0, to = committedByExecuteAt.length;
+            if (maxAppliedWriteByExecuteAt >= 0)
+            {
+                if (committedByExecuteAt[maxAppliedWriteByExecuteAt].executeAt.compareTo(startedBefore) <= 0) from = maxAppliedWriteByExecuteAt;
+                else to = maxAppliedWriteByExecuteAt;
+            }
+            int i = SortedArrays.binarySearch(committedByExecuteAt, from, to, startedBefore, (f, v) -> f.compareTo(v.executeAt), FAST);
+            if (i < 0) i = -2 - i;
+            else --i;
+            while (i >= 0 && !committedByExecuteAt[i].kind().isWrite()) --i;
+            maxCommittedWriteBefore = i < 0 ? null : committedByExecuteAt[i].executeAt;
+        }
+
+        for (int i = 0; i < end ; ++i)
+        {
+            TxnInfo txn = byId[i];
+            if (!testKind.test(txn.kind()))
+                continue;
+
+            switch (txn.status)
+            {
+                case COMMITTED:
+                case STABLE:
+                case APPLIED:
+                    // TODO (expected): prove the correctness of this approach
+                    if (!ELIDE_TRANSITIVE_DEPENDENCIES || maxCommittedWriteBefore == null || txn.executeAt.compareTo(maxCommittedWriteBefore) >= 0 || !Write.witnesses(txn))
+                        break;
+                case TRANSITIVELY_KNOWN:
+                case INVALID_OR_TRUNCATED_OR_UNMANAGED_COMMITTED:
+                    continue;
+            }
+
+            initialValue = map.apply(p1, key, txn.plainTxnId(), txn.executeAt, initialValue);
+        }
+
+        if (startedBefore.compareTo(prunedBefore) <= 0)
+        {
+            // in the event we have pruned transactions that may execute before us, we take the earliest future dependency we can in their place.
+            // in practice this only has an effect on ExclusiveSyncPoints because they do not agree an execution time
+            // and only take dependencies on TxnId lower than them. Other transactions will propose a timestamp that
+            // occurs after any dependencies witnessed here, or will be invalidated, or have already been agreed
+            // and their dependencies are known.
+            int i = SortedArrays.binarySearch(committedByExecuteAt, 0, maxAppliedWriteByExecuteAt, startedBefore, (f, v) -> f.compareTo(v.executeAt), FAST);
+            if (i < 0) i = -1 - i;
+            while (!committedByExecuteAt[i].kind().isWrite())
+                ++i;
+
+            initialValue = map.apply(p1, key, committedByExecuteAt[i].plainTxnId(), committedByExecuteAt[i].executeAt, initialValue);
+        }
+
+        return initialValue;
+    }
+
+    // NOTE: prev MAY NOT be the version that last updated us due to various possible race conditions
+    @VisibleForTesting
+    public CommandsForKeyUpdate update(Command next)
+    {
+        Invariants.checkState(manages(next.txnId()));
+        InternalStatus newStatus = InternalStatus.from(next.saveStatus());
+        if (newStatus == null)
+            return this;
+
+        return update(newStatus, next, false);
+    }
+
+    CommandsForKeyUpdate updatePruned(Command next)
+    {
+        InternalStatus newStatus = InternalStatus.from(next.saveStatus());
+        if (newStatus == null)
+            newStatus = TRANSITIVELY_KNOWN;
+        if (!manages(next.txnId()))
+            newStatus = newStatus.compareTo(COMMITTED) < 0 ? TRANSITIVELY_KNOWN : INVALID_OR_TRUNCATED_OR_UNMANAGED_COMMITTED;
+        return update(newStatus, next, true);
+    }
+
+    private CommandsForKeyUpdate update(InternalStatus newStatus, Command next, boolean wasPruned)
+    {
+        TxnId txnId = next.txnId();
+        Invariants.checkArgument(wasPruned || manages(txnId));
+
+        if (txnId.compareTo(redundantBefore.shardRedundantBefore()) < 0)
+            return this;
+
+        TxnId[] loadingAsPrunedFor = loadingPrunedFor(loadingPruned, txnId, null); // we default to null to distinguish between no match, and a match with NO_TXNIDS
+        wasPruned |= loadingAsPrunedFor != null;
+
+        int pos = Arrays.binarySearch(byId, txnId);
+        CommandsForKeyUpdate result;
+        if (pos < 0)
+        {
+            pos = -1 - pos;
+            if (newStatus.hasExecuteAtOrDeps && !wasPruned && txnId.domain().isKey()) result = insert(pos, txnId, newStatus, next);
+            else result = insert(pos, txnId, TxnInfo.create(txnId, newStatus, next), wasPruned, loadingAsPrunedFor);
+        }
+        else
+        {
+            // update
+            TxnInfo cur = byId[pos];
+
+            if (cur != null)
+            {
+                int c = newStatus.compareTo(cur.status);
+                if (c <= 0)
+                {
+                    if (c < 0)
+                    {
+                        if (!(newStatus == PREACCEPTED_OR_ACCEPTED_INVALIDATE && cur.status == ACCEPTED && next.acceptedOrCommitted().compareTo(cur.ballot()) > 0))
+                            return this;
+                    }
+                    else
+                    {
+                        if (!newStatus.hasInfo)
+                            return this;
+
+                        if (next.acceptedOrCommitted().compareTo(cur.ballot()) <= 0)
+                            return this;
+                    }
+                }
+            }
+
+            if (newStatus.hasExecuteAtOrDeps && !wasPruned) result = update(pos, txnId, cur, newStatus, next);
+            else result = update(pos, txnId, cur, TxnInfo.create(txnId, newStatus, next), wasPruned, loadingAsPrunedFor);
+        }
+
+        return result;
+    }
+
+    private CommandsForKeyUpdate insert(int insertPos, TxnId plainTxnId, InternalStatus newStatus, Command command)
+    {
+        return insertOrUpdate(this, insertPos, -1, plainTxnId, null, newStatus, command);
+    }
+
+    private CommandsForKeyUpdate update(int updatePos, TxnId plainTxnId, TxnInfo curInfo, InternalStatus newStatus, Command command)
+    {
+        return insertOrUpdate(this, updatePos, updatePos, plainTxnId, curInfo, newStatus, command);
+    }
+
+    CommandsForKeyUpdate update(int pos, TxnId plainTxnId, TxnInfo curInfo, TxnInfo newInfo, boolean wasPruned, TxnId[] loadingAsPrunedFor)
+    {
+        return insertOrUpdate(this, pos, plainTxnId, curInfo, newInfo, wasPruned, loadingAsPrunedFor);
+    }
+
+    /**
+     * Insert a new txnId and info
+     */
+    CommandsForKeyUpdate insert(int pos, TxnId plainTxnId, TxnInfo newInfo, boolean wasPruned, TxnId[] loadingAsPrunedFor)
+    {
+        return insertOrUpdate(this, pos, plainTxnId, null, newInfo, wasPruned, loadingAsPrunedFor);
+    }
+
+    // TODO (required): additional linearizability violation detection, based on expectation of presence in missing set
+
+    CommandsForKeyUpdate update(TxnInfo[] newById, int newMinUndecidedById, TxnInfo[] newCommittedByExecuteAt, int newMaxAppliedWriteByExecuteAt, Object[] newLoadingPruned, TxnId plainTxnId, @Nullable TxnInfo curInfo, @Nonnull TxnInfo newInfo)
+    {
+        return new CommandsForKey(key, redundantBefore, prunedBefore, newLoadingPruned, newById, newCommittedByExecuteAt, newMinUndecidedById, newMaxAppliedWriteByExecuteAt, unmanageds)
+               .notifyUnmanaged(curInfo, newInfo);
+    }
+
+    CommandsForKey update(Unmanaged[] newUnmanageds)
+    {
+        return new CommandsForKey(key, redundantBefore, prunedBefore, loadingPruned, byId, committedByExecuteAt, minUndecidedById, maxAppliedWriteByExecuteAt, newUnmanageds);
+    }
+
+    CommandsForKey update(Object[] newLoadingPruned)
+    {
+        return new CommandsForKey(key, redundantBefore, prunedBefore, newLoadingPruned, byId, committedByExecuteAt, minUndecidedById, maxAppliedWriteByExecuteAt, unmanageds);
+    }
+
+    private void notifyWaitingOnCommit(SafeCommandStore safeStore, TxnInfo uncommitted, NotifySink notifySink)
+    {
+        if (redundantBefore.endEpoch > uncommitted.epoch())
+            notifySink.waitingOnCommit(safeStore, uncommitted, key);
+    }
+
+    CommandsForKeyUpdate registerUnmanaged(SafeCommandStore safeStore, SafeCommand safeCommand)
+    {
+        return registerUnmanaged(safeStore, safeCommand, NotifySink.DefaultNotifySink.INSTANCE);
+    }
+
+    CommandsForKeyUpdate registerUnmanaged(SafeCommandStore safeStore, SafeCommand safeCommand, NotifySink notifySink)
+    {
+        return Updating.updateUnmanaged(this, safeStore, safeCommand, notifySink, true, null);
+    }
+
+    private CommandsForKeyUpdate notifyUnmanaged(@Nullable TxnInfo curInfo, TxnInfo newInfo)
+    {
+        return PostProcess.notifyUnmanaged(this, curInfo, newInfo);
+    }
+
+    void postProcess(SafeCommandStore safeStore, CommandsForKey prevCfk, @Nullable Command command, NotifySink notifySink)
+    {
+        TxnInfo minUndecided = minUndecided();
+        if (minUndecided != null && !minUndecided.equals(prevCfk.minUndecided()))
+            notifyWaitingOnCommit(safeStore, minUndecided, notifySink);
+
+        if (command == null || !command.hasBeen(Status.Committed) || !managesExecution(command.txnId()))
+            return;
+
+        /*
+         * Make sure transactions are notified they can execute, without repeatedly notifying.
+         *
+         * Basic logic:
+         *  - If there is any uncommitted transaction, notify the minimum txnId that we are expecting it to commit
+         *  - If a nextWrite is known, then:
+         *    - if it is next to execute we notify it;
+         *    - otherwise we try to notify any transactions that execute before it, but none after it
+         *  - If no nextWrite is known, then we attempt to notify any transaction as it is decided
+         *
+         * Note: If we have any uncommitted transactions that were declared before the next decided transaction,
+         *       we do not set next or nextWrite, and so do not notify them
+         */
+
+        TxnId updatedTxnId = command.txnId();
+        TxnInfo newInfo = get(updatedTxnId);
+        InternalStatus newStatus = newInfo.status;
+
+        TxnInfo prevInfo = prevCfk.get(updatedTxnId);
+        InternalStatus prevStatus = prevInfo == null ? TRANSITIVELY_KNOWN : prevInfo.status;
+
+        int byExecuteAtIndex = newStatus == INVALID_OR_TRUNCATED_OR_UNMANAGED_COMMITTED ? -1 : checkNonNegative(Arrays.binarySearch(committedByExecuteAt, newInfo, TxnInfo::compareExecuteAt));
+
+        Kinds mayExecuteKinds;
+        int mayExecuteToIndex, mayNotExecuteBeforeIndex = 0;
+        int mayExecuteAnyAtIndex = -1;
+        if (prevStatus.compareTo(COMMITTED) < 0)
+        {
+            mayExecuteKinds = updatedTxnId.kind().witnessedBy();
+            switch (newInfo.status)
+            {
+                default: throw new AssertionError("Unhandled InternalStatus: " + newInfo.status);
+                case ACCEPTED:
+                case HISTORICAL:
+                case PREACCEPTED_OR_ACCEPTED_INVALIDATE:
+                case TRANSITIVELY_KNOWN:
+                    throw illegalState("Invalid status: command has been committed but we have InternalStatus " + newInfo.status);
+
+                case INVALID_OR_TRUNCATED_OR_UNMANAGED_COMMITTED:
+                case APPLIED:
+                    mayExecuteToIndex = committedByExecuteAt.length;
+                    break;
+
+                case COMMITTED:
+                    mayExecuteToIndex = byExecuteAtIndex;
+                    break;
+
+                case STABLE:
+                    mayExecuteToIndex = byExecuteAtIndex + 1;
+                    mayExecuteAnyAtIndex = byExecuteAtIndex;
+                    break;
+            }
+        }
+        else if (newStatus == APPLIED)
+        {
+            mayExecuteKinds = updatedTxnId.kind().witnessedBy();
+            mayExecuteToIndex = committedByExecuteAt.length;
+        }
+        else if (newStatus == INVALID_OR_TRUNCATED_OR_UNMANAGED_COMMITTED && prevStatus != INVALID_OR_TRUNCATED_OR_UNMANAGED_COMMITTED)
+        {
+            // rare case of us erasing a command that has been committed, so simply try to execute the next executable thing
+            mayExecuteKinds = AnyGloballyVisible;
+            mayExecuteToIndex = committedByExecuteAt.length;
+        }
+        else
+        {
+            // we only permit to execute the updated transaction itself in this case, as there's no new information for the execution of other transactions
+            if (newStatus != STABLE)
+                return;
+
+            mayExecuteAnyAtIndex = byExecuteAtIndex;
+            mayExecuteKinds = AnyGloballyVisible;
+            mayExecuteToIndex = byExecuteAtIndex + 1;
+        }
+
+        notifyManaged(safeStore, mayExecuteKinds, mayNotExecuteBeforeIndex, mayExecuteToIndex, mayExecuteAnyAtIndex, notifySink);
+    }
+
+    private void notifyManaged(SafeCommandStore safeStore, Kinds kinds, int mayNotExecuteBeforeIndex, int mayExecuteToIndex, int mayExecuteAny, NotifySink notifySink)
+    {
+        Participants<?> asParticipants = null;
+        int undecidedIndex = minUndecidedById < 0 ? byId.length : minUndecidedById;
+        long unappliedCounters = 0L;
+        TxnInfo minUndecided = minUndecided();
+
+        for (int i = maxAppliedWriteByExecuteAt + 1; i < mayExecuteToIndex ; ++i)
+        {
+            TxnInfo txn = committedByExecuteAt[i];
+            if (txn.status == APPLIED || !managesExecution(txn))
+                continue;
+
+            Kind kind = txn.kind();
+            if (i >= mayNotExecuteBeforeIndex && (kinds.test(kind) || i == mayExecuteAny) && !isWaitingOnPruned(loadingPruned, txn, txn.executeAt))
+            {
+                switch (txn.status)
+                {
+                    case COMMITTED:
+                    {
+                        // cannot execute as dependencies not stable, so notify progress log to get or decide stable deps
+                        if (asParticipants == null)
+                            asParticipants = Keys.of(key).toParticipants();
+                        safeStore.progressLog().waiting(txn.plainTxnId(), WaitingToExecute, null, asParticipants);
+                        break;
+                    }
+
+                    case STABLE:
+                    {
+                        if (undecidedIndex < byId.length)
+                        {
+                            int nextUndecidedIndex = SortedArrays.exponentialSearch(byId, undecidedIndex, byId.length, txn.executeAt, Timestamp::compareTo, FAST);
+                            if (nextUndecidedIndex < 0) nextUndecidedIndex = -1 -nextUndecidedIndex;
+                            while (undecidedIndex < nextUndecidedIndex)
+                            {
+                                TxnInfo backfillTxn = byId[undecidedIndex++];
+                                if (backfillTxn.status.compareTo(COMMITTED) >= 0 || !managesExecution(backfillTxn)) continue;
+                                unappliedCounters += unappliedCountersDelta(backfillTxn.kind());
+                            }
+                        }
+
+                        int expectMissingCount = unappliedCount(unappliedCounters, kind);
+
+                        // We remove committed transactions from the missing set, since they no longer need them there
+                        // So the missing collection represents only those uncommitted transaction ids that a transaction
+                        // witnesses/conflicts with. So we may simply count all of those we know of with a lower TxnId,
+                        // and if the count is the same then we are not awaiting any of them for execution and can remove
+                        // this command's dependency on this key for execution.
+                        TxnId[] missing = txn.missing();
+                        int missingCount = missing.length;
+                        if (missingCount > 0)
+                        {
+                            int missingFrom = 0;
+                            if (minUndecided != null)
+                            {
+                                missingFrom = SortedArrays.binarySearch(missing, 0, missing.length, minUndecided, TxnId::compareTo, FAST);
+                                if (missingFrom < 0) missingFrom = -1 - missingFrom;
+                                missingCount -= missingFrom;
+                            }
+                            for (int j = missingFrom ; j < missing.length ; ++j)
+                            {
+                                if (!managesExecution(missing[j]))
+                                    --missingCount;
+                            }
+                        }
+                        if (expectMissingCount == missingCount)
+                        {
+                            TxnId txnId = txn.plainTxnId();
+                            notifySink.notWaiting(safeStore, txnId, key);
+                            if (asParticipants == null)
+                                asParticipants = Keys.of(key).toParticipants();
+                            safeStore.progressLog().waiting(txnId, WaitingToApply, null, asParticipants);
+                        }
+                    }
+                }
+            }
+
+            unappliedCounters += unappliedCountersDelta(kind);
+            if (kind == Kind.Write)
+                return; // the minimum execute index occurs after the next write, so nothing to do yet
+        }
+    }
+
+    private static long unappliedCountersDelta(Kind kind)
+    {
+        switch (kind)
+        {
+            default: throw new AssertionError("Unhandled Txn.Kind: " + kind);
+            case LocalOnly:
+            case EphemeralRead:
+                throw illegalState("Invalid Txn.Kind for CommandsForKey: " + kind);
+
+            case ExclusiveSyncPoint:
+            case SyncPoint:
+                return 0L;
+
+            case Write:
+                return (1L << 32) + 1L;
+
+            case Read:
+                return 1L;
+        }
+    }
+
+    private static int unappliedCount(long unappliedCounters, Kind kind)
+    {
+        switch (kind)
+        {
+            default: throw new AssertionError("Unhandled Txn.Kind: " + kind);
+            case LocalOnly:
+            case EphemeralRead:
+            case ExclusiveSyncPoint:
+                throw illegalState("Invalid Txn.Kind for CommandsForKey: " + kind);
+
+            case SyncPoint:
+            case Write:
+                return (int)unappliedCounters;
+
+            case Read:
+                return (int) (unappliedCounters >>> 32);
+        }
+
+    }
+
+    public CommandsForKey withRedundantBeforeAtLeast(RedundantBefore.Entry newRedundantBefore)
+    {
+        Invariants.checkArgument(newRedundantBefore.shardRedundantBefore().compareTo(shardRedundantBefore()) >= 0, "Expect new RedundantBefore.Entry shardAppliedOrInvalidatedBefore to be ahead of existing one");
+
+        if (newRedundantBefore.equals(redundantBefore))
+            return this;
+
+        TxnInfo[] newInfos = byId;
+        int pos = insertPos(newRedundantBefore.shardRedundantBefore());
+        if (pos != 0)
+        {
+            newInfos = Arrays.copyOfRange(byId, pos, byId.length);
+            for (int i = 0 ; i < newInfos.length ; ++i)
+            {
+                TxnInfo txn = newInfos[i];
+                TxnId[] missing = txn.missing();
+                if (missing == NO_TXNIDS) continue;
+                missing = removeRedundantMissing(missing, newRedundantBefore.shardRedundantBefore());
+                newInfos[i] = txn.update(missing);
+            }
+        }
+
+        // TODO (expected): filter pending unmanageds
+        return new CommandsForKey(key, newRedundantBefore, prunedBefore, loadingPruned, newInfos, unmanageds);
+    }
+
+    /**
+     * Remove transitively redundant applied or invalidated commands
+     * @param pruneInterval the number of committed commands we must have prior to the first prune point candidate to trigger a prune attempt
+     * @param minHlcDelta do not prune any commands with an HLC within this distance of the prune point candidate
+     */
+    public CommandsForKey maybePrune(int pruneInterval, long minHlcDelta)
+    {
+        return Pruning.maybePrune(this, pruneInterval, minHlcDelta);
+    }
+
+    public CommandsForKeyUpdate registerHistorical(TxnId txnId)
+    {
+        return Updating.registerHistorical(this, txnId);
+    }
+
+    int insertPos(Timestamp timestamp)
+    {
+        int i = Arrays.binarySearch(byId, 0, byId.length, timestamp);
+        if (i < 0) i = -1 -i;
+        return i;
+    }
+
+    public TxnId findFirst()
+    {
+        return byId.length > 0 ? byId[0] : null;
+    }
+
+    @Override
+    public boolean equals(Object o)
+    {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        CommandsForKey that = (CommandsForKey) o;
+        return Objects.equals(key, that.key)
+               && Arrays.equals(byId, that.byId)
+               && Arrays.equals(unmanageds, that.unmanageds);
+    }
+
+    @Override
+    public int hashCode()
+    {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public CommandsForKey cfk()
+    {
+        return this;
+    }
+
+    @Override
+    PostProcess postProcess()
+    {
+        return null;
+    }
+
+    TxnInfo minUndecided()
+    {
+        return minUndecidedById < 0 ? null : byId[minUndecidedById];
+    }
+
+    int maxContiguousManagedAppliedIndex()
+    {
+        int i = maxAppliedWriteByExecuteAt + 1;
+        while (i < committedByExecuteAt.length)
+        {
+            TxnInfo txn = committedByExecuteAt[i];
+            if (txn.status != APPLIED && managesExecution(txn))
+                break;
+            ++i;
+        }
+        return i - 1;
+    }
+
+    TxnInfo maxContiguousManagedApplied()
+    {
+        int i = maxContiguousManagedAppliedIndex();
+        return i < 0 ? null : committedByExecuteAt[i];
+    }
+
+    private void checkIntegrity()
+    {
+        if (isParanoid())
+        {
+            Invariants.checkState(byId.length == 0 || byId[0].compareTo(shardRedundantBefore()) >= 0);
+            Invariants.checkState(prunedBefore == NO_INFO || (prunedBefore.status == APPLIED && prunedBefore.kind().isWrite()));
+            Invariants.checkState(minUndecidedById < 0 || byId[minUndecidedById].status.compareTo(COMMITTED) < 0 && managesExecution(byId[minUndecidedById]));
+            if (maxAppliedWriteByExecuteAt >= 0)
+            {
+                Invariants.checkState(committedByExecuteAt[maxAppliedWriteByExecuteAt].kind() == Write);
+                Invariants.checkState(committedByExecuteAt[maxAppliedWriteByExecuteAt].status == APPLIED);
+            }
+
+            if (testParanoia(LINEAR, NONE, LOW))
+            {
+                Invariants.checkArgument(SortedArrays.isSortedUnique(byId));
+                Invariants.checkArgument(SortedArrays.isSortedUnique(committedByExecuteAt, TxnInfo::compareExecuteAt));
+
+                if (minUndecidedById >= 0) for (int i = 0 ; i < minUndecidedById ; ++i) Invariants.checkState(byId[i].status.compareTo(COMMITTED) >= 0 || !managesExecution(byId[i]));
+                else for (TxnInfo txn : byId) Invariants.checkState(txn.status.compareTo(COMMITTED) >= 0 || !managesExecution(txn));
+
+                if (maxAppliedWriteByExecuteAt >= 0)
+                {
+                    for (int i = maxAppliedWriteByExecuteAt + 1; i < committedByExecuteAt.length ; ++i)
+                        Invariants.checkState(committedByExecuteAt[i].kind() != Kind.Write || committedByExecuteAt[i].status.compareTo(APPLIED) < 0);
+                }
+                else
+                {
+                    for (TxnInfo txn : committedByExecuteAt)
+                        Invariants.checkState(txn.kind() != Kind.Write || txn.status.compareTo(APPLIED) < 0);
+                }
+            }
+            if (testParanoia(SUPERLINEAR, NONE, LOW))
+            {
+                for (TxnInfo txn : committedByExecuteAt)
+                {
+                    Invariants.checkState(txn == get(txn, byId));
+                }
+                for (TxnInfo txn : byId)
+                {
+                    for (TxnId missingId : txn.missing())
+                    {
+                        Invariants.checkState(txn.kind().witnesses(missingId));
+                        TxnInfo missingInfo = get(missingId, byId);
+                        Invariants.checkState(missingInfo.status.compareTo(COMMITTED) < 0);
+                        Invariants.checkState(txn.depsKnownBefore().compareTo(missingInfo.witnessedAfter()) >= 0);
+                    }
+                    if (txn.status.isCommitted())
+                        Invariants.checkState(txn == committedByExecuteAt[Arrays.binarySearch(committedByExecuteAt, 0, committedByExecuteAt.length, txn, TxnInfo::compareExecuteAt)]);
+                }
+                for (Pruning.LoadingPruned txn : BTree.<Pruning.LoadingPruned>iterable(loadingPruned))
+                {
+                    Invariants.checkState(indexOf(txn) < 0);
+                }
+            }
+        }
+    }
+}
diff --git a/accord-core/src/main/java/accord/local/cfk/CommandsForKeyUpdate.java b/accord-core/src/main/java/accord/local/cfk/CommandsForKeyUpdate.java
new file mode 100644
index 00000000..5d990141
--- /dev/null
+++ b/accord-core/src/main/java/accord/local/cfk/CommandsForKeyUpdate.java
@@ -0,0 +1,72 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package accord.local.cfk;
+
+import javax.annotation.Nullable;
+
+import com.google.common.annotations.VisibleForTesting;
+
+import accord.local.Command;
+import accord.local.SafeCommandStore;
+
+/**
+ * Encapsulates an updated {@code CommandsForKey} and any follow-up notification work that cannot be performed as part
+ * of the {@code CommandsForKey} update because it can trigger updates that may lead to additional updates to the
+ * {@code CommandsForKey}, which would still be being constructed. We therefore first update the {@code CommandsForKey},
+ * then notify any work that might itself need to update the {@code CommandsForKey}.
+ */
+public abstract class CommandsForKeyUpdate
+{
+    @VisibleForTesting
+    public abstract CommandsForKey cfk();
+    abstract PostProcess postProcess();
+    abstract void postProcess(SafeCommandStore safeStore, @Nullable CommandsForKey prevCfk, @Nullable Command command, NotifySink notifySink);
+
+    static class CommandsForKeyUpdateWithNotifier extends CommandsForKeyUpdate
+    {
+        final CommandsForKey cfk;
+        final PostProcess postProcess;
+
+        CommandsForKeyUpdateWithNotifier(CommandsForKey cfk, PostProcess postProcess)
+        {
+            this.cfk = cfk;
+            this.postProcess = postProcess;
+        }
+
+        @Override
+        public CommandsForKey cfk()
+        {
+            return cfk;
+        }
+
+        @Override
+        PostProcess postProcess()
+        {
+            return postProcess;
+        }
+
+        @Override
+        void postProcess(SafeCommandStore safeStore, @Nullable CommandsForKey prevCfk, @Nullable Command command, NotifySink notifySink)
+        {
+            cfk.postProcess(safeStore, prevCfk, command, notifySink);
+            postProcess.postProcess(safeStore, cfk.key(), notifySink);
+        }
+    }
+}
+
diff --git a/accord-core/src/main/java/accord/local/cfk/NotifySink.java b/accord-core/src/main/java/accord/local/cfk/NotifySink.java
new file mode 100644
index 00000000..be8b1ca4
--- /dev/null
+++ b/accord-core/src/main/java/accord/local/cfk/NotifySink.java
@@ -0,0 +1,113 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package accord.local.cfk;
+
+import accord.api.Key;
+import accord.local.Command;
+import accord.local.Commands;
+import accord.local.CommonAttributes;
+import accord.local.PreLoadContext;
+import accord.local.SafeCommand;
+import accord.local.SafeCommandStore;
+import accord.local.Status;
+import accord.local.cfk.CommandsForKey.TxnInfo;
+import accord.primitives.Keys;
+import accord.primitives.Seekables;
+import accord.primitives.TxnId;
+
+import static accord.local.KeyHistory.COMMANDS;
+import static accord.local.SaveStatus.LocalExecution.WaitingToExecute;
+import static accord.local.cfk.CommandsForKey.InternalStatus.PREACCEPTED_OR_ACCEPTED_INVALIDATE;
+
+interface NotifySink
+{
+    void notWaiting(SafeCommandStore safeStore, TxnId txnId, Key key);
+
+    void notWaiting(SafeCommandStore safeStore, SafeCommand safeCommand, Key key);
+
+    void waitingOnCommit(SafeCommandStore safeStore, TxnInfo uncommitted, Key key);
+
+    class DefaultNotifySink implements NotifySink
+    {
+        static final DefaultNotifySink INSTANCE = new DefaultNotifySink();
+
+        @Override
+        public void notWaiting(SafeCommandStore safeStore, TxnId txnId, Key key)
+        {
+            SafeCommand safeCommand = safeStore.ifLoadedAndInitialised(txnId);
+            if (safeCommand != null) notWaiting(safeStore, safeCommand, key);
+            else
+            {
+                PreLoadContext context = PreLoadContext.contextFor(txnId);
+                safeStore.commandStore().execute(context, safeStore0 -> notWaiting(safeStore0, safeStore0.unsafeGet(txnId), key))
+                         .begin(safeStore.agent());
+            }
+        }
+
+        @Override
+        public void notWaiting(SafeCommandStore safeStore, SafeCommand safeCommand, Key key)
+        {
+            Commands.removeWaitingOnKeyAndMaybeExecute(safeStore, safeCommand, key);
+        }
+
+        @Override
+        public void waitingOnCommit(SafeCommandStore safeStore, TxnInfo uncommitted, Key key)
+        {
+            TxnId txnId = uncommitted.plainTxnId();
+            if (uncommitted.status.compareTo(PREACCEPTED_OR_ACCEPTED_INVALIDATE) < 0)
+            {
+                Keys keys = Keys.of(key);
+                PreLoadContext context = PreLoadContext.contextFor(txnId, keys);
+                if (safeStore.canExecuteWith(context)) doNotifyMaybeInvalidWaitingOnCommit(safeStore, txnId, key, keys);
+                else
+                    safeStore.commandStore().execute(PreLoadContext.contextFor(txnId, keys, COMMANDS), safeStore0 -> doNotifyMaybeInvalidWaitingOnCommit(safeStore0, txnId, key, keys)).begin(safeStore.agent());
+            }
+            else
+            {
+                safeStore.progressLog().waiting(txnId, WaitingToExecute, null, Keys.of(key).toParticipants());
+            }
+        }
+
+        private void doNotifyMaybeInvalidWaitingOnCommit(SafeCommandStore safeStore, TxnId txnId, Key key, Keys keys)
+        {
+            SafeCommand safeCommand = safeStore.unsafeGet(txnId);
+            safeCommand.initialise();
+            Command command = safeCommand.current();
+            Seekables<?, ?> keysOrRanges = command.keysOrRanges();
+            if (keysOrRanges == null || !keysOrRanges.contains(key))
+            {
+                CommonAttributes.Mutable attrs = command.mutable();
+                if (command.additionalKeysOrRanges() == null) attrs.additionalKeysOrRanges(keys);
+                else attrs.additionalKeysOrRanges(keys.with((Keys) command.additionalKeysOrRanges()));
+                safeCommand.update(safeStore, command.updateAttributes(attrs));
+            }
+            if (command.hasBeen(Status.Committed))
+            {
+                // if we're committed but not invalidated, that means EITHER we have raced with a commit+
+                // OR we adopted as a dependency a
+                safeStore.get(key).update(safeStore, safeCommand.current());
+            }
+            else
+            {
+                // TODO (desired): we could complicate our state machine to replicate PreCommitted here, so we can simply wait for ReadyToExclude
+                safeStore.progressLog().waiting(txnId, WaitingToExecute, null, keys.toParticipants());
+            }
+        }
+    }
+}
diff --git a/accord-core/src/main/java/accord/local/cfk/PostProcess.java b/accord-core/src/main/java/accord/local/cfk/PostProcess.java
new file mode 100644
index 00000000..a44fe8dc
--- /dev/null
+++ b/accord-core/src/main/java/accord/local/cfk/PostProcess.java
@@ -0,0 +1,245 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package accord.local.cfk;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import javax.annotation.Nullable;
+
+import accord.api.Key;
+import accord.local.PreLoadContext;
+import accord.local.SafeCommand;
+import accord.local.SafeCommandStore;
+import accord.local.cfk.CommandsForKey.TxnInfo;
+import accord.primitives.Keys;
+import accord.primitives.Timestamp;
+import accord.primitives.TxnId;
+import accord.utils.ArrayBuffers;
+import accord.utils.Invariants;
+import accord.utils.SortedArrays;
+import accord.utils.btree.BTree;
+
+import static accord.local.KeyHistory.COMMANDS;
+import static accord.local.cfk.CommandsForKey.InternalStatus.APPLIED;
+import static accord.local.cfk.CommandsForKey.InternalStatus.INVALID_OR_TRUNCATED_OR_UNMANAGED_COMMITTED;
+import static accord.local.cfk.CommandsForKey.Unmanaged.Pending.APPLY;
+import static accord.local.cfk.Updating.updateUnmanaged;
+import static accord.local.cfk.Updating.updateUnmanagedAsync;
+import static accord.local.cfk.Utils.findApply;
+import static accord.local.cfk.Utils.findCommit;
+import static accord.local.cfk.Utils.findFirstApply;
+import static accord.local.cfk.Utils.removeUnmanaged;
+import static accord.local.cfk.Utils.selectUnmanaged;
+
+abstract class PostProcess
+{
+    final PostProcess prev;
+
+    protected PostProcess(PostProcess prev)
+    {
+        this.prev = prev;
+    }
+
+    void postProcess(SafeCommandStore safeStore, Key key, NotifySink notifySink)
+    {
+        doNotify(safeStore, key, notifySink);
+        if (prev != null)
+            prev.postProcess(safeStore, key, notifySink);
+    }
+
+    abstract void doNotify(SafeCommandStore safeStore, Key key, NotifySink notifySink);
+
+    static class LoadPruned extends PostProcess
+    {
+        final TxnId[] load;
+
+        LoadPruned(PostProcess prev, TxnId[] load)
+        {
+            super(prev);
+            this.load = load;
+        }
+
+        void doNotify(SafeCommandStore safeStore, Key key, NotifySink notifySink)
+        {
+            SafeCommandsForKey safeCfk = safeStore.get(key);
+            for (TxnId txnId : load)
+            {
+                safeStore = safeStore; // make it unsafe for use in lambda
+                SafeCommand safeCommand = safeStore.ifLoadedAndInitialised(txnId);
+                if (safeCommand != null) load(safeStore, safeCommand, safeCfk, notifySink);
+                else
+                    safeStore.commandStore().execute(PreLoadContext.contextFor(txnId, Keys.of(key), COMMANDS), safeStore0 -> {
+                        load(safeStore0, safeStore0.unsafeGet(txnId), safeStore0.get(key), notifySink);
+                    }).begin(safeStore.agent());
+            }
+        }
+
+        static void load(SafeCommandStore safeStore, SafeCommand safeCommand, SafeCommandsForKey safeCfk, NotifySink notifySink)
+        {
+            safeCfk.updatePruned(safeStore, safeCommand.current(), notifySink);
+        }
+
+        static CommandsForKeyUpdate load(TxnId[] txnIds, CommandsForKeyUpdate result)
+        {
+            if (txnIds.length == 0)
+                return result;
+            return new CommandsForKeyUpdate.CommandsForKeyUpdateWithNotifier(result.cfk(), new LoadPruned(result.postProcess(), txnIds));
+        }
+    }
+
+    static class NotifyNotWaiting extends PostProcess
+    {
+        final TxnId[] notify;
+
+        NotifyNotWaiting(PostProcess prev, TxnId[] notify)
+        {
+            super(prev);
+            this.notify = notify;
+        }
+
+        void doNotify(SafeCommandStore safeStore, Key key, NotifySink notifySink)
+        {
+            for (TxnId txnId : notify)
+                notifySink.notWaiting(safeStore, txnId, key);
+        }
+    }
+
+    static class NotifyUnmanagedOfCommit extends PostProcess
+    {
+        final TxnId[] notify;
+
+        NotifyUnmanagedOfCommit(PostProcess prev, TxnId[] notify)
+        {
+            super(prev);
+            this.notify = notify;
+        }
+
+        void doNotify(SafeCommandStore safeStore, Key key, NotifySink notifySink)
+        {
+            SafeCommandsForKey safeCfk = safeStore.get(key);
+            CommandsForKey cfk = safeCfk.current();
+            List<CommandsForKey.Unmanaged> addUnmanageds = new ArrayList<>();
+            for (TxnId txnId : notify)
+            {
+                SafeCommand safeCommand = safeStore.ifLoadedAndInitialised(txnId);
+                if (safeCommand != null)
+                {
+                    Invariants.checkState(cfk == updateUnmanaged(cfk, safeStore, safeCommand, notifySink, false, addUnmanageds));
+                }
+                else
+                {
+                    updateUnmanagedAsync(safeStore.commandStore(), txnId, key, notifySink);
+                }
+            }
+
+            if (!addUnmanageds.isEmpty())
+            {
+                CommandsForKey cur = safeCfk.current();
+                addUnmanageds.sort(CommandsForKey.Unmanaged::compareTo);
+                CommandsForKey.Unmanaged[] newUnmanageds = addUnmanageds.toArray(new CommandsForKey.Unmanaged[0]);
+                newUnmanageds = SortedArrays.linearUnion(cur.unmanageds, 0, cur.unmanageds.length, newUnmanageds, 0, newUnmanageds.length, CommandsForKey.Unmanaged::compareTo, ArrayBuffers.uncached(CommandsForKey.Unmanaged[]::new));
+                safeCfk.set(cur.update(newUnmanageds));
+            }
+        }
+    }
+
+    static CommandsForKeyUpdate notifyUnmanaged(CommandsForKey cfk, @Nullable TxnInfo curInfo, TxnInfo newInfo)
+    {
+        PostProcess notifier = null;
+        CommandsForKey.Unmanaged[] unmanageds = cfk.unmanageds;
+        {
+            // notify commit uses exclusive bounds, as we use minUndecided
+            Timestamp minUndecided = cfk.minUndecidedById < 0 ? Timestamp.MAX : cfk.byId[cfk.minUndecidedById];
+            if (!BTree.isEmpty(cfk.loadingPruned)) minUndecided = Timestamp.min(minUndecided, BTree.<Pruning.LoadingPruned>findByIndex(cfk.loadingPruned, 0));
+            int end = findCommit(unmanageds, minUndecided);
+            if (end > 0)
+            {
+                TxnId[] notifyUnmanaged = new TxnId[end];
+                for (int i = 0 ; i < end ; ++i)
+                    notifyUnmanaged[i] = unmanageds[i].txnId;
+
+                unmanageds = Arrays.copyOfRange(unmanageds, end, unmanageds.length);
+                notifier = new PostProcess.NotifyUnmanagedOfCommit(null, notifyUnmanaged);
+            }
+        }
+
+        if (newInfo.status.compareTo(APPLIED) >= 0)
+        {
+            TxnInfo maxContiguousApplied = cfk.maxContiguousManagedApplied();
+            if (maxContiguousApplied != null && maxContiguousApplied.compareExecuteAt(newInfo) < 0)
+                maxContiguousApplied = null;
+
+            if (maxContiguousApplied != null)
+            {
+                int start = findFirstApply(unmanageds);
+                int end = findApply(unmanageds, start, maxContiguousApplied.executeAt);
+                if (start != end)
+                {
+                    TxnId[] notifyNotWaiting = selectUnmanaged(unmanageds, start, end);
+                    unmanageds = removeUnmanaged(unmanageds, start, end);
+                    notifier = new PostProcess.NotifyNotWaiting(notifier, notifyNotWaiting);
+                }
+            }
+        }
+        if (newInfo.status == INVALID_OR_TRUNCATED_OR_UNMANAGED_COMMITTED && curInfo != null && curInfo.status.isCommitted())
+        {
+            TxnInfo[] committedByExecuteAt = cfk.committedByExecuteAt;
+            // this is a rare edge case, but we might have unmanaged transactions waiting on this command we must re-schedule or notify
+            int start = findFirstApply(unmanageds);
+            int end = start;
+            while (end < unmanageds.length && unmanageds[end].waitingUntil.equals(curInfo.executeAt))
+                ++end;
+
+            if (start != end)
+            {
+                // find committed predecessor, if any
+                int predecessor = -2 - Arrays.binarySearch(committedByExecuteAt, curInfo, TxnInfo::compareExecuteAt);
+
+                if (predecessor >= 0)
+                {
+                    int maxContiguousApplied = cfk.maxContiguousManagedAppliedIndex();
+                    if (maxContiguousApplied >= predecessor)
+                        predecessor = -1;
+                }
+
+                if (predecessor >= 0)
+                {
+                    Timestamp waitingUntil = committedByExecuteAt[predecessor].plainExecuteAt();
+                    unmanageds = unmanageds.clone();
+                    for (int i = start ; i < end ; ++i)
+                        unmanageds[i] = new CommandsForKey.Unmanaged(APPLY, unmanageds[i].txnId, waitingUntil);
+                }
+                else
+                {
+                    TxnId[] notifyNotWaiting = selectUnmanaged(unmanageds, start, end);
+                    unmanageds = removeUnmanaged(unmanageds, start, end);
+                    notifier = new PostProcess.NotifyNotWaiting(notifier, notifyNotWaiting);
+                }
+            }
+        }
+
+        if (notifier == null)
+            return cfk;
+
+        return new CommandsForKeyUpdate.CommandsForKeyUpdateWithNotifier(new CommandsForKey(cfk, cfk.loadingPruned, unmanageds), notifier);
+    }
+
+}
diff --git a/accord-core/src/main/java/accord/local/cfk/Pruning.java b/accord-core/src/main/java/accord/local/cfk/Pruning.java
new file mode 100644
index 00000000..86d3f0e1
--- /dev/null
+++ b/accord-core/src/main/java/accord/local/cfk/Pruning.java
@@ -0,0 +1,332 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package accord.local.cfk;
+
+import java.util.Arrays;
+
+import accord.local.cfk.CommandsForKey.TxnInfo;
+import accord.primitives.Timestamp;
+import accord.primitives.TxnId;
+import accord.utils.ArrayBuffers;
+import accord.utils.Invariants;
+import accord.utils.SortedArrays;
+import accord.utils.btree.BTree;
+import accord.utils.btree.BTreeRemoval;
+import accord.utils.btree.UpdateFunction;
+
+import static accord.local.cfk.CommandsForKey.InternalStatus.APPLIED;
+import static accord.local.cfk.CommandsForKey.InternalStatus.INVALID_OR_TRUNCATED_OR_UNMANAGED_COMMITTED;
+import static accord.local.cfk.CommandsForKey.NO_TXNIDS;
+import static accord.local.cfk.CommandsForKey.managesExecution;
+import static accord.local.cfk.Pruning.LoadingPruned.LOADINGF;
+import static accord.utils.ArrayBuffers.cachedTimestamps;
+import static accord.utils.ArrayBuffers.cachedTxnIds;
+
+public class Pruning
+{
+    /**
+     * A TxnId that we have witnessed as a dependency that predates {@link #prunedBefore}, so we must load its
+     * Command state to determine if this is a new transaction to track, or if it is an already-applied transaction
+     * we have pruned.
+     *
+     * Intended to be stored in a BTree, and includes static methods for managing the BTree.
+     */
+    static class LoadingPruned extends TxnId
+    {
+        static final UpdateFunction.Simple<LoadingPruned> LOADINGF = UpdateFunction.Simple.of(LoadingPruned::merge);
+
+        /**
+         * Transactions that had witnessed this pre-pruned TxnId and are therefore waiting for the load to complete
+         */
+        final TxnId[] witnessedBy;
+
+        public LoadingPruned(TxnId copy, TxnId[] witnessedBy)
+        {
+            super(copy);
+            this.witnessedBy = witnessedBy;
+        }
+
+        LoadingPruned merge(LoadingPruned that)
+        {
+            return new LoadingPruned(this, SortedArrays.linearUnion(witnessedBy, that.witnessedBy, cachedTxnIds()));
+        }
+
+        static Object[] empty()
+        {
+            return BTree.empty();
+        }
+    }
+
+    /**
+     * Updating {@code loadingPruned} to register that each element of {@code toLoad} is being loaded for {@code loadingFor}
+     */
+    static Object[] loadPruned(Object[] loadingPruned, TxnId[] toLoad, TxnId loadingFor)
+    {
+        return loadPruned(loadingPruned, toLoad, new TxnId[]{ loadingFor });
+    }
+
+    static Object[] loadPruned(Object[] loadingPruned, TxnId[] toLoad, TxnId[] loadingForAsList)
+    {
+        Object[] toLoadAsTree;
+        try (BTree.FastBuilder<LoadingPruned> fastBuilder = BTree.fastBuilder())
+        {
+            for (TxnId txnId : toLoad)
+                fastBuilder.add(new LoadingPruned(txnId, loadingForAsList));
+            toLoadAsTree = fastBuilder.build();
+        }
+        return BTree.update(loadingPruned, toLoadAsTree, LoadingPruned::compareTo, LOADINGF);
+    }
+
+    /**
+     * Find the list of TxnId that are waiting for {@code find} to load
+     */
+    static TxnId[] loadingPrunedFor(Object[] loadingPruned, TxnId find, TxnId[] ifNoMatch)
+    {
+        LoadingPruned obj = (LoadingPruned) BTree.find(loadingPruned, TxnId::compareTo, find);
+        if (obj == null)
+            return ifNoMatch;
+
+        return obj.witnessedBy;
+    }
+
+    /**
+     * Updating {@code loadingPruned} to remove {@code find}, as it has been loaded
+     */
+    static Object[] removeLoadingPruned(Object[] loadingPruned, TxnId find)
+    {
+        return BTreeRemoval.remove(loadingPruned, TxnId::compareTo, find);
+    }
+
+    /**
+     * Return true if {@code waitingId} is waiting for any transaction with a lower TxnId than waitingExecuteAt
+     */
+    static boolean isWaitingOnPruned(Object[] loadingPruned, TxnId waitingId, Timestamp waitingExecuteAt)
+    {
+        if (BTree.isEmpty(loadingPruned))
+            return false;
+
+        int ceilIndex = BTree.ceilIndex(loadingPruned, Timestamp::compareTo, waitingExecuteAt);
+        // TODO (desired): this is O(n.lg n), whereas we could import the accumulate function and perform in O(max(m, lg n))
+        for (int i = 0; i < ceilIndex; ++i)
+        {
+            LoadingPruned loading = BTree.findByIndex(loadingPruned, i);
+            if (!managesExecution(loading)) continue;
+            if (Arrays.binarySearch(loading.witnessedBy, waitingId) >= 0)
+                return true;
+        }
+
+        return false;
+    }
+
+    /**
+     * Return true if {@code waitingId} is waiting for any transaction with a lower TxnId than waitingExecuteAt
+     */
+    static boolean isAnyPredecessorWaitingOnPruned(Object[] loadingPruned, TxnId waitingId)
+    {
+        if (BTree.isEmpty(loadingPruned))
+            return false;
+
+        int ceilIndex = BTree.ceilIndex(loadingPruned, Timestamp::compareTo, waitingId);
+        // TODO (desired): this is O(n.lg n), whereas we could import the accumulate function and perform in O(max(m, lg n))
+        for (int i = 0; i < ceilIndex; ++i)
+        {
+            LoadingPruned loading = BTree.findByIndex(loadingPruned, i);
+            if (!managesExecution(loading)) continue;
+            // if we exactly match any, or if we sort after the first element then we're waiting for some txnId <= waitingId
+            if (-1 != Arrays.binarySearch(loading.witnessedBy, waitingId))
+                return true;
+        }
+
+        return false;
+    }
+
+    /**
+     * Remove transitively redundant applied or invalidated commands
+     * @param pruneInterval the number of committed commands we must have prior to the first prune point candidate to trigger a prune attempt
+     * @param minHlcDelta do not prune any commands with an HLC within this distance of the prune point candidate
+     */
+    static CommandsForKey maybePrune(CommandsForKey cfk, int pruneInterval, long minHlcDelta)
+    {
+        TxnInfo newPrunedBefore;
+        {
+            if (cfk.maxAppliedWriteByExecuteAt < pruneInterval)
+                return cfk;
+
+            int i = cfk.maxAppliedWriteByExecuteAt;
+            long maxPruneHlc = cfk.committedByExecuteAt[i].executeAt.hlc() - minHlcDelta;
+            while (--i >= 0)
+            {
+                TxnInfo txn = cfk.committedByExecuteAt[i];
+                if (txn.kind().isWrite() && txn.executeAt.hlc() <= maxPruneHlc && txn.status == APPLIED)
+                    break;
+            }
+
+            if (i < 0)
+                return cfk;
+
+            newPrunedBefore = cfk.committedByExecuteAt[i];
+            if (newPrunedBefore.compareTo(cfk.prunedBefore) <= 0)
+                return cfk;
+        }
+
+        int pos = cfk.insertPos(newPrunedBefore);
+        if (pos == 0)
+            return cfk;
+
+        return pruneBefore(cfk, newPrunedBefore, pos);
+    }
+
+    /**
+     * We can prune anything transitively applied where some later stable command replicates each of its missing array entries.
+     * These later commands can durably stand in for any recovery or dependency calculations.
+     */
+    static CommandsForKey pruneBefore(CommandsForKey cfk, TxnInfo newPrunedBefore, int pos)
+    {
+        Invariants.checkArgument(newPrunedBefore.compareTo(cfk.prunedBefore) >= 0, "Expect new prunedBefore to be ahead of existing one");
+
+        TxnInfo[] byId = cfk.byId;
+        int minUndecidedById = -1;
+        int retainCount = 0, removedCommittedCount = 0;
+        Timestamp[] removedExecuteAts = NO_TXNIDS;
+        int removedExecuteAtCount = 0;
+        TxnInfo[] newInfos;
+        {
+            ArrayBuffers.RecursiveObjectBuffers<TxnId> missingBuffers = new ArrayBuffers.RecursiveObjectBuffers<>(cachedTxnIds());
+            TxnId[] mergedMissing = newPrunedBefore.missing();
+            int mergedMissingCount = mergedMissing.length;
+            for (int i = 0 ; i < pos ; ++i)
+            {
+                TxnInfo txn = byId[i];
+                switch (txn.status)
+                {
+                    default: throw new AssertionError("Unhandled status: " + txn.status);
+                    case COMMITTED:
+                    case STABLE:
+                        ++retainCount;
+                        break;
+
+                    case HISTORICAL:
+                    case TRANSITIVELY_KNOWN:
+                    case PREACCEPTED_OR_ACCEPTED_INVALIDATE:
+                    case ACCEPTED:
+                        if (minUndecidedById < 0 && managesExecution(txn))
+                            minUndecidedById = retainCount;
+                        ++retainCount;
+                        break;
+
+                    case APPLIED:
+                        if (txn.executeAt.compareTo(newPrunedBefore.executeAt) < 0)
+                        {
+                            TxnId[] missing = txn.missing();
+                            if (missing == NO_TXNIDS || SortedArrays.isSubset(missing, 0, missing.length, mergedMissing, 0, mergedMissingCount))
+                            {
+                                if (missing != NO_TXNIDS)
+                                {
+                                    if (removedExecuteAtCount == removedExecuteAts.length)
+                                        removedExecuteAts = cachedTimestamps().resize(removedExecuteAts, removedExecuteAtCount, Math.max(8, removedExecuteAtCount + (removedExecuteAtCount >> 1)));
+                                    removedExecuteAts[removedExecuteAtCount++] = txn.executeAt;
+                                }
+                                ++removedCommittedCount;
+                                continue;
+                            }
+                            if (txn.executeAt == txn)
+                            {
+                                mergedMissing = SortedArrays.linearUnion(missing, missing.length, mergedMissing, mergedMissingCount, missingBuffers);
+                                mergedMissingCount = missingBuffers.sizeOfLast(mergedMissing);
+                            }
+                        }
+                        ++retainCount;
+
+                    case INVALID_OR_TRUNCATED_OR_UNMANAGED_COMMITTED:
+                        break;
+                }
+            }
+
+            if (pos == retainCount)
+                return cfk;
+
+            int removedByIdCount = pos - retainCount;
+            newInfos = new TxnInfo[byId.length - removedByIdCount];
+            if (minUndecidedById < 0 && cfk.minUndecidedById >= 0) // must occur later, so deduct all removals
+                minUndecidedById = cfk.minUndecidedById - removedByIdCount;
+            missingBuffers.discardBuffers();
+        }
+
+        {   // copy to new byTxnId array
+            int insertPos = retainCount;
+            int removedExecuteAtPos = removedExecuteAtCount - 1;
+            for (int i = pos - 1; i >= 0 ; --i)
+            {
+                TxnInfo txn = byId[i];
+                if (txn.status == INVALID_OR_TRUNCATED_OR_UNMANAGED_COMMITTED)
+                    continue;
+
+                if (txn.status == APPLIED && txn.executeAt.compareTo(newPrunedBefore.executeAt) < 0)
+                {
+                    TxnId[] missing = txn.missing();
+                    if (missing == NO_TXNIDS)
+                        continue;
+
+                    if (removedExecuteAtPos >= 0 && removedExecuteAts[removedExecuteAtPos] == txn.executeAt)
+                    {
+                        --removedExecuteAtPos;
+                        continue;
+                    }
+                }
+
+                newInfos[--insertPos] = txn;
+            }
+            Invariants.checkState(retainCount + byId.length - pos == newInfos.length);
+            System.arraycopy(byId, pos, newInfos, retainCount, byId.length - pos);
+        }
+
+        TxnInfo[] committedByExecuteAt = cfk.committedByExecuteAt;
+        TxnInfo[] newCommittedByExecuteAt;
+        {   // copy to new committedByExecuteAt array
+            Arrays.sort(removedExecuteAts, 0, removedExecuteAtCount);
+            newCommittedByExecuteAt = new TxnInfo[committedByExecuteAt.length - removedCommittedCount];
+            int sourcePos = Arrays.binarySearch(committedByExecuteAt, newPrunedBefore, TxnInfo::compareExecuteAt);
+            int insertPos = sourcePos - removedCommittedCount;
+            int removedExecuteAtPos = removedExecuteAtCount - 1;
+            for (int i = sourcePos - 1; i >= 0 ; --i)
+            {
+                TxnInfo txn = committedByExecuteAt[i];
+                if (txn.status == APPLIED && txn.compareTo(newPrunedBefore) < 0)
+                {
+                    TxnId[] missing = txn.missing();
+                    if (missing == NO_TXNIDS)
+                        continue;
+
+                    if (removedExecuteAtPos >= 0 && removedExecuteAts[removedExecuteAtPos] == txn.executeAt)
+                    {
+                        --removedExecuteAtPos;
+                        continue;
+                    }
+                }
+
+                newCommittedByExecuteAt[--insertPos] = txn;
+            }
+            System.arraycopy(committedByExecuteAt, sourcePos, newCommittedByExecuteAt, sourcePos - removedCommittedCount, committedByExecuteAt.length - sourcePos);
+        }
+
+        cachedTimestamps().forceDiscard(removedExecuteAts, removedExecuteAtCount);
+        int newMaxAppliedWriteByExecuteAt = cfk.maxAppliedWriteByExecuteAt - removedCommittedCount;
+        return new CommandsForKey(cfk.key, cfk.redundantBefore, newPrunedBefore, cfk.loadingPruned, newInfos, newCommittedByExecuteAt, minUndecidedById, newMaxAppliedWriteByExecuteAt, cfk.unmanageds);
+    }
+}
diff --git a/accord-core/src/main/java/accord/local/cfk/SafeCommandsForKey.java b/accord-core/src/main/java/accord/local/cfk/SafeCommandsForKey.java
new file mode 100644
index 00000000..46946c07
--- /dev/null
+++ b/accord-core/src/main/java/accord/local/cfk/SafeCommandsForKey.java
@@ -0,0 +1,112 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package accord.local.cfk;
+
+import javax.annotation.Nullable;
+
+import accord.api.Agent;
+import accord.api.Key;
+import accord.impl.SafeState;
+import accord.local.Command;
+import accord.local.RedundantBefore;
+import accord.local.SafeCommand;
+import accord.local.SafeCommandStore;
+import accord.local.Status;
+import accord.primitives.TxnId;
+
+public abstract class SafeCommandsForKey implements SafeState<CommandsForKey>
+{
+    private final Key key;
+
+    public SafeCommandsForKey(Key key)
+    {
+        this.key = key;
+    }
+
+    protected abstract void set(CommandsForKey update);
+
+    public Key key()
+    {
+        return key;
+    }
+
+    void updatePruned(SafeCommandStore safeStore, Command nextCommand, NotifySink notifySink)
+    {
+        CommandsForKey prevCfk = current();
+        update(safeStore, nextCommand, prevCfk, prevCfk.updatePruned(nextCommand), notifySink);
+    }
+
+    public void update(SafeCommandStore safeStore, Command nextCommand)
+    {
+        CommandsForKey prevCfk = current();
+        update(safeStore, nextCommand, prevCfk, prevCfk.update(nextCommand));
+    }
+
+    private void update(SafeCommandStore safeStore, @Nullable Command command, CommandsForKey prevCfk, CommandsForKeyUpdate updateCfk)
+    {
+        update(safeStore, command, prevCfk, updateCfk, NotifySink.DefaultNotifySink.INSTANCE);
+    }
+
+    private void update(SafeCommandStore safeStore, @Nullable Command command, CommandsForKey prevCfk, CommandsForKeyUpdate updateCfk, NotifySink notifySink)
+    {
+        if (updateCfk == prevCfk)
+            return;
+
+        CommandsForKey nextCfk = updateCfk.cfk();
+        if (nextCfk != prevCfk)
+        {
+            if (command != null && command.hasBeen(Status.Applied))
+            {
+                Agent agent = safeStore.agent();
+                nextCfk = nextCfk.maybePrune(agent.cfkPruneInterval(), agent.cfkHlcPruneDelta());
+            }
+            set(nextCfk);
+        }
+
+        updateCfk.postProcess(safeStore, prevCfk, command, notifySink);
+    }
+
+    public void registerUnmanaged(SafeCommandStore safeStore, SafeCommand unmanaged)
+    {
+        CommandsForKey prevCfk = current();
+        update(safeStore, null, prevCfk, prevCfk.registerUnmanaged(safeStore, unmanaged));
+    }
+
+    public void registerHistorical(SafeCommandStore safeStore, TxnId txnId)
+    {
+        CommandsForKey prevCfk = current();
+        update(safeStore, null, prevCfk, prevCfk.registerHistorical(txnId));
+    }
+
+    public void updateRedundantBefore(SafeCommandStore safeStore, RedundantBefore.Entry redundantBefore)
+    {
+        CommandsForKey prevCfk = current();
+        update(safeStore, null, prevCfk, prevCfk.withRedundantBeforeAtLeast(redundantBefore));
+    }
+
+    public void initialize()
+    {
+        set(new CommandsForKey(key));
+    }
+
+    public void refresh(SafeCommandStore safeStore)
+    {
+        updateRedundantBefore(safeStore, safeStore.commandStore().redundantBefore().get(key));
+    }
+}
diff --git a/accord-core/src/main/java/accord/local/cfk/Updating.java b/accord-core/src/main/java/accord/local/cfk/Updating.java
new file mode 100644
index 00000000..4b674f67
--- /dev/null
+++ b/accord-core/src/main/java/accord/local/cfk/Updating.java
@@ -0,0 +1,898 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package accord.local.cfk;
+
+import java.util.Arrays;
+import java.util.List;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import accord.api.Key;
+import accord.local.Command;
+import accord.local.CommandStore;
+import accord.local.PreLoadContext;
+import accord.local.SafeCommand;
+import accord.local.SafeCommandStore;
+import accord.local.Status;
+import accord.local.cfk.CommandsForKey.TxnInfo;
+import accord.primitives.Ballot;
+import accord.primitives.Keys;
+import accord.primitives.Timestamp;
+import accord.primitives.Txn;
+import accord.primitives.TxnId;
+import accord.utils.Invariants;
+import accord.utils.RelationMultiMap;
+import accord.utils.SortedArrays;
+import accord.utils.SortedCursor;
+
+import static accord.local.KeyHistory.COMMANDS;
+import static accord.local.cfk.CommandsForKey.InternalStatus.APPLIED;
+import static accord.local.cfk.CommandsForKey.InternalStatus.COMMITTED;
+import static accord.local.cfk.CommandsForKey.InternalStatus.HISTORICAL;
+import static accord.local.cfk.CommandsForKey.InternalStatus.INVALID_OR_TRUNCATED_OR_UNMANAGED_COMMITTED;
+import static accord.local.cfk.CommandsForKey.InternalStatus.TRANSITIVELY_KNOWN;
+import static accord.local.cfk.CommandsForKey.NO_TXNIDS;
+import static accord.local.cfk.CommandsForKey.managesExecution;
+import static accord.local.cfk.CommandsForKey.Unmanaged.Pending.APPLY;
+import static accord.local.cfk.CommandsForKey.Unmanaged.Pending.COMMIT;
+import static accord.local.cfk.Pruning.loadPruned;
+import static accord.local.cfk.Pruning.loadingPrunedFor;
+import static accord.local.cfk.Utils.insertMissing;
+import static accord.local.cfk.Utils.mergeAndFilterMissing;
+import static accord.local.cfk.Utils.missingTo;
+import static accord.local.cfk.Utils.removeOneMissing;
+import static accord.local.cfk.Utils.removePrunedAdditions;
+import static accord.local.cfk.Utils.validateMissing;
+import static accord.primitives.Txn.Kind.EphemeralRead;
+import static accord.primitives.Txn.Kind.ExclusiveSyncPoint;
+import static accord.primitives.Txn.Kind.Write;
+import static accord.utils.ArrayBuffers.cachedTxnIds;
+import static accord.utils.Invariants.Paranoia.NONE;
+import static accord.utils.Invariants.Paranoia.SUPERLINEAR;
+import static accord.utils.Invariants.ParanoiaCostFactor.LOW;
+import static accord.utils.Invariants.illegalState;
+import static accord.utils.Invariants.testParanoia;
+import static accord.utils.SortedArrays.Search.FAST;
+
+class Updating
+{
+    private static final Logger logger = LoggerFactory.getLogger(CommandsForKey.class);
+
+    /**
+     * A TxnInfo to insert alongside any dependencies we did not already know about
+     */
+    static class InfoWithAdditions
+    {
+        final TxnInfo info;
+        // a cachedTxnIds() array that should be returned once done
+        final TxnId[] additions;
+        final int additionCount;
+
+        InfoWithAdditions(TxnInfo info, TxnId[] additions, int additionCount)
+        {
+            this.info = info;
+            this.additions = additions;
+            this.additionCount = additionCount;
+        }
+    }
+
+    static CommandsForKeyUpdate insertOrUpdate(CommandsForKey cfk, int insertPos, int updatePos, TxnId plainTxnId, TxnInfo curInfo, CommandsForKey.InternalStatus newStatus, Command command)
+    {
+        // TODO (now): do not calculate any deps or additions if we're transitioning from Stable to Applied; wasted effort and might trigger LoadPruned
+        Object newInfoObj = computeInfoAndAdditions(cfk, insertPos, updatePos, plainTxnId, newStatus, command);
+        if (newInfoObj.getClass() != InfoWithAdditions.class)
+            return insertOrUpdate(cfk, insertPos, plainTxnId, curInfo, (TxnInfo)newInfoObj, false, null);
+
+        InfoWithAdditions newInfoWithAdditions = (InfoWithAdditions) newInfoObj;
+        TxnId[] additions = newInfoWithAdditions.additions;
+        int additionCount = newInfoWithAdditions.additionCount;
+        TxnInfo newInfo = newInfoWithAdditions.info;
+
+        TxnId[] prunedIds = removePrunedAdditions(additions, additionCount, cfk.prunedBefore());
+        Object[] newLoadingPruned = cfk.loadingPruned;
+        if (prunedIds != NO_TXNIDS)
+        {
+            additionCount -= prunedIds.length;
+            newLoadingPruned = Pruning.loadPruned(cfk.loadingPruned, prunedIds, plainTxnId);
+        }
+
+        int committedByExecuteAtUpdatePos = committedByExecuteAtUpdatePos(cfk.committedByExecuteAt, curInfo, newInfo);
+        TxnInfo[] newCommittedByExecuteAt = updateCommittedByExecuteAt(cfk, committedByExecuteAtUpdatePos, newInfo, false);
+        int newMaxAppliedWriteByExecuteAt = updateMaxAppliedWriteByExecuteAt(cfk, committedByExecuteAtUpdatePos, newInfo, newCommittedByExecuteAt, false);
+
+        TxnInfo[] byId = cfk.byId;
+        TxnInfo[] newById = new TxnInfo[byId.length + additionCount + (updatePos < 0 ? 1 : 0)];
+        insertOrUpdateWithAdditions(byId, insertPos, updatePos, plainTxnId, newInfo, additions, additionCount, newById, newCommittedByExecuteAt, NO_TXNIDS);
+        if (testParanoia(SUPERLINEAR, NONE, LOW))
+            validateMissing(newById, additions, additionCount, curInfo, newInfo, NO_TXNIDS);
+
+        int newMinUndecidedById = cfk.minUndecidedById;
+        {
+            TxnInfo curMinUndecided = cfk.minUndecided();
+            int additionsBeforeOldUndecided = additionCount;
+            if (curMinUndecided != null && additionCount > 0)
+            {
+                additionsBeforeOldUndecided = Arrays.binarySearch(additions, 0, additionCount, curMinUndecided);
+                if (additionsBeforeOldUndecided < 0)
+                    additionsBeforeOldUndecided = -1 - additionsBeforeOldUndecided;
+            }
+
+            TxnId newMinUndecided = null;
+            for (int i = 0 ; i < additionsBeforeOldUndecided ; ++i)
+            {
+                TxnId addition = additions[i];
+                if (!managesExecution(addition))
+                    continue;
+
+                newMinUndecided = addition;
+                break;
+            }
+
+            if ((newMinUndecided == null || newMinUndecided.compareTo(newInfo) > 0) && newInfo.status.compareTo(COMMITTED) < 0 && managesExecution(newInfo))
+                newMinUndecided = newInfo;
+
+            if (newMinUndecided != null && curMinUndecided != null && curMinUndecided.compareTo(newMinUndecided) < 0)
+                newMinUndecided = null;
+
+            if (updatePos < 0 && newMinUndecided == null && curMinUndecided != null && newInfo.compareTo(curMinUndecided) < 0)
+                ++additionsBeforeOldUndecided;
+
+            if (newMinUndecided == null && curMinUndecided == curInfo)
+                newMinUndecidedById = nextUndecided(newById, insertPos + 1);
+            else if (newMinUndecided == null && newMinUndecidedById >= 0)
+                newMinUndecidedById += additionsBeforeOldUndecided;
+            else if (newMinUndecided == newInfo)
+                newMinUndecidedById = insertPos + -1 - Arrays.binarySearch(additions, 0, additionCount, newInfo);
+            else if (newMinUndecided != null)
+                newMinUndecidedById = Arrays.binarySearch(newById, 0, newById.length, newMinUndecided);
+        }
+
+        cachedTxnIds().forceDiscard(additions, additionCount + prunedIds.length);
+        return PostProcess.LoadPruned.load(prunedIds, cfk.update(newById, newMinUndecidedById, newCommittedByExecuteAt, newMaxAppliedWriteByExecuteAt, newLoadingPruned, plainTxnId, curInfo, newInfo));
+    }
+
+    static Object computeInfoAndAdditions(CommandsForKey cfk, int insertPos, int updatePos, TxnId txnId, CommandsForKey.InternalStatus newStatus, Command command)
+    {
+        Invariants.checkState(newStatus.hasExecuteAtOrDeps);
+        Timestamp executeAt = command.executeAt();
+        if (executeAt.equals(txnId)) executeAt = txnId;
+        Ballot ballot = Ballot.ZERO;
+        if (newStatus.hasBallot)
+            ballot = command.acceptedOrCommitted();
+
+        Timestamp depsKnownBefore = newStatus.depsKnownBefore(txnId, executeAt);
+        SortedCursor<TxnId> deps = command.partialDeps().txnIds(cfk.key());
+        deps.find(cfk.redundantBefore().shardRedundantBefore());
+
+        return Updating.computeInfoAndAdditions(cfk.byId, insertPos, updatePos, txnId, newStatus, ballot, executeAt, depsKnownBefore, deps);
+    }
+
+    /**
+     * We return an Object here to avoid wasting allocations; most of the time we expect a new TxnInfo to be returned,
+     * but if we have transitive dependencies to insert we return an InfoWithAdditions
+     */
+    static Object computeInfoAndAdditions(TxnInfo[] byId, int insertPos, int updatePos, TxnId plainTxnId, CommandsForKey.InternalStatus newStatus, Ballot ballot, Timestamp executeAt, Timestamp depsKnownBefore, SortedCursor<TxnId> deps)
+    {
+        TxnId[] additions = NO_TXNIDS, missing = NO_TXNIDS;
+        int additionCount = 0, missingCount = 0;
+
+        // the position until which we should have witnessed transactions, i.e. for computing the missing collection
+        // *NOT* to be used for terminating *inserts* from deps parameter, as this may see the future
+        // (due to pruning sometimes including a later transaction where it cannot include all earlier ones)
+        int depsKnownBeforePos = insertPos;
+        if (depsKnownBefore != plainTxnId)
+        {
+            depsKnownBeforePos = Arrays.binarySearch(byId, insertPos, byId.length, depsKnownBefore);
+            Invariants.checkState(depsKnownBeforePos < 0);
+            depsKnownBeforePos = -1 - depsKnownBeforePos;
+        }
+
+        int txnIdsIndex = 0;
+        while (txnIdsIndex < byId.length && deps.hasCur())
+        {
+            TxnInfo t = byId[txnIdsIndex];
+            TxnId d = deps.cur();
+            int c = t.compareTo(d);
+            if (c == 0)
+            {
+                ++txnIdsIndex;
+                deps.advance();
+            }
+            else if (c < 0)
+            {
+                // we expect to be missing ourselves
+                // we also permit any transaction we have recorded as COMMITTED or later to be missing, as recovery will not need to consult our information
+                if (txnIdsIndex != updatePos && txnIdsIndex < depsKnownBeforePos && t.status.compareTo(COMMITTED) < 0 && plainTxnId.kind().witnesses(t))
+                {
+                    if (missingCount == missing.length)
+                        missing = cachedTxnIds().resize(missing, missingCount, Math.max(8, missingCount * 2));
+                    missing[missingCount++] = t.plainTxnId();
+                }
+                txnIdsIndex++;
+            }
+            else
+            {
+                if (plainTxnId.kind().witnesses(d))
+                {
+                    if (additionCount >= additions.length)
+                        additions = cachedTxnIds().resize(additions, additionCount, Math.max(8, additionCount * 2));
+
+                    additions[additionCount++] = d;
+                }
+                else
+                {
+                    // we can take dependencies on ExclusiveSyncPoints to represent a GC point in the log
+                    // if we don't ordinarily witness a transaction it is meaningless to include it as a dependency
+                    // as we will not logically be able to work with it (the missing collection will not correctly represent it anyway)
+                    Invariants.checkState(d.kind() == ExclusiveSyncPoint);
+                }
+                deps.advance();
+            }
+        }
+
+        if (deps.hasCur())
+        {
+            do
+            {
+                if (additionCount >= additions.length)
+                    additions = cachedTxnIds().resize(additions, additionCount, Math.max(8, additionCount * 2));
+                additions[additionCount++] = deps.cur();
+                deps.advance();
+            }
+            while (deps.hasCur());
+        }
+        else if (txnIdsIndex < byId.length)
+        {
+            while (txnIdsIndex < depsKnownBeforePos)
+            {
+                if (txnIdsIndex != updatePos && byId[txnIdsIndex].status.compareTo(COMMITTED) < 0)
+                {
+                    TxnId txnId = byId[txnIdsIndex].plainTxnId();
+                    if ((plainTxnId.kind().witnesses(txnId)))
+                    {
+                        if (missingCount == missing.length)
+                            missing = cachedTxnIds().resize(missing, missingCount, Math.max(8, missingCount * 2));
+                        missing[missingCount++] = txnId;
+                    }
+                }
+                txnIdsIndex++;
+            }
+        }
+
+        TxnInfo info = TxnInfo.create(plainTxnId, newStatus, executeAt, cachedTxnIds().completeAndDiscard(missing, missingCount), ballot);
+        if (additionCount == 0)
+            return info;
+
+        return new InfoWithAdditions(info, additions, additionCount);
+    }
+
+    static CommandsForKeyUpdate insertOrUpdate(CommandsForKey cfk, int pos, TxnId plainTxnId, TxnInfo curInfo, TxnInfo newInfo, boolean wasPruned, @Nullable TxnId[] loadingAsPrunedFor)
+    {
+        if (curInfo == newInfo)
+            return cfk;
+
+        Object[] newLoadingPruned = cfk.loadingPruned;
+        if (loadingAsPrunedFor != null) newLoadingPruned = Pruning.removeLoadingPruned(newLoadingPruned, plainTxnId);
+
+        int committedByExecuteAtUpdatePos = committedByExecuteAtUpdatePos(cfk.committedByExecuteAt, curInfo, newInfo);
+        TxnInfo[] newCommittedByExecuteAt = updateCommittedByExecuteAt(cfk, committedByExecuteAtUpdatePos, newInfo, wasPruned);
+        int newMaxAppliedWriteByExecuteAt = updateMaxAppliedWriteByExecuteAt(cfk, committedByExecuteAtUpdatePos, newInfo, newCommittedByExecuteAt, wasPruned);
+
+        int newMinUndecidedById = cfk.minUndecidedById;
+        TxnInfo[] byId = cfk.byId;
+        TxnInfo[] newById;
+        if (curInfo == null)
+        {
+            newById = new TxnInfo[byId.length + 1];
+            System.arraycopy(byId, 0, newById, 0, pos);
+            newById[pos] = newInfo;
+            System.arraycopy(byId, pos, newById, pos + 1, byId.length - pos);
+            if (managesExecution(newInfo))
+            {
+                if (newInfo.status.compareTo(COMMITTED) >= 0)
+                {
+                    if (newMinUndecidedById < 0 || pos <= newMinUndecidedById)
+                    {
+                        if (pos < newMinUndecidedById) ++newMinUndecidedById;
+                        else newMinUndecidedById = nextUndecided(newById, pos + 1);
+                    }
+                }
+                else
+                {
+                    if (newMinUndecidedById < 0 || pos < newMinUndecidedById)
+                        newMinUndecidedById = pos;
+                }
+            }
+            else if (pos <= newMinUndecidedById)
+                ++newMinUndecidedById;
+        }
+        else
+        {
+            newById = byId.clone();
+            newById[pos] = newInfo;
+            if (pos == newMinUndecidedById && curInfo.status.compareTo(COMMITTED) < 0 && newInfo.status.compareTo(COMMITTED) >= 0)
+                newMinUndecidedById = nextUndecided(newById, pos + 1);
+        }
+
+        if (loadingAsPrunedFor == null)
+            loadingAsPrunedFor = NO_TXNIDS;
+
+        if (newCommittedByExecuteAt.length > cfk.committedByExecuteAt.length)
+        {
+            Utils.removeFromMissingArrays(newById, newCommittedByExecuteAt, plainTxnId);
+        }
+        else if (curInfo != null && curInfo.status.compareTo(COMMITTED) < 0 && newInfo.status == INVALID_OR_TRUNCATED_OR_UNMANAGED_COMMITTED)
+        {
+            Utils.removeFromMissingArrays(newById, newCommittedByExecuteAt, plainTxnId);
+        }
+        else if (curInfo == null && newInfo.status != INVALID_OR_TRUNCATED_OR_UNMANAGED_COMMITTED)
+        {
+            // TODO (desired): for consistency, move this to insertOrUpdate (without additions), while maintaining the efficiency
+            Utils.addToMissingArrays(newById, newCommittedByExecuteAt, newInfo, plainTxnId, loadingAsPrunedFor);
+        }
+
+        if (testParanoia(SUPERLINEAR, NONE, LOW) && curInfo == null && newInfo.status.compareTo(COMMITTED) < 0)
+            validateMissing(newById, NO_TXNIDS, 0, curInfo, newInfo, loadingAsPrunedFor);
+
+        return cfk.update(newById, newMinUndecidedById, newCommittedByExecuteAt, newMaxAppliedWriteByExecuteAt, newLoadingPruned, plainTxnId, curInfo, newInfo);
+    }
+
+    /**
+     * Update newById to insert or update newInfo; insert any additions, and update any relevant missing arrays in
+     * both newById and newCommittedByExecuteAt (for both deletions and insertions).
+     */
+    static void insertOrUpdateWithAdditions(TxnInfo[] byId, int sourceInsertPos, int sourceUpdatePos, TxnId plainTxnId, TxnInfo newInfo, TxnId[] additions, int additionCount, TxnInfo[] newById, TxnInfo[] newCommittedByExecuteAt, @Nonnull TxnId[] witnessedBy)
+    {
+        int additionInsertPos = Arrays.binarySearch(additions, 0, additionCount, plainTxnId);
+        additionInsertPos = Invariants.checkArgument(-1 - additionInsertPos, additionInsertPos < 0);
+        int targetInsertPos = sourceInsertPos + additionInsertPos;
+
+        // we may need to insert or remove ourselves, depending on the new and prior status
+        boolean insertSelfMissing = sourceUpdatePos < 0 && newInfo.status.compareTo(COMMITTED) < 0;
+        boolean removeSelfMissing = sourceUpdatePos >= 0 && newInfo.status.compareTo(COMMITTED) >= 0 && byId[sourceUpdatePos].status.compareTo(COMMITTED) < 0;
+        // missingSource starts as additions, but if we insertSelfMissing at the relevant moment it becomes the merge of additions and plainTxnId
+        TxnId[] missingSource = additions;
+
+        // the most recently constructed pure insert missing array, so that it may be reused if possible
+        int i = 0, j = 0, missingCount = 0, missingLimit = additionCount, count = 0;
+        int minByExecuteAtSearchIndex = 0;
+        while (i < byId.length)
+        {
+            if (count == targetInsertPos)
+            {
+                newById[count] = newInfo;
+                if (i == sourceUpdatePos) ++i;
+                else if (insertSelfMissing) ++missingCount;
+                ++count;
+                continue;
+            }
+
+            int c = j == additionCount ? -1 : byId[i].compareTo(additions[j]);
+            if (c < 0)
+            {
+                TxnInfo txn = byId[i];
+                if (i == sourceUpdatePos)
+                {
+                    txn = newInfo;
+                }
+                else if (txn.status.hasDeps())
+                {
+                    Timestamp depsKnownBefore = txn.depsKnownBefore();
+                    if (insertSelfMissing && missingSource == additions && (missingCount != j || (depsKnownBefore != txn && depsKnownBefore.compareTo(plainTxnId) > 0)))
+                    {
+                        // add ourselves to the missing collection
+                        missingSource = insertMissing(additions, additionCount, plainTxnId, additionInsertPos);
+                        ++missingLimit;
+                    }
+
+                    int to = missingTo(txn, depsKnownBefore, missingSource, missingCount, missingLimit);
+                    if (to > 0 || removeSelfMissing)
+                    {
+                        TxnId[] curMissing = txn.missing();
+                        TxnId[] newMissing = curMissing;
+                        if (to > 0)
+                        {
+                            TxnId skipInsertMissing = null;
+                            if (Arrays.binarySearch(witnessedBy, plainTxnId) >= 0)
+                                skipInsertMissing = plainTxnId;
+
+                            newMissing = mergeAndFilterMissing(txn, curMissing, missingSource, to, skipInsertMissing);
+                        }
+
+                        if (removeSelfMissing)
+                            newMissing = removeOneMissing(newMissing, plainTxnId);
+
+                        if (newMissing != curMissing)
+                        {
+                            TxnInfo newTxn = txn.update(newMissing);
+
+                            if (txn.status.isCommitted())
+                            {
+                                // update newCommittedByExecuteAt
+                                int ci;
+                                if (txn.executeAt == txn)
+                                {
+                                    ci = SortedArrays.exponentialSearch(newCommittedByExecuteAt, minByExecuteAtSearchIndex, newCommittedByExecuteAt.length, txn, TxnInfo::compareExecuteAt, FAST);
+                                    minByExecuteAtSearchIndex = 1 + ci;
+                                }
+                                else
+                                {
+                                    ci = Arrays.binarySearch(newCommittedByExecuteAt, minByExecuteAtSearchIndex, newCommittedByExecuteAt.length, txn, TxnInfo::compareExecuteAt);
+                                }
+                                Invariants.checkState(newCommittedByExecuteAt[ci] == txn);
+                                newCommittedByExecuteAt[ci] = newTxn;
+                            }
+
+                            txn = newTxn;
+                        }
+                    }
+                }
+                newById[count] = txn;
+                i++;
+            }
+            else if (c > 0)
+            {
+                TxnId txnId = additions[j++];
+                newById[count] = TxnInfo.create(txnId, TRANSITIVELY_KNOWN, txnId, Ballot.ZERO);
+                ++missingCount;
+            }
+            else
+            {
+                throw illegalState(byId[i] + " should be an insertion, but found match when merging with origin");
+            }
+            count++;
+        }
+
+        if (j < additionCount)
+        {
+            if (count <= targetInsertPos)
+            {
+                while (count < targetInsertPos)
+                {
+                    TxnId txnId = additions[j++];
+                    newById[count++] = TxnInfo.create(txnId, TRANSITIVELY_KNOWN, txnId, Ballot.ZERO);
+                }
+                newById[targetInsertPos] = newInfo;
+                count = targetInsertPos + 1;
+            }
+            while (j < additionCount)
+            {
+                TxnId txnId = additions[j++];
+                newById[count++] = TxnInfo.create(txnId, TRANSITIVELY_KNOWN, txnId, Ballot.ZERO);
+            }
+        }
+        else if (count == targetInsertPos)
+        {
+            newById[targetInsertPos] = newInfo;
+        }
+    }
+
+    /**
+     * Similar to insertOrUpdateWithAdditions, but when we only need to insert some additions (i.e. when calling registerUnmanaged)
+     */
+    static TxnInfo[] insertAdditionsOnly(TxnInfo[] byId, TxnInfo[] committedByExecuteAt, TxnInfo[] newInfos, TxnId[] additions, int additionCount)
+    {
+        // the most recently constructed pure insert missing array, so that it may be reused if possible
+        int minByExecuteAtSearchIndex = 0;
+        int i = 0, j = 0, missingCount = 0, count = 0;
+        TxnInfo[] newCommittedByExecuteAt = null;
+        while (i < byId.length)
+        {
+            int c = j == additionCount ? -1 : byId[i].compareTo(additions[j]);
+            if (c < 0)
+            {
+                TxnInfo txn = byId[i];
+                if (txn.status.hasDeps())
+                {
+                    Timestamp depsKnownBefore = txn.depsKnownBefore();
+
+                    int to = missingTo(txn, depsKnownBefore, additions, missingCount, additionCount);
+                    if (to > 0)
+                    {
+                        TxnId[] prevMissing = txn.missing();
+                        TxnId[] newMissing = mergeAndFilterMissing(txn, prevMissing, additions, to, null);
+                        if (newMissing != prevMissing)
+                        {
+                            TxnInfo newTxn = txn.update(newMissing);
+                            if (txn.status.isCommitted())
+                            {
+                                if (newCommittedByExecuteAt == null)
+                                    newCommittedByExecuteAt = committedByExecuteAt.clone();
+
+                                int ci;
+                                if (txn.executeAt == txn)
+                                {
+                                    ci = SortedArrays.exponentialSearch(newCommittedByExecuteAt, minByExecuteAtSearchIndex, newCommittedByExecuteAt.length, txn, TxnInfo::compareExecuteAt, FAST);
+                                    minByExecuteAtSearchIndex = 1 + ci;
+                                }
+                                else
+                                {
+                                    ci = Arrays.binarySearch(newCommittedByExecuteAt, minByExecuteAtSearchIndex, newCommittedByExecuteAt.length, txn, TxnInfo::compareExecuteAt);
+                                }
+                                Invariants.checkState(newCommittedByExecuteAt[ci] == txn);
+                                newCommittedByExecuteAt[ci] = newTxn;
+                            }
+                            txn = newTxn;
+                        }
+                    }
+                }
+                newInfos[count] = txn;
+                i++;
+            }
+            else if (c > 0)
+            {
+                TxnId txnId = additions[j++];
+                newInfos[count] = TxnInfo.create(txnId, TRANSITIVELY_KNOWN, txnId, Ballot.ZERO);
+                ++missingCount;
+            }
+            else
+            {
+                throw illegalState(byId[i] + " should be an insertion, but found match when merging with origin");
+            }
+            count++;
+        }
+
+        while (j < additionCount)
+        {
+            TxnId txnId = additions[j++];
+            newInfos[count++] = TxnInfo.create(txnId, TRANSITIVELY_KNOWN, txnId, Ballot.ZERO);
+        }
+
+        if (newCommittedByExecuteAt == null)
+            newCommittedByExecuteAt = committedByExecuteAt;
+        return newCommittedByExecuteAt;
+    }
+
+    static int committedByExecuteAtUpdatePos(TxnInfo[] committedByExecuteAt, @Nullable TxnInfo curInfo, TxnInfo newInfo)
+    {
+        if (newInfo.status.isCommitted())
+        {
+            return Arrays.binarySearch(committedByExecuteAt, 0, committedByExecuteAt.length, newInfo, TxnInfo::compareExecuteAt);
+        }
+        else if (curInfo != null && curInfo.status.isCommitted() && newInfo.status == INVALID_OR_TRUNCATED_OR_UNMANAGED_COMMITTED)
+        {
+            return Arrays.binarySearch(committedByExecuteAt, 0, committedByExecuteAt.length, curInfo, TxnInfo::compareExecuteAt);
+        }
+        return committedByExecuteAt.length;
+    }
+
+    private static TxnInfo[] updateCommittedByExecuteAt(CommandsForKey cfk, int pos, TxnInfo newInfo, boolean wasPruned)
+    {
+        TxnInfo[] committedByExecuteAt = cfk.committedByExecuteAt;
+        if (pos == committedByExecuteAt.length)
+            return committedByExecuteAt;
+
+        if (newInfo.status != INVALID_OR_TRUNCATED_OR_UNMANAGED_COMMITTED)
+        {
+            if (pos >= 0 && committedByExecuteAt[pos].equals(newInfo))
+            {
+                TxnInfo[] result = committedByExecuteAt.clone();
+                result[pos] = newInfo;
+                return result;
+            }
+            else
+            {
+                if (pos >= 0) logger.error("Execution timestamp clash on key {}: {} and {} both have executeAt {}", cfk.key(), newInfo.plainTxnId(), committedByExecuteAt[pos].plainTxnId(), newInfo.executeAt);
+                else pos = -1 - pos;
+
+                TxnInfo[] result = new TxnInfo[committedByExecuteAt.length + 1];
+                System.arraycopy(committedByExecuteAt, 0, result, 0, pos);
+                result[pos] = newInfo;
+                System.arraycopy(committedByExecuteAt, pos, result, pos + 1, committedByExecuteAt.length - pos);
+
+                int maxAppliedWriteByExecuteAt = cfk.maxAppliedWriteByExecuteAt;
+                if (pos <= maxAppliedWriteByExecuteAt)
+                {
+                    if (pos < maxAppliedWriteByExecuteAt && !wasPruned && cfk.redundantBefore.bootstrappedAt.compareTo(newInfo) < 0)
+                    {
+                        for (int i = pos; i <= maxAppliedWriteByExecuteAt; ++i)
+                        {
+                            if (committedByExecuteAt[pos].kind().witnesses(newInfo))
+                                logger.error("Linearizability violation on key {}: {} is committed to execute (at {}) before {} that should witness it but has already applied (at {})", cfk.key, newInfo.plainTxnId(), newInfo.plainExecuteAt(), committedByExecuteAt[i].plainTxnId(), committedByExecuteAt[i].plainExecuteAt());
+                        }
+                    }
+                }
+
+                return result;
+            }
+        }
+        else
+        {
+            // we can transition from COMMITTED, STABLE or APPLIED to INVALID_OR_TRUNCATED if the local command store ERASES a command
+            // in this case, we not only need to update committedByExecuteAt, we also need to update any unmanaged transactions that
+            // might have been waiting for this command's execution, to either wait for the preceding committed command or else to stop waiting
+            TxnInfo[] newInfos = new TxnInfo[committedByExecuteAt.length - 1];
+            System.arraycopy(committedByExecuteAt, 0, newInfos, 0, pos);
+            System.arraycopy(committedByExecuteAt, pos + 1, newInfos, pos, newInfos.length - pos);
+            return newInfos;
+        }
+    }
+
+    private static int updateMaxAppliedWriteByExecuteAt(CommandsForKey cfk, int pos, TxnInfo newInfo, TxnInfo[] newCommittedByExecuteAt, boolean wasPruned)
+    {
+        int maxAppliedWriteByExecuteAt = cfk.maxAppliedWriteByExecuteAt;
+        if (pos == cfk.committedByExecuteAt.length)
+            return maxAppliedWriteByExecuteAt;
+
+        boolean inserted = pos < 0;
+        if (inserted) pos = -1 - pos;
+        if (pos <= maxAppliedWriteByExecuteAt)
+        {
+            if (inserted)
+            {
+                return maxAppliedWriteByExecuteAt + 1;
+            }
+            else if (newInfo.status == INVALID_OR_TRUNCATED_OR_UNMANAGED_COMMITTED)
+            {
+                // deleted
+                int newMaxAppliedWriteByExecuteAt = maxAppliedWriteByExecuteAt - 1;
+                while (newMaxAppliedWriteByExecuteAt >= 0 && newCommittedByExecuteAt[newMaxAppliedWriteByExecuteAt].kind() != Write)
+                    --newMaxAppliedWriteByExecuteAt;
+                return newMaxAppliedWriteByExecuteAt;
+            }
+        }
+        else if (newInfo.status == APPLIED)
+        {
+            return maybeUpdateMaxAppliedAndCheckForLinearizabilityViolations(cfk, pos, newInfo.kind(), newInfo, wasPruned);
+        }
+        return maxAppliedWriteByExecuteAt;
+    }
+
+    private static int maybeUpdateMaxAppliedAndCheckForLinearizabilityViolations(CommandsForKey cfk, int appliedPos, Txn.Kind appliedKind, TxnInfo applied, boolean wasPruned)
+    {
+        if (!wasPruned && cfk.redundantBefore.bootstrappedAt.compareTo(applied) < 0)
+        {
+            TxnInfo[] committedByExecuteAt = cfk.committedByExecuteAt;
+            for (int i = cfk.maxAppliedWriteByExecuteAt + 1; i < appliedPos ; ++i)
+            {
+                if (committedByExecuteAt[i].status != APPLIED && appliedKind.witnesses(committedByExecuteAt[i]))
+                    logger.error("Linearizability violation on key {}: {} is committed to execute (at {}) before {} that should witness it but has already applied (at {})", cfk.key, committedByExecuteAt[i].plainTxnId(), committedByExecuteAt[i].plainExecuteAt(), applied.plainTxnId(), applied.plainExecuteAt());
+            }
+        }
+
+        return appliedKind == Txn.Kind.Write ? appliedPos : cfk.maxAppliedWriteByExecuteAt;
+    }
+
+    static int nextUndecided(TxnInfo[] infos, int pos)
+    {
+        while (true)
+        {
+            if (pos == infos.length)
+                return -1;
+
+            if (infos[pos].status.compareTo(COMMITTED) < 0 && managesExecution(infos[pos]))
+                return pos;
+
+            ++pos;
+        }
+    }
+
+    static void updateUnmanagedAsync(CommandStore commandStore, TxnId txnId, Key key, NotifySink notifySink)
+    {
+        PreLoadContext context = PreLoadContext.contextFor(txnId, Keys.of(key), COMMANDS);
+        commandStore.execute(context, safeStore -> {
+            SafeCommandsForKey safeCommandsForKey = safeStore.get(key);
+            CommandsForKey cur = safeCommandsForKey.current();
+            CommandsForKey next = Updating.updateUnmanaged(cur, safeStore, safeStore.unsafeGet(txnId), notifySink);
+            if (cur != next)
+                safeCommandsForKey.set(next);
+        }).begin(commandStore.agent());
+    }
+
+    static CommandsForKey updateUnmanaged(CommandsForKey cfk, SafeCommandStore safeStore, SafeCommand safeCommand, NotifySink notifySink)
+    {
+        CommandsForKeyUpdate upd = Updating.updateUnmanaged(cfk, safeStore, safeCommand, notifySink, false, null);
+        Invariants.checkState(upd instanceof CommandsForKey);
+        return (CommandsForKey) upd;
+    }
+
+    /**
+     * Three modes of operation:
+     *  - {@code register}: inserts any missing dependencies from safeCommand into the collection; may return CommandsForKeyUpdate
+     *  - {@code !register, update == null}: fails if any dependencies are missing; always returns a CommandsForKey
+     *  - {@code !register && update != null}: fails if any dependencies are missing; always returns the original CommandsForKey, and maybe adds a new Unmanaged to {@code update}
+     */
+    static CommandsForKeyUpdate updateUnmanaged(CommandsForKey cfk, SafeCommandStore safeStore, SafeCommand safeCommand, NotifySink notifySink, boolean register, @Nullable List<CommandsForKey.Unmanaged> update)
+    {
+        Invariants.checkArgument(!register || update == null);
+        if (safeCommand.current().hasBeen(Status.Truncated))
+            return cfk;
+
+        Command.Committed command = safeCommand.current().asCommitted();
+        TxnId waitingTxnId = command.txnId();
+        Timestamp waitingExecuteAt = command.executeAt();
+
+        TxnInfo[] byId = cfk.byId;
+        RelationMultiMap.SortedRelationList<TxnId> txnIds = command.partialDeps().keyDeps.txnIds(cfk.key());
+        TxnId[] missing = NO_TXNIDS;
+        int missingCount = 0;
+        int i = txnIds.find(cfk.shardRedundantBefore());
+        if (i < 0) i = -1 - i;
+        if (i < txnIds.size())
+        {
+            Txn.Kind waitingKind = waitingTxnId.kind();
+            boolean readyToApply = true; // our dependencies have applied, so we are ready to apply
+            boolean waitingToApply = true; // our dependencies have committed, so we know when we execute and are waiting
+            Timestamp executesAt = null;
+            int j = SortedArrays.binarySearch(byId, 0, byId.length, txnIds.get(i), Timestamp::compareTo, FAST);
+
+            if (j < 0) j = -1 -j;
+            while (i < txnIds.size())
+            {
+                int c = j == byId.length ? -1 : txnIds.get(i).compareTo(byId[j]);
+                if (c == 0)
+                {
+                    TxnInfo txn = byId[j];
+                    if (txn.status.compareTo(COMMITTED) < 0) waitingToApply = readyToApply = false;
+                    else if (txn.status != INVALID_OR_TRUNCATED_OR_UNMANAGED_COMMITTED
+                             && (txn.executeAt.compareTo(waitingExecuteAt) < 0
+                                 || waitingKind == EphemeralRead
+                                 || (waitingKind == ExclusiveSyncPoint && txn.compareTo(waitingTxnId) < 0)))
+                    {
+                        readyToApply &= txn.status == APPLIED;
+                        executesAt = Timestamp.nonNullOrMax(executesAt, txn.executeAt);
+                    }
+                    ++i;
+                    ++j;
+                }
+                else if (c > 0)
+                {
+                    if (waitingKind.isSyncPoint())
+                    {
+                        // we special-case sync points to handle pruning of transactions they should have witnessed as dependencies.
+                        // We require all earlier TxnId to be decided before we can execute; we must be informed of all the necessary TxnId
+                        // transitively by the "future" dependency provided for this purpose in mapReduceActive, so simply ensuring
+                        // all earlier TxnId are committed is sufficient to compute the correct executeAt.
+                        TxnInfo txn = byId[j];
+                        if (managesExecution(txn))
+                        {
+                            if (txn.status.compareTo(COMMITTED) < 0) readyToApply = waitingToApply = false;
+                            else if (txn.status != INVALID_OR_TRUNCATED_OR_UNMANAGED_COMMITTED
+                                     && (txn.executeAt.compareTo(waitingExecuteAt) < 0
+                                         || waitingKind == EphemeralRead
+                                         || (waitingKind == ExclusiveSyncPoint && txn.compareTo(waitingTxnId) < 0)))
+                            {
+                                readyToApply &= txn.status == APPLIED;
+                                executesAt = Timestamp.nonNullOrMax(executesAt, txn.executeAt);
+                            }
+                        }
+                    }
+                    ++j;
+                }
+                else if (!managesExecution(txnIds.get(i))) ++i;
+                else if (register)
+                {
+                    readyToApply = waitingToApply = false;
+                    if (missingCount == missing.length)
+                        missing = cachedTxnIds().resize(missing, missingCount, Math.max(8, missingCount + missingCount/2));
+                    missing[missingCount++] = txnIds.get(i++);
+                }
+                else
+                {
+                    Invariants.checkState(txnIds.get(i++).compareTo(cfk.prunedBefore) < 0);
+                }
+            }
+
+            if (waitingKind.isSyncPoint() && Pruning.isAnyPredecessorWaitingOnPruned(cfk.loadingPruned, waitingTxnId))
+                readyToApply = waitingToApply = false;
+
+            if (!readyToApply)
+            {
+                TxnInfo[] newById = byId, newCommittedByExecuteAt = cfk.committedByExecuteAt;
+                int newMinUndecidedById = cfk.minUndecidedById;
+                Object[] newLoadingPruned = cfk.loadingPruned;
+                TxnId[] loadPruned = NO_TXNIDS;
+                int clearMissingCount = missingCount;
+                if (missingCount > 0)
+                {
+                    int prunedIndex = Arrays.binarySearch(missing, 0, missingCount, cfk.prunedBefore());
+                    if (prunedIndex < 0) prunedIndex = -1 - prunedIndex;
+                    if (prunedIndex > 0)
+                    {
+                        loadPruned = Arrays.copyOf(missing, prunedIndex);
+                        newLoadingPruned = Pruning.loadPruned(cfk.loadingPruned, loadPruned, waitingTxnId);
+                    }
+
+                    if (prunedIndex != missingCount)
+                    {
+                        missingCount -= prunedIndex;
+                        System.arraycopy(missing, prunedIndex, missing, 0, missingCount);
+                        newById = new TxnInfo[byId.length + missingCount];
+                        newCommittedByExecuteAt = insertAdditionsOnly(byId, cfk.committedByExecuteAt, newById, missing, missingCount);
+                        // we can safely use missing[prunedIndex] here because we only fill missing with transactions for which we manage execution
+                        newMinUndecidedById = Arrays.binarySearch(newById, TxnId.nonNullOrMin(missing[0], cfk.minUndecided()));
+                    }
+                }
+                cachedTxnIds().discard(missing, clearMissingCount);
+
+                CommandsForKey.Unmanaged newPendingRecord;
+                if (waitingToApply)
+                {
+                    if (executesAt instanceof TxnInfo)
+                        executesAt = ((TxnInfo) executesAt).plainExecuteAt();
+
+                    if (waitingTxnId.kind().awaitsOnlyDeps() && executesAt != null)
+                    {
+                        if (executesAt.compareTo(command.waitingOn.executeAtLeast(Timestamp.NONE)) > 0)
+                        {
+                            Command.WaitingOn.Update waitingOn = new Command.WaitingOn.Update(command.waitingOn);
+                            waitingOn.updateExecuteAtLeast(executesAt);
+                            safeCommand.updateWaitingOn(waitingOn);
+                        }
+                    }
+
+                    newPendingRecord = new CommandsForKey.Unmanaged(APPLY, command.txnId(), executesAt);
+                }
+                else newPendingRecord = new CommandsForKey.Unmanaged(COMMIT, command.txnId(), txnIds.get(txnIds.size() - 1));
+
+                if (update != null)
+                {
+                    update.add(newPendingRecord);
+                    return cfk;
+                }
+
+                CommandsForKey.Unmanaged[] newUnmanaged = SortedArrays.insert(cfk.unmanageds, newPendingRecord, CommandsForKey.Unmanaged[]::new);
+
+                CommandsForKey result;
+                if (newById == byId) result = new CommandsForKey(cfk, newLoadingPruned, newUnmanaged);
+                else result = new CommandsForKey(cfk.key(), cfk.redundantBefore(), cfk.prunedBefore, newLoadingPruned, newById, newCommittedByExecuteAt, newMinUndecidedById, cfk.maxAppliedWriteByExecuteAt, newUnmanaged);
+
+                if (loadPruned == NO_TXNIDS)
+                    return result;
+
+                return new CommandsForKeyUpdate.CommandsForKeyUpdateWithNotifier(result, new PostProcess.LoadPruned(null, loadPruned));
+            }
+        }
+
+        notifySink.notWaiting(safeStore, safeCommand, cfk.key());
+        return cfk;
+    }
+
+    static CommandsForKeyUpdate registerHistorical(CommandsForKey cfk, TxnId txnId)
+    {
+        if (txnId.compareTo(cfk.shardRedundantBefore()) < 0)
+            return cfk;
+
+        int i = Arrays.binarySearch(cfk.byId, txnId);
+        if (i >= 0)
+        {
+            if (cfk.byId[i].status.compareTo(HISTORICAL) >= 0)
+                return cfk;
+            return cfk.update(i, txnId, cfk.byId[i], TxnInfo.create(txnId, HISTORICAL, txnId, Ballot.ZERO), false, null);
+        }
+        else if (txnId.compareTo(cfk.prunedBefore) >= 0)
+        {
+            return cfk.insert(-1 - i, txnId, TxnInfo.create(txnId, HISTORICAL, txnId, Ballot.ZERO), false, null);
+        }
+        else
+        {
+            TxnId[] loadingPrunedFor = loadingPrunedFor(cfk.loadingPruned, txnId, null);
+            if (loadingPrunedFor != null && Arrays.binarySearch(loadingPrunedFor, txnId) >= 0)
+                return cfk;
+
+            TxnId[] txnIdArray = new TxnId[] { txnId };
+            Object[] newLoadingPruned = loadPruned(cfk.loadingPruned, txnIdArray, NO_TXNIDS);
+            return PostProcess.LoadPruned.load(txnIdArray, cfk.update(newLoadingPruned));
+        }
+    }
+}
diff --git a/accord-core/src/main/java/accord/local/cfk/Utils.java b/accord-core/src/main/java/accord/local/cfk/Utils.java
new file mode 100644
index 00000000..109e684f
--- /dev/null
+++ b/accord-core/src/main/java/accord/local/cfk/Utils.java
@@ -0,0 +1,393 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package accord.local.cfk;
+
+import java.util.Arrays;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import accord.local.cfk.CommandsForKey.TxnInfo;
+import accord.primitives.Timestamp;
+import accord.primitives.Txn;
+import accord.primitives.TxnId;
+import accord.utils.Invariants;
+import accord.utils.SortedArrays;
+import net.nicoulaj.compilecommand.annotations.Inline;
+
+import static accord.local.cfk.CommandsForKey.InternalStatus.ACCEPTED;
+import static accord.local.cfk.CommandsForKey.InternalStatus.COMMITTED;
+import static accord.local.cfk.CommandsForKey.NO_TXNIDS;
+import static accord.local.cfk.CommandsForKey.Unmanaged.Pending.COMMIT;
+import static accord.utils.ArrayBuffers.cachedTxnIds;
+import static accord.utils.SortedArrays.Search.FAST;
+
+class Utils
+{
+    static void validateMissing(TxnInfo[] byId, TxnId[] additions, int additionCount, TxnInfo curInfo, TxnInfo newInfo, @Nonnull TxnId[] shouldNotHaveMissing)
+    {
+        for (TxnInfo txn : byId)
+        {
+            if (txn == newInfo) continue;
+            if (!txn.status.hasDeps()) continue;
+            int additionIndex = Arrays.binarySearch(additions, 0, additionCount, txn.depsKnownBefore());
+            if (additionIndex < 0) additionIndex = -1 - additionIndex;
+            TxnId[] missing = txn.missing();
+            int j = 0;
+            for (int i = 0 ; i < additionIndex ; ++i)
+            {
+                if (!txn.kind().witnesses(additions[i])) continue;
+                j = SortedArrays.exponentialSearch(missing, j, missing.length, additions[i]);
+                if (shouldNotHaveMissing != NO_TXNIDS && Arrays.binarySearch(shouldNotHaveMissing, txn) >= 0) Invariants.checkState(j < 0);
+                else Invariants.checkState(j >= 0);
+            }
+            if (curInfo == null && newInfo.status.compareTo(COMMITTED) < 0 && txn.kind().witnesses(newInfo) && txn.depsKnownBefore().compareTo(newInfo) > 0 && (shouldNotHaveMissing == NO_TXNIDS || Arrays.binarySearch(shouldNotHaveMissing, txn) < 0))
+                Invariants.checkState(Arrays.binarySearch(missing, newInfo) >= 0);
+        }
+    }
+
+    /**
+     * {@code removeTxnId} no longer needs to be tracked in missing arrays;
+     * remove it from byId and committedByExecuteAt, ensuring both arrays still reference the same TxnInfo where updated
+     */
+    static void removeFromMissingArrays(TxnInfo[] byId, TxnInfo[] committedByExecuteAt, TxnId removeTxnId)
+    {
+        int startIndex = SortedArrays.binarySearch(committedByExecuteAt, 0, committedByExecuteAt.length, removeTxnId, (id, info) -> id.compareTo(info.executeAt), FAST);
+        if (startIndex < 0) startIndex = -1 - startIndex;
+        else ++startIndex;
+
+        int minSearchIndex = Arrays.binarySearch(byId, removeTxnId) + 1;
+        for (int i = startIndex ; i < committedByExecuteAt.length ; ++i)
+        {
+            int newMinSearchIndex;
+            {
+                TxnInfo txn = committedByExecuteAt[i];
+                if (txn.getClass() == TxnInfo.class) continue;
+                if (!txn.kind().witnesses(removeTxnId)) continue;
+
+                TxnId[] missing = txn.missing();
+                TxnId[] newMissing = removeOneMissing(missing, removeTxnId);
+                if (missing == newMissing) continue;
+
+                newMinSearchIndex = updateInfoArraysByExecuteAt(i, txn, txn.update(newMissing), minSearchIndex, byId, committedByExecuteAt);
+            }
+
+            minSearchIndex = removeFromMissingArraysById(byId, minSearchIndex, newMinSearchIndex, removeTxnId);
+        }
+
+        removeFromMissingArraysById(byId, minSearchIndex, byId.length, removeTxnId);
+    }
+
+    /**
+     * {@code removeTxnId} no longer needs to be tracked in missing arrays;
+     * remove it from a range of byId ACCEPTED status entries only, that could not be tracked via committedByExecuteAt
+     */
+    static int removeFromMissingArraysById(TxnInfo[] byId, int from, int to, TxnId removeTxnId)
+    {
+        for (int i = from ; i < to ; ++i)
+        {
+            TxnInfo txn = byId[i];
+            if (txn.getClass() == TxnInfo.class) continue;
+            if (!txn.status.hasExecuteAtOrDeps) continue;
+            if (!txn.kind().witnesses(removeTxnId)) continue;
+            if (txn.status != ACCEPTED) continue;
+
+            TxnId[] missing = txn.missing();
+            TxnId[] newMissing = removeOneMissing(missing, removeTxnId);
+            if (missing == newMissing) continue;
+            byId[i] = txn.update(newMissing);
+        }
+        return to;
+    }
+
+    /**
+     * {@code insertTxnId} needs to be tracked in missing arrays;
+     * add it to byId and committedByExecuteAt, ensuring both arrays still reference the same TxnInfo where updated
+     * Do not insert it into any members of {@code doNotInsert} as these are known to have witnessed {@code insertTxnId}
+     */
+    static void addToMissingArrays(TxnInfo[] byId, TxnInfo[] committedByExecuteAt, TxnInfo newInfo, TxnId insertTxnId, @Nonnull TxnId[] doNotInsert)
+    {
+        TxnId[] oneMissing = null;
+
+        int startIndex = SortedArrays.binarySearch(committedByExecuteAt, 0, committedByExecuteAt.length, insertTxnId, (id, info) -> id.compareTo(info.executeAt), FAST);
+        if (startIndex < 0) startIndex = -1 - startIndex;
+        else ++startIndex;
+
+        int minByIdSearchIndex = Arrays.binarySearch(byId, insertTxnId) + 1;
+        int minDoNotInsertSearchIndex = 0;
+        for (int i = startIndex ; i < committedByExecuteAt.length ; ++i)
+        {
+            int newMinSearchIndex;
+            {
+                TxnInfo txn = committedByExecuteAt[i];
+                if (txn == newInfo) continue;
+                if (!txn.kind().witnesses(insertTxnId)) continue;
+
+                if (doNotInsert != NO_TXNIDS)
+                {
+                    if (txn.executeAt == txn)
+                    {
+                        int j = SortedArrays.exponentialSearch(doNotInsert, 0, doNotInsert.length, txn);
+                        if (j >= 0)
+                        {
+                            minDoNotInsertSearchIndex = j;
+                            continue;
+                        }
+                        minDoNotInsertSearchIndex = -1 -j;
+                    }
+                    else
+                    {
+                        if (Arrays.binarySearch(doNotInsert, txn) >= 0)
+                            continue;
+                    }
+                }
+
+                TxnId[] missing = txn.missing();
+                if (missing == NO_TXNIDS) missing = oneMissing = ensureOneMissing(insertTxnId, oneMissing);
+                else missing = SortedArrays.insert(missing, insertTxnId, TxnId[]::new);
+
+                newMinSearchIndex = updateInfoArraysByExecuteAt(i, txn, txn.update(missing), minByIdSearchIndex, byId, committedByExecuteAt);
+            }
+
+            for (; minByIdSearchIndex < newMinSearchIndex ; ++minByIdSearchIndex)
+            {
+                TxnInfo txn = byId[minByIdSearchIndex];
+                if (txn == newInfo) continue;
+                if (!txn.status.hasExecuteAtOrDeps) continue;
+                if (!txn.kind().witnesses(insertTxnId)) continue;
+                if (txn.status != ACCEPTED) continue;
+                if (minDoNotInsertSearchIndex < doNotInsert.length && doNotInsert[minDoNotInsertSearchIndex].equals(txn))
+                {
+                    ++minDoNotInsertSearchIndex;
+                    continue;
+                }
+
+                TxnId[] missing = txn.missing();
+                if (missing == NO_TXNIDS) missing = oneMissing = ensureOneMissing(insertTxnId, oneMissing);
+                else missing = SortedArrays.insert(missing, insertTxnId, TxnId[]::new);
+                byId[minByIdSearchIndex] = txn.update(missing);
+            }
+        }
+
+        for (; minByIdSearchIndex < byId.length ; ++minByIdSearchIndex)
+        {
+            TxnInfo txn = byId[minByIdSearchIndex];
+            if (txn == newInfo) continue;
+            if (!txn.status.hasExecuteAtOrDeps) continue;
+            if (!txn.kind().witnesses(insertTxnId)) continue;
+            if (txn.status != ACCEPTED) continue;
+            if (minDoNotInsertSearchIndex < doNotInsert.length && doNotInsert[minDoNotInsertSearchIndex].equals(txn))
+            {
+                ++minDoNotInsertSearchIndex;
+                continue;
+            }
+
+            TxnId[] missing = txn.missing();
+            if (missing == NO_TXNIDS) missing = oneMissing = ensureOneMissing(insertTxnId, oneMissing);
+            else missing = SortedArrays.insert(missing, insertTxnId, TxnId[]::new);
+            byId[minByIdSearchIndex] = txn.update(missing);
+        }
+    }
+
+    /**
+     * Take an index in {@code committedByExecuteAt}, find the companion entry in {@code byId}, and update both of them.
+     * Return the updated minSearchIndex used for querying {@code byId} - this will be updated only if txnId==executeAt
+     */
+    @Inline
+    static int updateInfoArraysByExecuteAt(int i, TxnInfo prevTxn, TxnInfo newTxn, int minSearchIndex, TxnInfo[] byId, TxnInfo[] committedByExecuteAt)
+    {
+        int j;
+        if (prevTxn.executeAt == prevTxn)
+        {
+            j = SortedArrays.exponentialSearch(byId, minSearchIndex, byId.length, prevTxn, TxnInfo::compareTo, FAST);
+            minSearchIndex = 1 + j;
+        }
+        else
+        {
+            j = Arrays.binarySearch(byId, prevTxn);
+        }
+        Invariants.checkState(byId[j] == prevTxn);
+        byId[j] = committedByExecuteAt[i] = newTxn;
+        return minSearchIndex;
+    }
+
+    static TxnId[] removePrunedAdditions(TxnId[] additions, int additionCount, TxnId prunedBefore)
+    {
+        if (additions[0].compareTo(prunedBefore) >= 0)
+            return NO_TXNIDS;
+
+        int prunedIndex = Arrays.binarySearch(additions, 1, additionCount, prunedBefore);
+        if (prunedIndex < 0) prunedIndex = -1 - prunedIndex;
+        if (prunedIndex == 0)
+            return NO_TXNIDS;
+
+        TxnId[] prunedIds = new TxnId[prunedIndex];
+        System.arraycopy(additions, 0, prunedIds, 0, prunedIndex);
+        System.arraycopy(additions, prunedIndex, additions, 0, additionCount - prunedIndex);
+        return prunedIds;
+    }
+
+    /**
+     * If a {@code missing} contains {@code removeTxnId}, return a new array without it (or NO_TXNIDS if the only entry)
+     */
+    static TxnId[] removeOneMissing(TxnId[] missing, TxnId removeTxnId)
+    {
+        if (missing == NO_TXNIDS) return NO_TXNIDS;
+
+        int j = Arrays.binarySearch(missing, removeTxnId);
+        if (j < 0) return missing;
+
+        if (missing.length == 1)
+            return NO_TXNIDS;
+
+        int length = missing.length;
+        TxnId[] newMissing = new TxnId[length - 1];
+        System.arraycopy(missing, 0, newMissing, 0, j);
+        System.arraycopy(missing, j + 1, newMissing, j, length - (1 + j));
+        return newMissing;
+    }
+
+    static TxnId[] removeRedundantMissing(TxnId[] missing, TxnId removeBefore)
+    {
+        if (missing == NO_TXNIDS)
+            return NO_TXNIDS;
+
+        int j = Arrays.binarySearch(missing, removeBefore);
+        if (j < 0) j = -1 - j;
+        if (j <= 0) return missing;
+        if (j == missing.length) return NO_TXNIDS;
+        return Arrays.copyOfRange(missing, j, missing.length);
+    }
+
+    static TxnId[] ensureOneMissing(TxnId txnId, TxnId[] oneMissing)
+    {
+        return oneMissing != null ? oneMissing : new TxnId[] { txnId };
+    }
+
+    static TxnId[] insertMissing(TxnId[] additions, int additionCount, TxnId updateTxnId, int additionInsertPos)
+    {
+        TxnId[] result = new TxnId[additionCount + 1];
+        System.arraycopy(additions, 0, result, 0, additionInsertPos);
+        System.arraycopy(additions, additionInsertPos, result, additionInsertPos + 1, additionCount - additionInsertPos);
+        result[additionInsertPos] = updateTxnId;
+        return result;
+    }
+
+    static int missingTo(TxnId txnId, Timestamp depsKnownBefore, TxnId[] missingSource, int missingCount, int missingLimit)
+    {
+        if (depsKnownBefore == txnId) return missingCount;
+        int to = Arrays.binarySearch(missingSource, 0, missingLimit, depsKnownBefore);
+        if (to < 0) to = -1 - to;
+        return to;
+    }
+
+    /**
+     * Insert the contents of {@code additions} up to {@code additionCount} into {@code current}, ignoring {@code skipAddition} if not null.
+     * Only insert entries that would be witnessed by {@code owner}.
+     */
+    static TxnId[] mergeAndFilterMissing(TxnId owner, TxnId[] current, TxnId[] additions, int additionCount, @Nullable TxnId skipAddition)
+    {
+        Txn.Kind.Kinds kinds = owner.kind().witnesses();
+        int additionLength = additionCount;
+        for (int i = additionCount - 1 ; i >= 0 ; --i)
+        {
+            if (!kinds.test(additions[i].kind()))
+                --additionCount;
+        }
+
+        if (additionCount == (skipAddition == null ? 0 : 1))
+            return current;
+
+        TxnId[] buffer = cachedTxnIds().get(current.length + (skipAddition == null ? additionCount : additionCount - 1));
+        int i = 0, j = 0, count = 0;
+        while (i < additionLength && j < current.length)
+        {
+            if (kinds.test(additions[i].kind()))
+            {
+                int c = additions[i].compareTo(current[j]);
+                if (c < 0)
+                {
+                    TxnId addition = additions[i++];
+                    if (addition != skipAddition)
+                        buffer[count++] = addition;
+                }
+                else
+                {
+                    buffer[count++] = current[j++];
+                }
+            }
+            else i++;
+        }
+        while (i < additionLength)
+        {
+            if (kinds.test(additions[i].kind()))
+            {
+                TxnId addition = additions[i];
+                if (addition != skipAddition)
+                    buffer[count++] = addition;
+            }
+            i++;
+        }
+        while (j < current.length)
+        {
+            buffer[count++] = current[j++];
+        }
+        Invariants.checkState(count == additionCount + current.length);
+        return cachedTxnIds().completeAndDiscard(buffer, count);
+    }
+
+    static TxnId[] selectUnmanaged(CommandsForKey.Unmanaged[] unmanageds, int start, int end)
+    {
+        TxnId[] notifyNotWaiting = new TxnId[end - start];
+        for (int i = start ; i < end ; ++i)
+        {
+            CommandsForKey.Unmanaged unmanaged = unmanageds[i];
+            TxnId txnId = unmanaged.txnId;
+            notifyNotWaiting[i - start] = txnId;
+        }
+        return notifyNotWaiting;
+    }
+
+    static CommandsForKey.Unmanaged[] removeUnmanaged(CommandsForKey.Unmanaged[] unmanageds, int start, int end)
+    {
+        CommandsForKey.Unmanaged[] newUnmanageds = new CommandsForKey.Unmanaged[unmanageds.length - (end - start)];
+        System.arraycopy(unmanageds, 0, newUnmanageds, 0, start);
+        System.arraycopy(unmanageds, end, newUnmanageds, start, unmanageds.length - end);
+        return newUnmanageds;
+    }
+
+    static int findCommit(CommandsForKey.Unmanaged[] unmanageds, Timestamp exclusive)
+    {
+        return -1 - SortedArrays.exponentialSearch(unmanageds, 0, unmanageds.length, exclusive, (f, v) -> {
+            if (v.pending != COMMIT) return -1;
+            return f.compareTo(v.waitingUntil) > 0 ? 1 : -1;
+        }, FAST);
+    }
+
+    static int findFirstApply(CommandsForKey.Unmanaged[] unmanageds)
+    {
+        return -1 - SortedArrays.binarySearch(unmanageds, 0, unmanageds.length, null, (f, v) -> v.pending == COMMIT ? 1 : -1, FAST);
+    }
+
+    static int findApply(CommandsForKey.Unmanaged[] unmanageds, int start, Timestamp inclusive)
+    {
+        return -1 - SortedArrays.binarySearch(unmanageds, start, unmanageds.length, inclusive, (f, v) -> {
+            return f.compareTo(v.waitingUntil) >= 0 ? 1 : -1;
+        }, FAST);
+    }
+}
diff --git a/accord-core/src/main/java/accord/messages/BeginRecovery.java b/accord-core/src/main/java/accord/messages/BeginRecovery.java
index c20c6f3d..f04e0ac9 100644
--- a/accord-core/src/main/java/accord/messages/BeginRecovery.java
+++ b/accord-core/src/main/java/accord/messages/BeginRecovery.java
@@ -130,12 +130,7 @@ public class BeginRecovery extends TxnRequest<BeginRecovery.RecoverReply>
         }
         else
         {
-            // TODO (expected): if we can combine these with the earlierAcceptedNoWitness we can avoid persisting deps on Accept
-            //    the problem is we need some way to ensure liveness. If we were to store witnessedAt as a separate register
-            //    we could return these and filter them by whatever the max witnessedAt is that we discover, OR we could
-            //    filter on replicas to exclude any that are started after anything that is committed, since they will have to adopt
-            //    them as a dependency (but we have to make sure we consider dependency rules, so if there's no write and only reads)
-            //    we might still have new transactions block our execution.
+            // TODO (expected): modify the mapReduce API to perform this check in a single pass
             Ranges ranges = safeStore.ranges().allAt(txnId);
             rejectsFastPath = hasAcceptedOrCommittedStartedAfterWithoutWitnessing(safeStore, txnId, ranges, partialTxn.keys());
             if (!rejectsFastPath)
@@ -363,7 +358,7 @@ public class BeginRecovery extends TxnRequest<BeginRecovery.RecoverReply>
          * TODO (required): consider carefully how _adding_ ranges to a CommandStore affects this
          */
         return safeStore.mapReduceFull(keys, ranges, startedAfter, startedAfter.kind().witnessedBy(), STARTED_AFTER, WITHOUT, IS_PROPOSED,
-                                             (p1, keyOrRange, txnId, executeAt, prev) -> true, null, false);
+                                       (p1, keyOrRange, txnId, executeAt, prev) -> true, null, false);
     }
 
     private static boolean hasStableExecutesAfterWithoutWitnessing(SafeCommandStore safeStore, TxnId executesAfter, Ranges ranges, Seekables<?, ?> keys)
diff --git a/accord-core/src/main/java/accord/messages/CheckStatus.java b/accord-core/src/main/java/accord/messages/CheckStatus.java
index c2446b70..a6cf86cf 100644
--- a/accord-core/src/main/java/accord/messages/CheckStatus.java
+++ b/accord-core/src/main/java/accord/messages/CheckStatus.java
@@ -157,7 +157,7 @@ public class CheckStatus extends AbstractEpochRequest<CheckStatus.CheckStatusRep
         if (!command.has(Known.DefinitionOnly) && Route.isRoute(query) && safeStore.ranges().allAt(txnId.epoch()).contains(Route.castToRoute(query).homeKey()))
             Commands.informHome(safeStore, safeCommand, Route.castToRoute(query));
 
-        InvalidIfNot invalidIfNotAtLeast = invalidIfNot(safeStore, command);
+        InvalidIfNot invalidIfNotAtLeast = invalidIfNot(safeStore, command, command.route());
         boolean isCoordinating = isCoordinating(node, command);
         Durability durability = command.durability();
         Route<?> route = command.route();
@@ -202,11 +202,14 @@ public class CheckStatus extends AbstractEpochRequest<CheckStatus.CheckStatusRep
         else node.reply(replyTo, replyContext, ok, null);
     }
 
-    private InvalidIfNot invalidIfNot(SafeCommandStore safeStore, Command command)
+    private InvalidIfNot invalidIfNot(SafeCommandStore safeStore, Command command, @Nullable Route<?> route)
     {
         if (command.known().isDecidedToExecute())
             return NotKnownToBeInvalid;
-        return Infer.invalidIfNot(safeStore, txnId, query);
+        InvalidIfNot invalidIfNot = Infer.invalidIfNot(safeStore, txnId, query);
+        if (route != null)
+            invalidIfNot = invalidIfNot.atLeast(Infer.invalidIfNot(safeStore, txnId, route));
+        return invalidIfNot;
     }
 
     public interface CheckStatusReply extends Reply
diff --git a/accord-core/src/main/java/accord/messages/Commit.java b/accord-core/src/main/java/accord/messages/Commit.java
index 25f083c4..dd7a392b 100644
--- a/accord-core/src/main/java/accord/messages/Commit.java
+++ b/accord-core/src/main/java/accord/messages/Commit.java
@@ -161,6 +161,7 @@ public class Commit extends TxnRequest<CommitOrReadNack>
 
     public static void commitMinimal(Node node, Topologies coordinateEpochOnly, Ballot ballot, TxnId txnId, Txn txn, FullRoute<?> route, Timestamp executeAt, Deps unstableDeps, Callback<ReadReply> callback)
     {
+        Invariants.checkArgument(coordinateEpochOnly.size() == 1, "Invalid coordinate epochs: %s", coordinateEpochOnly);
         // we want to send to everyone, and we want to include all of the relevant data, but we stabilise on the coordination epoch replica responses
         Topology coordinates = coordinateEpochOnly.forEpoch(txnId.epoch());
         Topologies all = coordinateEpochOnly;
diff --git a/accord-core/src/main/java/accord/messages/Propagate.java b/accord-core/src/main/java/accord/messages/Propagate.java
index a6d209e0..cb23080e 100644
--- a/accord-core/src/main/java/accord/messages/Propagate.java
+++ b/accord-core/src/main/java/accord/messages/Propagate.java
@@ -138,7 +138,7 @@ public class Propagate implements EpochSupplier, LocalRequest<Status.Known>, Pre
         on.mapReduceConsumeLocal(this, route, txnId.epoch(), toEpoch, this);
     }
 
-    @SuppressWarnings({"rawtypes", "unchecked"})
+    @SuppressWarnings({"rawtypes"})
     public static void propagate(Node node, TxnId txnId, long sourceEpoch, WithQuorum withQuorum, Route route, @Nullable Status.Known target, CheckStatus.CheckStatusOkFull full, BiConsumer<Status.Known, Throwable> callback)
     {
         propagate(node, txnId, sourceEpoch, sourceEpoch, withQuorum, route, target, full, callback);
diff --git a/accord-core/src/main/java/accord/messages/ReadData.java b/accord-core/src/main/java/accord/messages/ReadData.java
index 2746b6d0..f5921613 100644
--- a/accord-core/src/main/java/accord/messages/ReadData.java
+++ b/accord-core/src/main/java/accord/messages/ReadData.java
@@ -148,7 +148,7 @@ public abstract class ReadData extends AbstractEpochRequest<ReadData.CommitOrRea
     @Override
     public PreLoadContext listenerPreLoadContext(TxnId caller)
     {
-        return PreLoadContext.contextFor(txnId, caller, keys());
+        return PreLoadContext.contextFor(txnId, keys());
     }
 
     @Override
@@ -192,6 +192,7 @@ public abstract class ReadData extends AbstractEpochRequest<ReadData.CommitOrRea
                 ++waitingOnCount;
                 safeCommand.addListener(this);
                 safeStore.progressLog().waiting(safeCommand, executeOn().min.execution, null, readScope);
+                beginWaiting(safeStore, false);
                 return status.compareTo(SaveStatus.Stable) >= 0 ? null : Insufficient;
 
             case OBSOLETE:
@@ -202,6 +203,7 @@ public abstract class ReadData extends AbstractEpochRequest<ReadData.CommitOrRea
                 waitingOn.set(safeStore.commandStore().id());
                 reading.set(safeStore.commandStore().id());
                 ++waitingOnCount;
+                beginWaiting(safeStore, true);
                 safeCommand.addListener(this);
                 read(safeStore, safeCommand.current());
                 return null;
@@ -351,31 +353,37 @@ public abstract class ReadData extends AbstractEpochRequest<ReadData.CommitOrRea
         }
     }
 
-    void cancel(@Nullable SafeCommandStore safeStore)
+    void beginCancel(@Nullable SafeCommandStore safeStore)
     {
         if (safeStore != null)
         {
-            SafeCommand safeCommand = safeStore.ifInitialised(txnId);
-            if (safeCommand != null) safeCommand.removeListener(this);
-            waitingOn.clear(safeStore.commandStore().id());
+            int id = safeStore.commandStore().id();
+            cancelWaiting(safeStore);
+            waitingOn.clear(id);
         }
 
         // TODO (expected): efficient unsubscribe mechanism
-        if (waitingOn != null)
-            node.commandStores().mapReduceConsume(this, waitingOn.stream(), forEach(in -> {
-                SafeCommand safeCommand = in.ifInitialised(txnId);
-                if (safeCommand != null) safeCommand.removeListener(this);
-            }, node.agent()));
+        node.commandStores().mapReduceConsume(this, waitingOn.stream(), forEach(safeStore0 -> cancelWaiting(safeStore0), node.agent()));
         state = State.OBSOLETE;
-        waitingOn = null;
+        waitingOn.clear();
         reading = null;
         data = null;
         unavailable = null;
     }
 
+    protected void beginWaiting(SafeCommandStore safeStore, boolean isExecuting)
+    {
+    }
+
+    protected void cancelWaiting(SafeCommandStore safeStore)
+    {
+        SafeCommand safeCommand = safeStore.ifInitialised(txnId);
+        if (safeCommand != null) safeCommand.removeListener(this);
+    }
+
     void onFailure(@Nullable SafeCommandStore safeStore, CommitOrReadNack failReply, Throwable throwable)
     {
-        cancel(safeStore);
+        beginCancel(safeStore);
         if (throwable != null)
         {
             node.reply(replyTo, replyContext, null, throwable);
diff --git a/accord-core/src/main/java/accord/messages/ReadEphemeralTxnData.java b/accord-core/src/main/java/accord/messages/ReadEphemeralTxnData.java
index 75c21262..e170c313 100644
--- a/accord-core/src/main/java/accord/messages/ReadEphemeralTxnData.java
+++ b/accord-core/src/main/java/accord/messages/ReadEphemeralTxnData.java
@@ -108,7 +108,7 @@ public class ReadEphemeralTxnData extends ReadData
         if (retryInLaterEpoch > 0)
         {
             // TODO (expected): wait for all stores' results and report only the ranges that execute later to be retried
-            cancel(safeStore);
+            beginCancel(safeStore);
             node.reply(replyTo, replyContext, new ReadOkWithFutureEpoch(null, null, retryInLaterEpoch), null);
         }
         super.read(safeStore, command);
diff --git a/accord-core/src/main/java/accord/messages/WaitOnCommit.java b/accord-core/src/main/java/accord/messages/WaitOnCommit.java
index 69353977..c79f0e24 100644
--- a/accord-core/src/main/java/accord/messages/WaitOnCommit.java
+++ b/accord-core/src/main/java/accord/messages/WaitOnCommit.java
@@ -187,7 +187,7 @@ public class WaitOnCommit implements Request, MapReduceConsume<SafeCommandStore,
     @Override
     public PreLoadContext listenerPreLoadContext(TxnId caller)
     {
-        return PreLoadContext.contextFor(txnId, caller, keys());
+        return PreLoadContext.contextFor(txnId, keys());
     }
 
     @Override
diff --git a/accord-core/src/main/java/accord/primitives/AbstractKeys.java b/accord-core/src/main/java/accord/primitives/AbstractKeys.java
index 3d9548e7..59ead203 100644
--- a/accord-core/src/main/java/accord/primitives/AbstractKeys.java
+++ b/accord-core/src/main/java/accord/primitives/AbstractKeys.java
@@ -29,6 +29,7 @@ import java.util.stream.Stream;
 
 import accord.api.RoutingKey;
 import accord.utils.ArrayBuffers.ObjectBuffers;
+import accord.utils.IndexedBiConsumer;
 import accord.utils.IndexedFold;
 import accord.utils.IndexedFoldToLong;
 import accord.utils.IndexedTriFold;
@@ -258,6 +259,15 @@ public abstract class AbstractKeys<K extends RoutableKey> implements Iterable<K>
         Routables.foldl(this, rs, (k, consumer, i) -> { consumer.accept(k); return consumer; }, forEach);
     }
 
+    @Inline
+    public final <P1> void forEach(Ranges rs, IndexedBiConsumer<P1, ? super K> forEach, P1 p1)
+    {
+        Routables.foldl(this, rs, (p, ignore, k, consumer, i) -> {
+            consumer.accept(p, k, i);
+            return consumer;
+        }, p1, null, forEach, i -> false);
+    }
+
     @Inline
     public final long foldl(Ranges rs, IndexedFoldToLong<? super K> fold, long param, long initialValue, long terminalValue)
     {
@@ -276,6 +286,12 @@ public abstract class AbstractKeys<K extends RoutableKey> implements Iterable<K>
         return initialValue;
     }
 
+    public void forEach(Consumer<? super K> forEach)
+    {
+        for (K key : keys)
+            forEach.accept(key);
+    }
+
     public final FullKeyRoute toRoute(RoutingKey homeKey)
     {
         if (isEmpty())
diff --git a/accord-core/src/main/java/accord/primitives/Ballot.java b/accord-core/src/main/java/accord/primitives/Ballot.java
index fa940314..5dbb5bda 100644
--- a/accord-core/src/main/java/accord/primitives/Ballot.java
+++ b/accord-core/src/main/java/accord/primitives/Ballot.java
@@ -24,6 +24,9 @@ public class Ballot extends Timestamp
 {
     public static Ballot fromBits(long msb, long lsb, Id node)
     {
+        if (msb == 0 && lsb == 0 && node.equals(Id.NONE))
+            return Ballot.ZERO;
+
         return new Ballot(msb, lsb, node);
     }
 
@@ -34,6 +37,9 @@ public class Ballot extends Timestamp
 
     public static Ballot fromValues(long epoch, long hlc, int flags, Id node)
     {
+        if (epoch == 0 && hlc == 0 && flags == 0 && node.equals(Id.NONE))
+            return ZERO;
+
         return new Ballot(epoch, hlc, flags, node);
     }
 
diff --git a/accord-core/src/main/java/accord/primitives/Deps.java b/accord-core/src/main/java/accord/primitives/Deps.java
index d8e134d0..2286dca4 100644
--- a/accord-core/src/main/java/accord/primitives/Deps.java
+++ b/accord-core/src/main/java/accord/primitives/Deps.java
@@ -19,11 +19,13 @@
 package accord.primitives;
 
 import accord.api.Key;
+import accord.local.cfk.CommandsForKey;
 import accord.utils.Invariants;
+import accord.utils.MergeFewDisjointSortedListsCursor;
+import accord.utils.SortedCursor;
+import accord.utils.SortedList;
 
 import java.util.AbstractList;
-import java.util.ArrayList;
-import java.util.Arrays;
 import java.util.List;
 import java.util.function.Consumer;
 import java.util.function.Function;
@@ -31,11 +33,32 @@ import java.util.function.Predicate;
 import javax.annotation.Nullable;
 
 /**
- * A collection of transaction dependencies, keyed by the key or range on which they were adopted
+ * A collection of transaction dependencies, keyed by the key or range on which they were adopted.
+ *
+ * Dependencies are used in the following phases:
+ *
+ *  - PreAccept calculates dependencies that contain TxnId < the proposed TxnId
+ *      - if the fast path is taken, these dependencies are used immediately for execution
+ *      - if the slow path is taken, these dependencies are proposed in the Accept phase
+ *  - Accept phase calculates dependencies that contain TxnId < the proposed executeAt; these are merged with those found during the PreAccept phase
+ *  - Commit phase simply makes these dependencies durable
+ *
+ *  Dependencies are mostly calculated by CommandsForKey, which has some efficiency tricks, including pruning both itself
+ *  and transitive dependencies in any calculation. This means that
+ *   - Dependencies will omit any transaction that is known to be a durable dependency of another transaction that has been included.
+ *     This mechanism is performed in isolation by each replica, so a final set of Deps may contain redundant transactions.
+ *   - CommandsForKey may prune already-applied transactions. This means we can in some circumstances try to calculate
+ *     dependencies for a timestamp that occurs before the prune point. If this happens, we insert a future dependency,
+ *     so it is possible for Deps to contain TxnId > their intended bound.
+ *      - This can occur for regular transactions with old TxnId during PreAccept; in this case we always propose an executeAt that is greater than this inserted Dep
+ *      - This can occur for SyncPoint and ExclusiveSyncPoint because they are invisible to other transactions and only
+ *        create happens-before edges. So they may have their dependencies calculated at any point after pruning may happen.
+ *        In this case, CommandsForKey uses this future dependency to ensure its earlier dependencies are up-to-date before
+ *        deciding the execution sequence for the SyncPoint/ExclusiveSyncPoint.
  */
 public class Deps
 {
-    public static final Deps NONE = new Deps(KeyDeps.NONE, RangeDeps.NONE);
+    public static final Deps NONE = new Deps(KeyDeps.NONE, RangeDeps.NONE, KeyDeps.NONE);
 
     public static Builder builder()
     {
@@ -47,6 +70,7 @@ public class Deps
     {
         final KeyDeps.Builder keyBuilder;
         RangeDeps.Builder rangeBuilder;
+        KeyDeps.Builder directKeyBuilder;
 
         AbstractBuilder()
         {
@@ -55,12 +79,23 @@ public class Deps
 
         public AbstractBuilder<T> add(Seekable keyOrRange, TxnId txnId)
         {
-            switch (keyOrRange.domain())
+            Invariants.checkArgument(keyOrRange.domain() == txnId.domain(), keyOrRange + " is not same domain as " + txnId);
+            switch (txnId.domain())
             {
                 default: throw new AssertionError();
                 case Key:
-                    keyBuilder.add(keyOrRange.asKey(), txnId);
+                    if (CommandsForKey.managesExecution(txnId))
+                    {
+                        keyBuilder.add(keyOrRange.asKey(), txnId);
+                    }
+                    else
+                    {
+                        if (directKeyBuilder == null)
+                            directKeyBuilder = KeyDeps.builder();
+                        directKeyBuilder.add(keyOrRange.asKey(), txnId);
+                    }
                     break;
+
                 case Range:
                     if (rangeBuilder == null)
                         rangeBuilder = RangeDeps.builder();
@@ -78,6 +113,8 @@ public class Deps
             keyBuilder.close();
             if (rangeBuilder != null)
                 rangeBuilder.close();
+            if (directKeyBuilder != null)
+                directKeyBuilder.close();
         }
     }
 
@@ -91,37 +128,88 @@ public class Deps
         @Override
         public Deps build()
         {
-            return new Deps(keyBuilder.build(), rangeBuilder == null ? RangeDeps.NONE : rangeBuilder.build());
+            return new Deps(keyBuilder.build(),
+                            rangeBuilder == null ? RangeDeps.NONE : rangeBuilder.build(),
+                            directKeyBuilder == null ? KeyDeps.NONE : directKeyBuilder.build());
         }
     }
 
+    /**
+     * key dependencies where the execution will be managed by {@link CommandsForKey}, so {@link accord.local.Command.WaitingOn} will wait only on the Key.
+     * This is essentially plain reads and writes.
+     *
+     * i.e. where {@code CommandsForKey.managesExecution}
+     */
     public final KeyDeps keyDeps;
+
+    /**
+     * Range transaction dependencies. The execution of these is equivalent to {@code directKeyDeps}, in that they are
+     * managed via direct relationships in {@link accord.local.Command.WaitingOn}.
+     */
     public final RangeDeps rangeDeps;
 
-    public Deps(KeyDeps keyDeps, RangeDeps rangeDeps)
+    /**
+     * key dependencies where the execution will be managed by direct dependency relationships, so {@link accord.local.Command.WaitingOn} will wait on the {@code TxnId} directly
+     * i.e. where {@code !CommandsForKey.managesExecution}
+     */
+    public final KeyDeps directKeyDeps;
+
+    public Deps(KeyDeps keyDeps, RangeDeps rangeDeps, KeyDeps directKeyDeps)
     {
         this.keyDeps = keyDeps;
+        this.directKeyDeps = directKeyDeps;
         this.rangeDeps = rangeDeps;
     }
 
     public boolean contains(TxnId txnId)
     {
-        return keyDeps.contains(txnId) || rangeDeps.contains(txnId);
+        Routable.Domain domain = txnId.domain();
+        if (domain.isRange())
+            return rangeDeps.contains(txnId);
+
+        if (CommandsForKey.managesExecution(txnId))
+            return keyDeps.contains(txnId);
+
+        return directKeyDeps.contains(txnId);
     }
 
     public boolean intersects(TxnId txnId, Ranges ranges)
     {
-        return keyDeps.intersects(txnId, ranges) || rangeDeps.intersects(txnId, ranges);
+        Routable.Domain domain = txnId.domain();
+        if (domain.isRange())
+            return rangeDeps.intersects(txnId, ranges);
+
+        if (CommandsForKey.managesExecution(txnId))
+            return keyDeps.intersects(txnId, ranges);
+
+        return directKeyDeps.intersects(txnId, ranges);
+    }
+
+    public SortedCursor<TxnId> txnIds(Key key)
+    {
+        SortedList<TxnId> keyDeps = this.keyDeps.txnIds(key);
+        SortedList<TxnId> rangeDeps = this.rangeDeps.computeTxnIds(key);
+        SortedList<TxnId> directKeyDeps = this.directKeyDeps.txnIds(key);
+        int count = Math.min(1, keyDeps.size()) + Math.min(1, directKeyDeps.size()) + Math.min(1, rangeDeps.size());
+        MergeFewDisjointSortedListsCursor<TxnId> cursor = new MergeFewDisjointSortedListsCursor<>(count);
+        if (keyDeps.size() > 0)
+            cursor.add(keyDeps);
+        if (rangeDeps.size() > 0)
+            cursor.add(rangeDeps);
+        if (directKeyDeps.size() > 0)
+            cursor.add(directKeyDeps);
+        cursor.init();
+        return cursor;
     }
 
     public Deps with(Deps that)
     {
-        return new Deps(this.keyDeps.with(that.keyDeps), this.rangeDeps.with(that.rangeDeps));
+        return new Deps(this.keyDeps.with(that.keyDeps), this.rangeDeps.with(that.rangeDeps), this.directKeyDeps.with(that.directKeyDeps));
     }
 
     public Deps without(Predicate<TxnId> remove)
     {
-        return new Deps(keyDeps.without(remove), rangeDeps.without(remove));
+        return new Deps(keyDeps.without(remove), rangeDeps.without(remove), directKeyDeps.without(remove));
     }
 
     public PartialDeps slice(Ranges covering)
@@ -131,117 +219,58 @@ public class Deps
 
     public PartialDeps slice(Ranges covering, Ranges slice)
     {
-        return new PartialDeps(covering, keyDeps.slice(slice), rangeDeps.slice(slice));
+        return new PartialDeps(covering, keyDeps.slice(slice), rangeDeps.slice(slice), directKeyDeps.slice(slice));
     }
 
     public boolean isEmpty()
     {
-        return keyDeps.isEmpty() && rangeDeps.isEmpty();
+        return keyDeps.isEmpty() && rangeDeps.isEmpty() && directKeyDeps.isEmpty();
     }
 
     public int txnIdCount()
     {
-        return keyDeps.txnIdCount() + rangeDeps.txnIdCount();
+        return keyDeps.txnIdCount() + rangeDeps.txnIdCount() + directKeyDeps.txnIdCount();
     }
 
-    public int indexOf(TxnId txnId)
+    public TxnId txnId(int i)
     {
-        switch (txnId.domain())
         {
-            default: throw new AssertionError("Unknown domain: " + txnId.domain());
-            case Key:
-                return Arrays.binarySearch(keyDeps.txnIds, txnId);
-            case Range:
-                int i = Arrays.binarySearch(rangeDeps.txnIds, txnId);
-                if (i < 0) i -= keyDeps.txnIdCount();
-                else i += keyDeps.txnIdCount();
-                return i;
+            int keyDepsLimit = keyDeps.txnIdCount();
+            if (i < keyDepsLimit)
+                return keyDeps.txnId(i);
+            i -= keyDepsLimit;
         }
-    }
 
-    public TxnId txnId(int i)
-    {
-        return i < keyDeps.txnIdCount()
-                ? keyDeps.txnId(i)
-                : rangeDeps.txnId(i - keyDeps.txnIdCount());
-    }
+        {
+            int directKeyDepsLimit = directKeyDeps.txnIdCount();
+            if (i < directKeyDepsLimit)
+                return directKeyDeps.txnId(i);
+            i -= directKeyDepsLimit;
+        }
 
-    public TxnId minTxnId()
-    {
-        if (keyDeps.isEmpty() && rangeDeps.isEmpty())
-            throw new IndexOutOfBoundsException();
-        if (keyDeps.isEmpty()) return rangeDeps.txnId(0);
-        if (rangeDeps.isEmpty()) return keyDeps.txnId(0);
-        return TxnId.min(keyDeps.txnId(0), rangeDeps.txnId(0));
+        return rangeDeps.txnId(i);
     }
 
     public List<TxnId> txnIds()
     {
-        final int txnIdCount = txnIdCount();
-        final int keyDepsCount = keyDeps.txnIdCount();
-        return new AbstractList<TxnId>() {
+        return new AbstractList<>()
+        {
             @Override
-            public TxnId get(int index)
-            {
-                return index < keyDepsCount
-                        ? keyDeps.txnId(index)
-                        : rangeDeps.txnId(index - keyDepsCount);
-            }
-
+            public TxnId get(int index) { return txnId(index); }
             @Override
             public int size()
             {
-                return txnIdCount;
+                return txnIdCount();
             }
         };
     }
 
-    public List<TxnId> computeTxnIds(Seekable keyOrRange)
-    {
-        List<TxnId> keyIds, rangeIds;
-        switch (keyOrRange.domain())
-        {
-            default: throw new AssertionError();
-            case Key:
-            {
-                Key key = keyOrRange.asKey();
-                keyIds = keyDeps.txnIds(key);
-                rangeIds = rangeDeps.computeTxnIds(key);
-                break;
-            }
-            case Range:
-            {
-                Range range = keyOrRange.asRange();
-                keyIds = keyDeps.txnIds(range);
-                rangeIds = rangeDeps.computeTxnIds(range);
-            }
-        }
-
-        if (rangeIds.isEmpty()) return keyIds;
-        if (keyIds.isEmpty()) return rangeIds;
-
-        List<TxnId> output = new ArrayList<>(keyIds.size() + rangeIds.size());
-        int ki = 0, ri = 0;
-        while (ki < keyIds.size() && ri < rangeIds.size())
-        {
-            int c = keyIds.get(ki).compareTo(rangeIds.get(ri));
-            Invariants.checkState(c != 0);
-            if (c < 0) output.add(keyIds.get(ki++));
-            else output.add(rangeIds.get(ri++));
-        }
-        while (ki < keyIds.size())
-            output.add(keyIds.get(ki++));
-        while (ri < rangeIds.size())
-            output.add(rangeIds.get(ri++));
-        return output;
-    }
-
     public Participants<?> participants(TxnId txnId)
     {
         switch (txnId.domain())
         {
             default:    throw new AssertionError();
-            case Key:   return keyDeps.participants(txnId);
+            case Key:   return CommandsForKey.managesExecution(txnId) ? keyDeps.participants(txnId) : directKeyDeps.participants(txnId);
             case Range: return rangeDeps.participants(txnId);
         }
     }
@@ -250,19 +279,21 @@ public class Deps
     public void forEachUniqueTxnId(Ranges ranges, Consumer<TxnId> forEach)
     {
         keyDeps.forEachUniqueTxnId(ranges, forEach);
+        directKeyDeps.forEachUniqueTxnId(ranges, forEach);
         rangeDeps.forEachUniqueTxnId(ranges, forEach);
     }
 
     public static <T> Deps merge(List<T> list, Function<T, Deps> getter)
     {
         return new Deps(KeyDeps.merge(list, getter, deps -> deps.keyDeps),
-                        RangeDeps.merge(list, getter, deps -> deps.rangeDeps));
+                        RangeDeps.merge(list, getter, deps -> deps.rangeDeps),
+                        KeyDeps.merge(list, getter, deps -> deps.directKeyDeps));
     }
 
     @Override
     public String toString()
     {
-        return keyDeps.toString() + ", " + rangeDeps.toString();
+        return keyDeps + ", " + rangeDeps + ", " + directKeyDeps;
     }
 
     @Override
@@ -273,14 +304,15 @@ public class Deps
 
     public boolean equals(Deps that)
     {
-        return that != null && this.keyDeps.equals(that.keyDeps) && this.rangeDeps.equals(that.rangeDeps);
+        return that != null && this.keyDeps.equals(that.keyDeps) && this.rangeDeps.equals(that.rangeDeps) && this.directKeyDeps.equals(that.directKeyDeps);
     }
 
     public @Nullable TxnId maxTxnId()
     {
         TxnId maxKeyDep = keyDeps.isEmpty() ? null : keyDeps.txnId(keyDeps.txnIdCount() - 1);
         TxnId maxRangeDep = rangeDeps.isEmpty() ? null : rangeDeps.txnId(rangeDeps.txnIdCount() - 1);
-        return TxnId.nonNullOrMax(maxKeyDep, maxRangeDep);
+        TxnId maxDirectKeyDep = directKeyDeps.isEmpty() ? null : directKeyDeps.txnId(directKeyDeps.txnIdCount() - 1);
+        return TxnId.nonNullOrMax(TxnId.nonNullOrMax(maxKeyDep, maxRangeDep), maxDirectKeyDep);
     }
 
     @Override
diff --git a/accord-core/src/main/java/accord/primitives/KeyDeps.java b/accord-core/src/main/java/accord/primitives/KeyDeps.java
index 180be4dc..eb9c5b22 100644
--- a/accord-core/src/main/java/accord/primitives/KeyDeps.java
+++ b/accord-core/src/main/java/accord/primitives/KeyDeps.java
@@ -515,6 +515,11 @@ public class KeyDeps implements Iterable<Map.Entry<Key, TxnId>>
         return txnIds[i];
     }
 
+    public int indexOf(TxnId txnId)
+    {
+        return Arrays.binarySearch(txnIds, txnId);
+    }
+
     public SortedArrayList<TxnId> txnIds()
     {
         return new SortedArrayList<>(txnIds);
diff --git a/accord-core/src/main/java/accord/primitives/LatestDeps.java b/accord-core/src/main/java/accord/primitives/LatestDeps.java
index c2a03e3c..911da3fd 100644
--- a/accord-core/src/main/java/accord/primitives/LatestDeps.java
+++ b/accord-core/src/main/java/accord/primitives/LatestDeps.java
@@ -43,7 +43,7 @@ public class LatestDeps extends ReducingRangeMap<LatestDeps.LatestEntry>
 
     public static class SerializerSupport
     {
-        public static <V> LatestDeps create(boolean inclusiveEnds, RoutingKey[] starts, LatestEntry[] values)
+        public static LatestDeps create(boolean inclusiveEnds, RoutingKey[] starts, LatestEntry[] values)
         {
             return new LatestDeps(inclusiveEnds, starts, values);
         }
@@ -128,14 +128,16 @@ public class LatestDeps extends ReducingRangeMap<LatestDeps.LatestEntry>
             if (deps == null) return null;
             KeyDeps keyDeps = deps.keyDeps;
             RangeDeps rangeDeps = deps.rangeDeps;
+            KeyDeps directKeyDeps = deps.directKeyDeps;
             Keys keys = deps.keyDeps.keys;
 
             boolean slice = keys.indexOf(start) != -1 || keys.indexOf(end) != -1 - keys.size();
+            if (!slice) slice = directKeyDeps.keys.indexOf(start) != -1 || directKeyDeps.keys.indexOf(end) != -1 - keys.size();
             if (!slice) slice = rangeDeps.indexOfStart(start) != -1 || rangeDeps.indexOfStart(end) != -1 - rangeDeps.rangeCount();
             if (!slice) return deps;
 
             Ranges ranges = Ranges.of(start.rangeFactory().newRange(start, end));
-            return new Deps(keyDeps.slice(ranges), rangeDeps.slice(ranges));
+            return new Deps(keyDeps.slice(ranges), rangeDeps.slice(ranges), directKeyDeps.slice(ranges));
         }
 
         public String toString()
@@ -186,7 +188,7 @@ public class LatestDeps extends ReducingRangeMap<LatestDeps.LatestEntry>
         if (deps == null)
             return null;
 
-        return new Deps(deps.keyDeps.slice(ranges), deps.rangeDeps.slice(ranges));
+        return new Deps(deps.keyDeps.slice(ranges), deps.rangeDeps.slice(ranges), deps.directKeyDeps.slice(ranges));
     }
 
     static class Builder extends AbstractIntervalBuilder<RoutingKey, LatestEntry, LatestDeps>
@@ -309,8 +311,9 @@ public class LatestDeps extends ReducingRangeMap<LatestDeps.LatestEntry>
                 return Deps.NONE;
 
             KeyDeps keyDeps =  KeyDeps.merge(stream(Merge::forProposal, (d, r) -> d.keyDeps.slice(r)));
+            KeyDeps directKeyDeps =  KeyDeps.merge(stream(Merge::forProposal, (d, r) -> d.directKeyDeps.slice(r)));
             RangeDeps rangeDeps =  RangeDeps.merge(stream(Merge::forProposal, (d, r) -> d.rangeDeps.slice(r)));
-            return new Deps(keyDeps, rangeDeps);
+            return new Deps(keyDeps, rangeDeps, directKeyDeps);
         }
 
         MergedCommitResult mergeCommit(TxnId txnId, Timestamp executeAt)
@@ -321,8 +324,9 @@ public class LatestDeps extends ReducingRangeMap<LatestDeps.LatestEntry>
             List<Range> sufficientFor = new ArrayList<>();
             boolean useLocalDeps = txnId.equals(executeAt);
             KeyDeps keyDeps =  KeyDeps.merge(stream(forCommit(useLocalDeps, sufficientFor), (d, r) -> d.keyDeps.slice(r)));
+            KeyDeps directKeyDeps =  KeyDeps.merge(stream(forCommit(useLocalDeps, sufficientFor), (d, r) -> d.directKeyDeps.slice(r)));
             RangeDeps rangeDeps =  RangeDeps.merge(stream(forCommit(useLocalDeps, sufficientFor), (d, r) -> d.rangeDeps.slice(r)));
-            return new MergedCommitResult(new Deps(keyDeps, rangeDeps), Ranges.of(sufficientFor.toArray(new Range[0])));
+            return new MergedCommitResult(new Deps(keyDeps, rangeDeps, directKeyDeps), Ranges.of(sufficientFor.toArray(new Range[0])));
         }
 
         private <V> Stream<V> stream(TriFunction<Ranges, MergeEntry, BiFunction<Deps, Ranges, V>, Stream<V>> selector, BiFunction<Deps, Ranges, V> getter)
diff --git a/accord-core/src/main/java/accord/primitives/PartialDeps.java b/accord-core/src/main/java/accord/primitives/PartialDeps.java
index 9367328b..7b17b3c7 100644
--- a/accord-core/src/main/java/accord/primitives/PartialDeps.java
+++ b/accord-core/src/main/java/accord/primitives/PartialDeps.java
@@ -22,7 +22,7 @@ import accord.utils.Invariants;
 
 public class PartialDeps extends Deps
 {
-    public static final PartialDeps NONE = new PartialDeps(Ranges.EMPTY, KeyDeps.NONE, RangeDeps.NONE);
+    public static final PartialDeps NONE = new PartialDeps(Ranges.EMPTY, KeyDeps.NONE, RangeDeps.NONE, KeyDeps.NONE);
 
     public static Builder builder(Ranges covering)
     {
@@ -39,7 +39,10 @@ public class PartialDeps extends Deps
         @Override
         public PartialDeps build()
         {
-            return new PartialDeps(covering, keyBuilder.build(), rangeBuilder == null ? RangeDeps.NONE : rangeBuilder.build());
+            return new PartialDeps(covering,
+                                   keyBuilder.build(),
+                                   rangeBuilder == null ? RangeDeps.NONE : rangeBuilder.build(),
+                                   directKeyBuilder == null ? KeyDeps.NONE : directKeyBuilder.build());
         }
     }
 
@@ -49,11 +52,12 @@ public class PartialDeps extends Deps
     //    where the store has some ranges that we participate in, and some we do not; we will not correctly construct covering in some cases
     public final Ranges covering;
 
-    public PartialDeps(Ranges covering, KeyDeps keyDeps, RangeDeps rangeDeps)
+    public PartialDeps(Ranges covering, KeyDeps keyDeps, RangeDeps rangeDeps, KeyDeps directKeyDeps)
     {
-        super(keyDeps, rangeDeps);
+        super(keyDeps, rangeDeps, directKeyDeps);
         this.covering = covering;
         Invariants.checkState(covering.containsAll(keyDeps.keys));
+        Invariants.checkState(covering.containsAll(directKeyDeps.keys));
         Invariants.checkState(rangeDeps.isCoveredBy(covering));
     }
 
@@ -62,14 +66,6 @@ public class PartialDeps extends Deps
         return covering.containsAll(participants);
     }
 
-    public boolean isEqualOrFuller(PartialDeps that)
-    {
-        // TODO (required): this is likely not correct: could have all txnIds but be missing some txnId<->key relations
-        return covering.containsAll(that.covering)
-            && keyDeps.txnIds().containsAll(that.keyDeps.txnIds())
-            && rangeDeps.txnIds().containsAll(that.rangeDeps.txnIds());
-    }
-
     public Deps with(Deps that)
     {
         if (that instanceof PartialDeps)
@@ -79,17 +75,18 @@ public class PartialDeps extends Deps
 
     public PartialDeps with(PartialDeps that)
     {
-        Invariants.checkArgument((this.rangeDeps == null) == (that.rangeDeps == null));
         return new PartialDeps(that.covering.with(this.covering),
                 this.keyDeps.with(that.keyDeps),
-                this.rangeDeps == null ? null : this.rangeDeps.with(that.rangeDeps));
+                this.rangeDeps.with(that.rangeDeps),
+                this.directKeyDeps.with(that.directKeyDeps)
+        );
     }
 
     public Deps reconstitute(FullRoute<?> route)
     {
         if (!covers(route.participants()))
             throw new IllegalArgumentException();
-        return new Deps(keyDeps, rangeDeps);
+        return new Deps(keyDeps, rangeDeps, directKeyDeps);
     }
 
     // covering might cover a wider set of ranges, some of which may have no involved keys
@@ -98,10 +95,8 @@ public class PartialDeps extends Deps
         if (!covers(covering))
             throw new IllegalArgumentException();
 
-        if (covers(covering))
-            return this;
-
-        return new PartialDeps(covering, keyDeps, rangeDeps);
+        if (covers(covering)) return this;
+        else throw Invariants.illegalArgument(this.covering + " does not cover " + covering);
     }
 
     @Override
diff --git a/accord-core/src/main/java/accord/primitives/PartialTxn.java b/accord-core/src/main/java/accord/primitives/PartialTxn.java
index 06d470e0..6bcb9ca8 100644
--- a/accord-core/src/main/java/accord/primitives/PartialTxn.java
+++ b/accord-core/src/main/java/accord/primitives/PartialTxn.java
@@ -18,8 +18,6 @@
 
 package accord.primitives;
 
-import java.util.Objects;
-
 import accord.api.Query;
 import accord.api.Read;
 import accord.api.Update;
@@ -59,8 +57,6 @@ public interface PartialTxn extends Txn
         return covering().containsAll(participants);
     }
 
-    boolean isEqualOrFuller(PartialTxn txn);
-
     static PartialTxn merge(@Nullable PartialTxn a, @Nullable PartialTxn b)
     {
         return a == null ? b : b == null ? a : a.with(b);
@@ -112,17 +108,6 @@ public interface PartialTxn extends Txn
             return covering.containsAll(ranges);
         }
 
-        @Override
-        public boolean isEqualOrFuller(PartialTxn txn)
-        {
-            return kind() == txn.kind()
-                && covering().containsAll(txn.covering())
-                && keys().containsAll(txn.keys())
-                && read().isEqualOrFuller(txn.read())
-                && Objects.equals(query(), txn.query())
-                && ((update() == null && txn.update() == null) || (update() != null && txn.update() != null && update().isEqualOrFuller(txn.update())));
-        }
-
         @Override
         public Txn reconstitute(FullRoute<?> route)
         {
diff --git a/accord-core/src/main/java/accord/primitives/RangeDeps.java b/accord-core/src/main/java/accord/primitives/RangeDeps.java
index b609a8cc..4cc18d29 100644
--- a/accord-core/src/main/java/accord/primitives/RangeDeps.java
+++ b/accord-core/src/main/java/accord/primitives/RangeDeps.java
@@ -18,7 +18,6 @@
 
 package accord.primitives;
 
-import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.BitSet;
 import java.util.Iterator;
@@ -44,6 +43,7 @@ import accord.utils.RelationMultiMap;
 import accord.utils.SearchableRangeList;
 import accord.utils.SortedArrays;
 import accord.utils.SortedArrays.SortedArrayList;
+import accord.utils.SortedList;
 import accord.utils.SymmetricComparator;
 import accord.utils.TriFunction;
 import net.nicoulaj.compilecommand.annotations.DontInline;
@@ -658,20 +658,38 @@ public class RangeDeps implements Iterable<Map.Entry<Range, TxnId>>
         return txnIds(rangesToTxnIds, start, end);
     }
 
-    public List<TxnId> computeTxnIds(Key key)
+    static class ListBuilder
     {
-        List<TxnId> result = new ArrayList<>();
-        forEachUniqueTxnId(key, result::add);
-        result.sort(TxnId::compareTo);
-        return result;
+        TxnId[] buffer = NO_TXNIDS;
+        int count;
+
+        void add(TxnId txnId)
+        {
+            if (count == buffer.length)
+                buffer = cachedTxnIds().resize(buffer, count, Math.max(8, count + (count >> 1)));
+            buffer[count++] = txnId;
+        }
+
+        SortedList<TxnId> build()
+        {
+            TxnId[] txnIds = cachedTxnIds().completeAndDiscard(buffer, count);
+            Arrays.sort(txnIds);
+            return new SortedArrayList<>(txnIds);
+        }
+    }
+
+    public SortedList<TxnId> computeTxnIds(Key key)
+    {
+        ListBuilder builder = new ListBuilder();
+        forEachUniqueTxnId(key, builder::add);
+        return builder.build();
     }
 
     public List<TxnId> computeTxnIds(Range key)
     {
-        List<TxnId> result = new ArrayList<>();
-        forEachUniqueTxnId(key, result::add);
-        result.sort(TxnId::compareTo);
-        return result;
+        ListBuilder builder = new ListBuilder();
+        forEachUniqueTxnId(key, builder::add);
+        return builder.build();
     }
 
     private SortedRelationList<TxnId> txnIds(int[] ids, int start, int end)
diff --git a/accord-core/src/main/java/accord/primitives/SyncPoint.java b/accord-core/src/main/java/accord/primitives/SyncPoint.java
index 5be269f4..a9abc19f 100644
--- a/accord-core/src/main/java/accord/primitives/SyncPoint.java
+++ b/accord-core/src/main/java/accord/primitives/SyncPoint.java
@@ -28,6 +28,10 @@ import accord.utils.Invariants;
  * expect the whole cluster to process these, and we do not want transaction processing to be held up,
  * so while these are processed much like a transaction, they are invisible to real transactions which
  * may proceed before this is witnessed by the node processing it.
+ *
+ * TODO (required): is this safe for barrier transactions? Since it is invisible to other transactions, it can be recovered
+ *   to execute at its TxnId time, even if a later txn exists. We must either make it visible to other transactions
+ *   for coordination (but not necessarily for execution), or else require that it has an Accept round.
  */
 public class SyncPoint<S extends Seekables<?, ?>>
 {
diff --git a/accord-core/src/main/java/accord/utils/ArrayBuffers.java b/accord-core/src/main/java/accord/utils/ArrayBuffers.java
index ba633ccc..2e66d2e5 100644
--- a/accord-core/src/main/java/accord/utils/ArrayBuffers.java
+++ b/accord-core/src/main/java/accord/utils/ArrayBuffers.java
@@ -21,6 +21,7 @@ package accord.utils;
 import accord.api.Key;
 import accord.api.RoutingKey;
 import accord.primitives.Range;
+import accord.primitives.Timestamp;
 import accord.primitives.TxnId;
 
 import java.lang.reflect.Array;
@@ -57,6 +58,7 @@ public class ArrayBuffers
     private static final ThreadLocal<ObjectBufferCache<RoutingKey>> ROUTINGKEYS = ThreadLocal.withInitial(() -> new ObjectBufferCache<>(3, 1 << 9, RoutingKey[]::new));
     private static final ThreadLocal<ObjectBufferCache<Range>> KEYRANGES = ThreadLocal.withInitial(() -> new ObjectBufferCache<>(3, 1 << 7, Range[]::new));
     private static final ThreadLocal<ObjectBufferCache<TxnId>> TXN_IDS = ThreadLocal.withInitial(() -> new ObjectBufferCache<>(3, 1 << 12, TxnId[]::new));
+    private static final ThreadLocal<ObjectBufferCache<Timestamp>> TIMESTAMPS = ThreadLocal.withInitial(() -> new ObjectBufferCache<>(3, 1 << 12, Timestamp[]::new));
 
     public static IntBuffers cachedInts()
     {
@@ -83,6 +85,11 @@ public class ArrayBuffers
         return TXN_IDS.get();
     }
 
+    public static ObjectBuffers<Timestamp> cachedTimestamps()
+    {
+        return TIMESTAMPS.get();
+    }
+
     public static <T> ObjectBuffers<T> uncached(IntFunction<T[]> allocator) { return new UncachedObjectBuffers<>(allocator); }
 
     public static IntBuffers uncachedInts() { return UncachedIntBuffers.INSTANCE; }
@@ -162,6 +169,14 @@ public class ArrayBuffers
          */
         T[] get(int minSize);
 
+        /**
+         * Return an {@code T[]} of size at least {@code size}, where size is the exact
+         * number of items that will be populated by the caller. In most cases this
+         * can allocate an output array, but in some use cases it may still return a larger buffer.
+         * No need to invoke complete on the return value.
+         */
+        T[] getAndCompleteExact(int size);
+
         /**
          * Return an {@code T[]} of size at least {@code minSize}, possibly from a pool,
          * and copy the contents of {@code copyAndDiscard} into it.
@@ -235,7 +250,7 @@ public class ArrayBuffers
          *
          * Depending on implementation, this is either saved from the last such invocation, or else simply returns the size of the buffer parameter.
          */
-        int lengthOfLast(T[] buffer);
+        int sizeOfLast(T[] buffer);
     }
 
     private static final class UncachedIntBuffers implements IntBuffers
@@ -288,6 +303,12 @@ public class ArrayBuffers
             return allocator.apply(minSize);
         }
 
+        @Override
+        public T[] getAndCompleteExact(int size)
+        {
+            return allocator.apply(size);
+        }
+
         @Override
         public T[] complete(T[] buffer, int usedSize)
         {
@@ -304,7 +325,7 @@ public class ArrayBuffers
         }
 
         @Override
-        public int lengthOfLast(T[] buffer)
+        public int sizeOfLast(T[] buffer)
         {
             return buffer.length;
         }
@@ -457,7 +478,7 @@ public class ArrayBuffers
         }
 
         @Override
-        public int lengthOfLast(T[] buffer)
+        public int sizeOfLast(T[] buffer)
         {
             return buffer.length;
         }
@@ -479,6 +500,12 @@ public class ArrayBuffers
         {
             return getInternal(minSize);
         }
+
+        @Override
+        public T[] getAndCompleteExact(int size)
+        {
+            return allocator.apply(size);
+        }
     }
 
     /**
@@ -508,6 +535,13 @@ public class ArrayBuffers
             return objs.get(minSize);
         }
 
+        @Override
+        public T[] getAndCompleteExact(int size)
+        {
+            length = size;
+            return get(size);
+        }
+
         @Override
         public T[] complete(T[] buffer, int usedSize)
         {
@@ -568,7 +602,7 @@ public class ArrayBuffers
         }
 
         @Override
-        public int lengthOfLast(T[] buffer)
+        public int sizeOfLast(T[] buffer)
         {
             if (length == -1)
                 throw illegalState("Attempted to get last length but no call to complete called");
@@ -576,6 +610,105 @@ public class ArrayBuffers
         }
     }
 
+    /**
+     * Returns the buffer to the caller, saving the length if necessary
+     */
+    public static class RecursiveObjectBuffers<T> implements ObjectBuffers<T>
+    {
+        final ObjectBuffers<T> wrapped;
+        T[] cur, alt;
+        int lastSize, maxBufferSize;
+
+        public RecursiveObjectBuffers(ObjectBuffers<T> wrapped)
+        {
+            this.wrapped = wrapped;
+        }
+
+        @Override
+        public T[] get(int minSize)
+        {
+            if (alt != null)
+            {
+                if (alt.length >= minSize)
+                    return alt;
+
+                wrapped.forceDiscard(alt, Math.min(maxBufferSize, alt.length));
+            }
+            return alt = wrapped.get(minSize);
+        }
+
+        @Override
+        public T[] getAndCompleteExact(int size)
+        {
+            updateSize(size);
+            T[] buf = get(size);
+            this.alt = cur;
+            this.cur = buf;
+            return buf;
+        }
+
+        @Override
+        public T[] complete(T[] buffer, int usedSize)
+        {
+            updateSize(usedSize);
+            Invariants.checkArgument(buffer == alt);
+            alt = cur;
+            cur = buffer;
+            return buffer;
+        }
+
+        @Override
+        public T[] completeAndDiscard(T[] buffer, int usedSize)
+        {
+            return complete(buffer, usedSize);
+        }
+
+        @Override
+        public T[] completeWithExisting(T[] buffer, int usedSize)
+        {
+            updateSize(usedSize);
+            return buffer;
+        }
+
+        private void updateSize(int size)
+        {
+            lastSize = size;
+            maxBufferSize = Math.max(size, maxBufferSize);
+        }
+
+        @Override
+        public boolean discard(T[] buffer, int usedSize)
+        {
+            return false;
+        }
+
+        @Override
+        public boolean forceDiscard(T[] buffer, int usedSize)
+        {
+            return false;
+        }
+
+        @Override
+        public int sizeOfLast(T[] buffer)
+        {
+            return lastSize;
+        }
+
+        public void discardBuffers()
+        {
+            if (cur != null)
+            {
+                wrapped.forceDiscard(cur, Math.min(maxBufferSize, cur.length));
+                cur = null;
+            }
+            if (alt != null)
+            {
+                wrapped.forceDiscard(alt, Math.min(maxBufferSize, alt.length));
+                alt = null;
+            }
+        }
+    }
+
     /**
      * Returns the buffer to the caller, saving the length if necessary
      */
diff --git a/accord-core/src/main/java/accord/utils/Invariants.java b/accord-core/src/main/java/accord/utils/Invariants.java
index ad9d8b35..2f951d21 100644
--- a/accord-core/src/main/java/accord/utils/Invariants.java
+++ b/accord-core/src/main/java/accord/utils/Invariants.java
@@ -28,14 +28,32 @@ import static java.lang.String.format;
 
 public class Invariants
 {
-    // TODO (now): configure by system parameter and turn off by default
-    private static final boolean PARANOID = true;
-    private static final boolean DEBUG = true;
+    public enum Paranoia
+    {
+        NONE, CONSTANT, LINEAR, SUPERLINEAR, QUADRATIC
+    }
+
+    public enum ParanoiaCostFactor
+    {
+        LOW, HIGH
+    }
+
+    private static final int PARANOIA_COMPUTE = Paranoia.valueOf(System.getProperty("accord.paranoia.cpu", "NONE").toUpperCase()).ordinal();
+    private static final int PARANOIA_MEMORY = Paranoia.valueOf(System.getProperty("accord.paranoia.memory", "NONE").toUpperCase()).ordinal();
+    private static final int PARANOIA_FACTOR = ParanoiaCostFactor.valueOf(System.getProperty("accord.paranoia.costfactor", "LOW").toUpperCase()).ordinal();
+    private static boolean IS_PARANOID = PARANOIA_COMPUTE > 0 || PARANOIA_MEMORY > 0;
+    private static final boolean DEBUG = System.getProperty("accord.debug", "false").equals("true");
 
     public static boolean isParanoid()
     {
-        return PARANOID;
+        return IS_PARANOID;
     }
+
+    public static boolean testParanoia(Paranoia compute, Paranoia memory, ParanoiaCostFactor factor)
+    {
+        return PARANOIA_COMPUTE >= compute.ordinal() && PARANOIA_MEMORY >= memory.ordinal() && PARANOIA_FACTOR >= factor.ordinal();
+    }
+
     public static boolean debug()
     {
         return DEBUG;
@@ -92,7 +110,7 @@ public class Invariants
 
     public static void paranoid(boolean condition)
     {
-        if (PARANOID && !condition)
+        if (isParanoid() && !condition)
             illegalState();
     }
 
@@ -324,6 +342,14 @@ public class Invariants
         return param;
     }
 
+    @Inline
+    public static int checkNonNegative(int index)
+    {
+        if (index < 0)
+            throw illegalState("Index %d expected to be non-negative", index);
+        return index;
+    }
+
     public static <O> O cast(Object o, Class<O> klass)
     {
         try
@@ -336,6 +362,18 @@ public class Invariants
         }
     }
 
+    public static void checkIndex(int index, int length)
+    {
+        if (!(index >= 0 && index < length))
+        {
+            if (index < 0)
+                throw new IndexOutOfBoundsException("Index " + index + " must not be negative");
+            if (length < 0)
+                throw new IndexOutOfBoundsException("Length " + length + " must not be negative");
+            throw new IndexOutOfBoundsException(String.format("%d must be less than %d", index, length));
+        }
+    }
+
     public static void checkIndexInBounds(int realLength, int offset, int length)
     {
         if (realLength == 0 || length == 0)
@@ -348,4 +386,5 @@ public class Invariants
         if (endOffset > realLength)
             throw new IndexOutOfBoundsException(String.format("Offset %d, length = %d; real length was %d", offset, length, realLength));
     }
+
 }
diff --git a/accord-core/src/main/java/accord/utils/MergeFewDisjointSortedListsCursor.java b/accord-core/src/main/java/accord/utils/MergeFewDisjointSortedListsCursor.java
new file mode 100644
index 00000000..91b5a77e
--- /dev/null
+++ b/accord-core/src/main/java/accord/utils/MergeFewDisjointSortedListsCursor.java
@@ -0,0 +1,156 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package accord.utils;
+
+import java.util.Arrays;
+import javax.annotation.Nonnull;
+
+public class MergeFewDisjointSortedListsCursor<T extends Comparable<? super T>> implements SortedCursor<T>
+{
+    // Holds and is comparable by the head item of an iterator it owns
+    protected static final class Candidate<T extends Comparable<? super T>> implements Comparable<Candidate<T>>
+    {
+        private final SortedList<? extends T> list;
+        private int itemIdx;
+        private T item;
+
+        public Candidate(@Nonnull SortedList<? extends T> list)
+        {
+            Invariants.checkState(!list.isEmpty());
+            this.list = list;
+            this.item = list.get(0);
+        }
+
+        /** @return this if our iterator had an item, and it is now available, otherwise null */
+        private Candidate<T> advance()
+        {
+            if (++itemIdx >= list.size())
+                return null;
+
+            item = list.get(itemIdx);
+            return this;
+        }
+
+        private boolean find(Comparable<? super T> find)
+        {
+            boolean found;
+            int i = list.findNext(itemIdx, find);
+            found = i >= 0;
+            if (i < 0) i = -1 - i;
+            if ((itemIdx = i) < list.size())
+                item = list.get(itemIdx = i);
+            else
+                item = null;
+            return found;
+        }
+
+        public int compareTo(Candidate<T> that)
+        {
+            return this.item.compareTo(that.item);
+        }
+    }
+
+    protected final Candidate<T>[] heap;
+
+    /** Number of non-exhausted iterators. */
+    int size;
+
+    public MergeFewDisjointSortedListsCursor(int capacity)
+    {
+        this.heap = (Candidate<T>[]) new Candidate[capacity];
+    }
+
+    public void add(SortedList<? extends T> list)
+    {
+        Candidate<T> candidate = new Candidate<>(list);
+        heap[size++] = candidate;
+    }
+
+    public void init()
+    {
+        Arrays.sort(heap, 0, size);
+    }
+
+    @Override
+    public boolean hasCur()
+    {
+        return size > 0;
+    }
+
+    @Override
+    public T cur()
+    {
+        return heap[0].item;
+    }
+
+    public void advance()
+    {
+        Candidate<T> sink = heap[0].advance();
+        if (sink == null)
+        {
+            heap[0] = null;
+            if (--size > 0)
+                System.arraycopy(heap, 1, heap, 0, size);
+        }
+        else
+        {
+            int i = 1;
+            for (; i < size && sink.compareTo(heap[i]) > 0; ++i)
+                heap[i - 1] = heap[i];
+            heap[i - 1] = sink;
+        }
+    }
+
+    @Override
+    public boolean find(Comparable<? super T> find)
+    {
+        int i = 0;
+        int removedCount = 0;
+        boolean found = false;
+        while (i < size)
+        {
+            int c = find.compareTo(heap[i].item);
+            if (c <= 0)
+            {
+                found |= c == 0;
+                if (removedCount > 0)
+                    System.arraycopy(heap, removedCount, heap, 0, size - removedCount);
+                break;
+            }
+
+            Candidate<T> candidate = heap[i];
+            found |= candidate.find(find);
+            if (candidate.item == null)
+            {
+                Invariants.checkState(candidate.itemIdx == candidate.list.size());
+                ++removedCount;
+            }
+            else if (removedCount > 0)
+            {
+                heap[i - removedCount] = candidate;
+            }
+            i++;
+        }
+
+        while (removedCount-- > 0)
+            heap[--size] = null;
+
+        Arrays.sort(heap, 0, size);
+        return found;
+    }
+}
\ No newline at end of file
diff --git a/accord-core/src/main/java/accord/utils/RelationMultiMap.java b/accord-core/src/main/java/accord/utils/RelationMultiMap.java
index 7eb8e824..590bd9fe 100644
--- a/accord-core/src/main/java/accord/utils/RelationMultiMap.java
+++ b/accord-core/src/main/java/accord/utils/RelationMultiMap.java
@@ -573,9 +573,9 @@ public class RelationMultiMap
         try
         {
             outKeys = SortedArrays.linearUnion(leftKeys, leftKeysLength, rightKeys, rightKeysLength, keyComparator, keyBuffers);
-            outKeysLength = keyBuffers.lengthOfLast(outKeys);
+            outKeysLength = keyBuffers.sizeOfLast(outKeys);
             outValues = SortedArrays.linearUnion(leftValues, leftValuesLength, rightValues, rightValuesLength, valueComparator, valueBuffers);
-            outValuesLength = valueBuffers.lengthOfLast(outValues);
+            outValuesLength = valueBuffers.sizeOfLast(outValues);
 
             remapLeft = remapToSuperset(leftValues, leftValuesLength, outValues, outValuesLength, valueComparator, intBuffers);
             remapRight = remapToSuperset(rightValues, rightValuesLength, outValues, outValuesLength, valueComparator, intBuffers);
diff --git a/accord-core/src/main/java/accord/utils/SortedArrays.java b/accord-core/src/main/java/accord/utils/SortedArrays.java
index 30c179cb..8da30df1 100644
--- a/accord-core/src/main/java/accord/utils/SortedArrays.java
+++ b/accord-core/src/main/java/accord/utils/SortedArrays.java
@@ -20,10 +20,10 @@ package accord.utils;
 
 import java.util.AbstractList;
 import java.util.Arrays;
+import java.util.Collection;
 import java.util.Comparator;
 import java.util.function.IntBinaryOperator;
 import java.util.function.IntFunction;
-import java.util.stream.StreamSupport;
 
 import accord.utils.ArrayBuffers.ObjectBuffers;
 import accord.utils.ArrayBuffers.IntBufferAllocator;
@@ -48,6 +48,7 @@ public class SortedArrays
         final T[] array;
         public SortedArrayList(T[] array)
         {
+            // implicitly checks entries are non-null
             this.array = checkArgument(array, SortedArrays::isSortedUnique);
         }
 
@@ -79,13 +80,40 @@ public class SortedArrays
         {
             return test.array.length == SortedArrays.foldlIntersection(Comparable::compareTo, array, 0, array.length, test.array, 0, test.array.length, (t, p, v, li, ri) -> v + 1, 0, 0, test.array.length);
         }
+
+        public static class Builder<T extends Comparable<? super T>>
+        {
+            final T[] array;
+            int count;
+
+            public Builder(T[] array)
+            {
+                this.array = array;
+            }
+
+            public void add(T value)
+            {
+                array[count++] = value;
+            }
+
+            public SortedArrayList<T> build()
+            {
+                Invariants.checkState(count == array.length);
+                return new SortedArrayList<>(array);
+            }
+        }
     }
 
     public static class ExtendedSortedArrayList<T extends Comparable<? super T>> extends SortedArrayList<T>
     {
-        public static <T extends Comparable<? super T>> ExtendedSortedArrayList<T> sortedCopyOf(Iterable<T> iterator, IntFunction<T[]> allocator)
+        public static <T extends Comparable<? super T>> ExtendedSortedArrayList<T> sortedCopyOf(Collection<T> copy, IntFunction<T[]> allocator)
         {
-            return new ExtendedSortedArrayList<>(checkArgument(StreamSupport.stream(iterator.spliterator(), false).sorted().toArray(allocator), SortedArrays::isSortedUnique), allocator);
+            T[] array = allocator.apply(copy.size());
+            array = copy.toArray(array);
+            Arrays.sort(array);
+            // implicitly checks entries are non-null
+            checkArgument(array, SortedArrays::isSortedUnique);
+            return new ExtendedSortedArrayList<>(array, allocator);
         }
 
         final IntFunction<T[]> allocator;
@@ -188,8 +216,10 @@ public class SortedArrays
                 {
                     if (leftStart == 0)
                         return buffers.completeWithExisting(left, leftEnd);
-                    result = buffers.get(leftEnd - leftStart);
-                    System.arraycopy(left, leftStart, result, 0, leftEnd - leftStart);
+
+                    int size = leftEnd - leftStart;
+                    result = buffers.getAndCompleteExact(size);
+                    System.arraycopy(left, leftStart, result, 0, size);
                     return result;
                 }
                 // no elements matched or only a subset matched
@@ -227,10 +257,13 @@ public class SortedArrays
                 {
                     if (rightStart == 0)
                         return buffers.completeWithExisting(right, rightEnd);
-                    result = buffers.get(rightEnd - rightStart);
-                    System.arraycopy(right, rightStart, result, 0, rightEnd - rightStart);
+
+                    int size = rightEnd - rightStart;
+                    result = buffers.getAndCompleteExact(size);
+                    System.arraycopy(right, rightStart, result, 0, size);
                     return result;
                 }
+
                 // no elements matched or only a subset matched
                 result = buffers.get((rightEnd - rightStart) + (leftEnd - leftIdx));
                 resultSize = rightIdx - rightStart;
@@ -272,11 +305,12 @@ public class SortedArrays
             while (rightIdx < rightEnd)
                 result[resultSize++] = right[rightIdx++];
 
-            return buffers.complete(result, resultSize);
+            return buffers.completeAndDiscard(result, resultSize);
         }
-        finally
+        catch (Throwable t)
         {
             buffers.discard(result, resultSize);
+            throw t;
         }
     }
 
@@ -1291,6 +1325,37 @@ public class SortedArrays
         return initialValue;
     }
 
+    @Inline
+    public static <T extends Comparable<? super T>> boolean isSubset(T[] test, T[] superset)
+    {
+        return isSubset(Comparable::compareTo, test, 0, test.length, superset, 0, superset.length);
+    }
+
+    @Inline
+    public static <T extends Comparable<? super T>> boolean isSubset(T[] test, int testFrom, int testTo, T[] superset, int supersetFrom, int supersetTo)
+    {
+        return isSubset(Comparable::compareTo, test, testFrom, testTo, superset, supersetFrom, supersetTo);
+    }
+
+    @Inline
+    public static <T> boolean isSubset(AsymmetricComparator<? super T, ? super T> comparator, T[] test, int testFrom, int testTo, T[] superset, int supersetFrom, int supersetTo)
+    {
+        while (true)
+        {
+            long abi = findNextIntersection(test, testFrom, testTo, superset, supersetFrom, supersetTo, comparator);
+            if (abi < 0)
+                return true;
+
+            int nextai = (int)(abi);
+            if (testFrom != nextai)
+                return false;
+
+            supersetFrom = (int)(abi >>> 32);
+            ++testFrom;
+            ++supersetFrom;
+        }
+    }
+
     public static <T extends Comparable<T>> void assertSorted(T[] array)
     {
         if (!isSorted(array))
diff --git a/accord-core/src/main/java/accord/api/Read.java b/accord-core/src/main/java/accord/utils/SortedCursor.java
similarity index 55%
copy from accord-core/src/main/java/accord/api/Read.java
copy to accord-core/src/main/java/accord/utils/SortedCursor.java
index 3b77ed3c..4dcbaabc 100644
--- a/accord-core/src/main/java/accord/api/Read.java
+++ b/accord-core/src/main/java/accord/utils/SortedCursor.java
@@ -15,24 +15,13 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+package accord.utils;
 
-package accord.api;
-
-import accord.local.SafeCommandStore;
-import accord.primitives.Ranges;
-import accord.primitives.Seekable;
-import accord.primitives.Seekables;
-import accord.primitives.Timestamp;
-import accord.utils.async.AsyncChain;
-
-/**
- * A read to be performed on potentially multiple shards, the inputs of which may be fed to a {@link Query}
- */
-public interface Read
+public interface SortedCursor<T extends Comparable<? super T>>
 {
-    Seekables<?, ?> keys();
-    AsyncChain<Data> read(Seekable key, SafeCommandStore commandStore, Timestamp executeAt, DataStore store);
-    Read slice(Ranges ranges);
-    Read merge(Read other);
-    default boolean isEqualOrFuller(Read other) { return true; }
+    // move to the first item >= find, returning true if an exact match was found
+    boolean find(Comparable<? super T> find);
+    void advance();
+    boolean hasCur();
+    T cur();
 }
diff --git a/accord-core/src/main/java/accord/utils/btree/BTree.java b/accord-core/src/main/java/accord/utils/btree/BTree.java
index 8bb5d529..ccb4688e 100644
--- a/accord-core/src/main/java/accord/utils/btree/BTree.java
+++ b/accord-core/src/main/java/accord/utils/btree/BTree.java
@@ -501,6 +501,23 @@ public class BTree
         }
     }
 
+    public static <Compare, Existing extends Compare, Insert extends Compare> Object[] updateExisting(Object[] toUpdate,
+                                                                                                      Insert insert,
+                                                                                                      int index,
+                                                                                                      UpdateFunction<Insert, Existing> updateF)
+    {
+        Existing prev = (Existing) toUpdate[index];
+        Object next = updateF.merge(prev, insert);
+        if (next == prev)
+            return toUpdate;
+
+        Object[] merged = new Object[toUpdate.length];
+        System.arraycopy(toUpdate, 0, merged, 0, index);
+        merged[index] = next;
+        System.arraycopy(toUpdate, index + 1, merged, index + 1, toUpdate.length - (index + 1));
+        return merged;
+    }
+
     public static void reverseInSitu(Object[] tree)
     {
         reverseInSitu(tree, height(tree), true);
diff --git a/accord-core/src/test/java/accord/impl/IntKey.java b/accord-core/src/test/java/accord/impl/IntKey.java
index ae575505..cf4cef61 100644
--- a/accord-core/src/test/java/accord/impl/IntKey.java
+++ b/accord-core/src/test/java/accord/impl/IntKey.java
@@ -115,6 +115,12 @@ public class IntKey implements RoutableKey
         {
             super(key);
         }
+
+        @Override
+        public accord.primitives.Range asRange()
+        {
+            return new Range(new Routing(key - 1), new Routing(key));
+        }
     }
 
     public static class Routing extends IntKey implements accord.api.RoutingKey
diff --git a/accord-core/src/test/java/accord/impl/PrefixedIntHashKey.java b/accord-core/src/test/java/accord/impl/PrefixedIntHashKey.java
index 7e75acc1..621ba41d 100644
--- a/accord-core/src/test/java/accord/impl/PrefixedIntHashKey.java
+++ b/accord-core/src/test/java/accord/impl/PrefixedIntHashKey.java
@@ -310,6 +310,10 @@ public class PrefixedIntHashKey implements RoutableKey
                     int rightInt = that instanceof Sentinel ? (((Sentinel) that).isMin ? -1 : 1) : 0;
                     rc = Integer.compare(leftInt, rightInt);
                 }
+                else if (this instanceof Key && that instanceof Key)
+                {
+                    rc = Integer.compare(this.key, other.key);
+                }
             }
         }
         return rc;
diff --git a/accord-core/src/test/java/accord/impl/TestAgent.java b/accord-core/src/test/java/accord/impl/TestAgent.java
index dfe5abd7..87f1503a 100644
--- a/accord-core/src/test/java/accord/impl/TestAgent.java
+++ b/accord-core/src/test/java/accord/impl/TestAgent.java
@@ -120,6 +120,18 @@ public class TestAgent implements Agent
         return MICROSECONDS.convert(10, SECONDS);
     }
 
+    @Override
+    public long cfkHlcPruneDelta()
+    {
+        return 1000;
+    }
+
+    @Override
+    public int cfkPruneInterval()
+    {
+        return 1;
+    }
+
     @Override
     public Txn emptyTxn(Txn.Kind kind, Seekables<?, ?> keysOrRanges)
     {
diff --git a/accord-core/src/test/java/accord/impl/basic/DelayedCommandStores.java b/accord-core/src/test/java/accord/impl/basic/DelayedCommandStores.java
index 4e96d890..319330df 100644
--- a/accord-core/src/test/java/accord/impl/basic/DelayedCommandStores.java
+++ b/accord-core/src/test/java/accord/impl/basic/DelayedCommandStores.java
@@ -64,6 +64,9 @@ import accord.utils.ReflectionUtils.Difference;
 import accord.utils.async.AsyncChain;
 import accord.utils.async.AsyncChains;
 
+import static accord.utils.Invariants.Paranoia.LINEAR;
+import static accord.utils.Invariants.ParanoiaCostFactor.HIGH;
+
 public class DelayedCommandStores extends InMemoryCommandStores.SingleThread
 {
     //TODO (correctness): remove once we have a Journal integration that does not change the values of a command...
@@ -163,6 +166,9 @@ public class DelayedCommandStores extends InMemoryCommandStores.SingleThread
         @Override
         public void validateRead(Command current)
         {
+            if (!Invariants.testParanoia(LINEAR, LINEAR, HIGH))
+                return;
+
             // "loading" the command doesn't make sense as we don't "store" the command...
             if (current.txnId().kind() == Txn.Kind.EphemeralRead)
                 return;
@@ -210,7 +216,7 @@ public class DelayedCommandStores extends InMemoryCommandStores.SingleThread
         public <T> AsyncChain<T> submit(Callable<T> fn)
         {
             Task<T> task = new DelayedTask<>(fn);
-            if (Invariants.isParanoid())
+            if (Invariants.testParanoia(LINEAR, LINEAR, HIGH))
             {
                 return AsyncChains.detectLeak(agent::onUncaughtException, () -> {
                     boolean wasEmpty = pending.isEmpty();
@@ -278,6 +284,7 @@ public class DelayedCommandStores extends InMemoryCommandStores.SingleThread
         @Override
         public void postExecute()
         {
+            super.postExecute();
             commands.entrySet().forEach(e -> {
                 InMemorySafeCommand safe = e.getValue();
                 if (!safe.isModified()) return;
diff --git a/accord-core/src/test/java/accord/impl/basic/Journal.java b/accord-core/src/test/java/accord/impl/basic/Journal.java
index 9258c0e0..7c8c8373 100644
--- a/accord-core/src/test/java/accord/impl/basic/Journal.java
+++ b/accord-core/src/test/java/accord/impl/basic/Journal.java
@@ -64,6 +64,7 @@ public class Journal implements LocalRequest.Handler, Runnable
     private final MessageListener messageListener;
     private final Map<Key, List<Diff>> diffs = new HashMap<>();
     private Node node;
+    boolean isScheduled;
 
     public Journal(MessageListener messageListener)
     {
@@ -73,7 +74,13 @@ public class Journal implements LocalRequest.Handler, Runnable
     public void start(Node node)
     {
         this.node = node;
-        node.scheduler().recurring(this, 1, TimeUnit.MILLISECONDS);
+    }
+
+    private void ensureScheduled()
+    {
+        if (isScheduled) return;
+        node.scheduler().once(this, 1, TimeUnit.MILLISECONDS);
+        isScheduled = true;
     }
 
     public void shutdown()
@@ -84,6 +91,7 @@ public class Journal implements LocalRequest.Handler, Runnable
     @Override
     public <R> void handle(LocalRequest<R> message, BiConsumer<? super R, Throwable> callback, Node node)
     {
+        ensureScheduled();
         messageListener.onMessage(NodeSink.Action.DELIVER, node.id(), node.id(), -1, message);
         if (message.type().hasSideEffects())
         {
@@ -96,6 +104,7 @@ public class Journal implements LocalRequest.Handler, Runnable
 
     public void handle(Request request, Node.Id from, ReplyContext replyContext)
     {
+        ensureScheduled();
         if (request.type() != null && request.type().hasSideEffects())
         {
             // enqueue
@@ -108,6 +117,7 @@ public class Journal implements LocalRequest.Handler, Runnable
     @Override
     public void run()
     {
+        isScheduled = false;
         if (this.node == null)
             return;
         try
diff --git a/accord-core/src/test/java/accord/impl/list/ListAgent.java b/accord-core/src/test/java/accord/impl/list/ListAgent.java
index 343be285..6d6cee5a 100644
--- a/accord-core/src/test/java/accord/impl/list/ListAgent.java
+++ b/accord-core/src/test/java/accord/impl/list/ListAgent.java
@@ -102,6 +102,18 @@ public class ListAgent implements Agent
         return timeout;
     }
 
+    @Override
+    public long cfkHlcPruneDelta()
+    {
+        return 100;
+    }
+
+    @Override
+    public int cfkPruneInterval()
+    {
+        return 1;
+    }
+
     @Override
     public Txn emptyTxn(Txn.Kind kind, Seekables<?, ?> keysOrRanges)
     {
diff --git a/accord-core/src/test/java/accord/local/WaitingOnTest.java b/accord-core/src/test/java/accord/local/WaitingOnTest.java
new file mode 100644
index 00000000..68a522e8
--- /dev/null
+++ b/accord-core/src/test/java/accord/local/WaitingOnTest.java
@@ -0,0 +1,136 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package accord.local;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import accord.local.Command.WaitingOn;
+import accord.primitives.Deps;
+import accord.primitives.Keys;
+import accord.primitives.TxnId;
+import accord.utils.AccordGens;
+import accord.utils.Gen;
+import accord.utils.Gens;
+import org.assertj.core.api.Assertions;
+
+import static accord.utils.Property.qt;
+
+class WaitingOnTest
+{
+    private static final Gen<Gen<Boolean>> BOOLEAN_DISTRO = Gens.bools().mixedDistribution();
+    private static final Gen<Deps> DEPS_GEN = AccordGens.depsFromKey(AccordGens.intKeys(), AccordGens.ranges(AccordGens.intRoutingKey()));
+    private static final Gen<Gen<WaitingOn>> WAITING_ON_DISTRO = rs -> AccordGens.waitingOn(DEPS_GEN, BOOLEAN_DISTRO.next(rs),
+                                                                                            BOOLEAN_DISTRO.next(rs),
+                                                                                            BOOLEAN_DISTRO.next(rs),
+                                                                                            BOOLEAN_DISTRO.next(rs));
+
+    @Test
+    void property()
+    {
+        qt().forAll(Gens.flatten(WAITING_ON_DISTRO)).check(WaitingOnTest::validateWaitingOn);
+    }
+
+    private static void validateWaitingOn(WaitingOn waitingOn)
+    {
+        validateIndexOf(waitingOn);
+        validateDirectKeyTxnReachable(waitingOn);
+        validateWaitingOnKey(waitingOn);
+        validateNextWaitingOn(waitingOn);
+    }
+
+    private static void validateIndexOf(WaitingOn waitingOn)
+    {
+        // property: indexOf(txnId(i)) == i
+        WaitingOn.Update update = new WaitingOn.Update(waitingOn);
+        for (int i = 0, size = waitingOn.txnIdCount(); i < size; i++)
+        {
+            TxnId expected = waitingOn.txnId(i);
+            Assertions.assertThat(waitingOn.indexOf(expected))
+                      .describedAs("Txn %s indexOf is not what is expected", expected)
+                      .isEqualTo(i);
+
+            // what about update?
+            Assertions.assertThat(update.txnId(i)).isEqualTo(expected);
+            Assertions.assertThat(update.indexOf(expected))
+                      .describedAs("Txn %s Update.indexOf is not what is expected", expected)
+                      .isEqualTo(i);
+        }
+    }
+
+    private static void validateDirectKeyTxnReachable(WaitingOn waitingOn)
+    {
+        // property: direct key txn are reachable
+        for (TxnId expected : waitingOn.directKeyDeps.txnIds())
+        {
+            Assertions.assertThat(waitingOn.indexOf(expected))
+                      .describedAs("Txn %s came from directKeyDeps but indexOf does not match", expected)
+                      .isEqualTo(waitingOn.directRangeDeps.txnIdCount() + waitingOn.directKeyDeps.indexOf(expected));
+        }
+    }
+
+    private static void validateWaitingOnKey(WaitingOn waitingOn)
+    {
+        // property: isWaitingOnKey(key_index) == is_set(key_offset + key_index)
+        Keys keys = waitingOn.keys;
+        int offset = waitingOn.directRangeDeps.txnIdCount() + waitingOn.directKeyDeps.txnIdCount();
+        boolean hasKeyWaiting = false;
+        for (int i = 0; i < keys.size(); i++)
+        {
+            boolean expected = waitingOn.waitingOn.get(offset + i);
+            Assertions.assertThat(waitingOn.isWaitingOnKey(i))
+                      .isEqualTo(expected);
+            hasKeyWaiting |= expected;
+        }
+        Assertions.assertThat(waitingOn.isWaitingOnKey()).isEqualTo(hasKeyWaiting);
+    }
+
+    private static void validateNextWaitingOn(WaitingOn waitingOn)
+    {
+        TxnId nextWaitingOn = waitingOn.nextWaitingOn();
+        List<TxnId> waitingOnTxns = waitingOnTxns(waitingOn);
+        TxnId maxTxn = waitingOnTxns.stream().max(Comparator.naturalOrder()).orElse(null);
+        TxnId minTxn = waitingOnTxns.stream().min(Comparator.naturalOrder()).orElse(null);
+        Assertions.assertThat(nextWaitingOn)
+                  .describedAs("nextWaitingOn did not match expected")
+                  .isEqualTo(maxTxn);
+
+        WaitingOn.Update update = new WaitingOn.Update(waitingOn);
+        Assertions.assertThat(update.minWaitingOnTxnId())
+                  .describedAs("Update.minWaitingOnTxnId did not match expected")
+                  .isEqualTo(minTxn);
+        Assertions.assertThat(update.minWaitingOnTxnIdx())
+                  .describedAs("Update.minWaitingOnTxnIdx did not match expected")
+                  .isEqualTo(minTxn == null ? -1 : waitingOn.indexOf(minTxn));
+    }
+
+    private static List<TxnId> waitingOnTxns(WaitingOn waitingOn)
+    {
+        List<TxnId> matches = new ArrayList<>();
+        for (int i = 0, size = waitingOn.txnIdCount(); i < size; i++)
+        {
+            if (waitingOn.waitingOn.get(i))
+                matches.add(waitingOn.txnId(i));
+        }
+        return matches;
+    }
+}
\ No newline at end of file
diff --git a/accord-core/src/test/java/accord/local/cfk/CommandsForKeyTest.java b/accord-core/src/test/java/accord/local/cfk/CommandsForKeyTest.java
new file mode 100644
index 00000000..c893702f
--- /dev/null
+++ b/accord-core/src/test/java/accord/local/cfk/CommandsForKeyTest.java
@@ -0,0 +1,1093 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package accord.local.cfk;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.EnumMap;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.TreeSet;
+import java.util.concurrent.Callable;
+import java.util.function.Consumer;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.junit.jupiter.api.Test;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import accord.api.Agent;
+import accord.api.Data;
+import accord.api.DataStore;
+import accord.api.Key;
+import accord.api.ProgressLog;
+import accord.api.Query;
+import accord.api.Read;
+import accord.api.Result;
+import accord.api.RoutingKey;
+import accord.api.Update;
+import accord.impl.IntKey;
+import accord.local.Command;
+import accord.local.Command.AbstractCommand;
+import accord.local.CommandStore;
+import accord.local.CommandStores;
+import accord.local.CommonAttributes;
+import accord.local.Listeners;
+import accord.local.Node;
+import accord.local.NodeTimeService;
+import accord.local.PreLoadContext;
+import accord.local.SafeCommand;
+import accord.local.SafeCommandStore;
+import accord.local.SaveStatus;
+import accord.local.Status;
+import accord.local.cfk.CommandsForKey.TxnInfo;
+import accord.primitives.Ballot;
+import accord.primitives.Deps;
+import accord.primitives.EpochSupplier;
+import accord.primitives.FullRoute;
+import accord.primitives.Keys;
+import accord.primitives.Participants;
+import accord.primitives.Range;
+import accord.primitives.Ranges;
+import accord.primitives.Routable.Domain;
+import accord.primitives.Route;
+import accord.primitives.Seekable;
+import accord.primitives.Seekables;
+import accord.primitives.Timestamp;
+import accord.primitives.Txn;
+import accord.primitives.TxnId;
+import accord.primitives.Unseekables;
+import accord.primitives.Writes;
+import accord.utils.DefaultRandom;
+import accord.utils.Invariants;
+import accord.utils.RandomSource;
+import accord.utils.async.AsyncChain;
+import accord.utils.async.AsyncResults;
+
+import static accord.local.Command.NotDefined.notDefined;
+import static accord.local.Status.Durability.NotDurable;
+
+// TODO (expected): test setting redundant before
+// TODO (expected): test ballot updates
+// TODO (expected): test transition to Erased
+// TODO (expected): ensure execution is not too early
+// TODO (expected): validate mapReduce
+// TODO (expected): insert linearizability violations and detect them
+public class CommandsForKeyTest
+{
+    private static final Logger logger = LoggerFactory.getLogger(CommandsForKeyTest.class);
+    private static final Key KEY = IntKey.key(1);
+    private static final Range RANGE = IntKey.range(0, 2);
+    private static final Keys KEYS = IntKey.keys(1);
+    private static final Ranges RANGES = Ranges.of(IntKey.range(0, 2));
+    private static final Txn KEY_TXN = new Txn.InMemory(KEYS, new TestRead(KEYS), new TestQuery());
+    private static final Txn RANGE_TXN = new Txn.InMemory(RANGES, new TestRead(RANGES), new TestQuery());
+    private static final FullRoute KEY_ROUTE = KEYS.toRoute(IntKey.routing(1));
+    private static final FullRoute RANGE_ROUTE = RANGES.toRoute(IntKey.routing(1));
+
+    static class CommandUpdate
+    {
+        final Command prev, next;
+
+        CommandUpdate(Command prev, Command next)
+        {
+            this.prev = prev;
+            this.next = next;
+        }
+    }
+
+    // TODO (expected): randomise ballots
+    static class Canon implements NotifySink
+    {
+        private static final TxnId MIN = new TxnId(1, 1, Txn.Kind.Read, Domain.Key, new Node.Id(1));
+
+        // TODO (expected): randomise ratios
+        static final Txn.Kind[] KINDS = new Txn.Kind[] { Txn.Kind.Read, Txn.Kind.Write, Txn.Kind.EphemeralRead, Txn.Kind.SyncPoint, Txn.Kind.ExclusiveSyncPoint };
+        final RandomSource rnd;
+        final Node.Id[] nodeIds;
+        final Domain[] domains;
+        final TreeSet<TxnId> unwitnessed = new TreeSet<>();
+        final TreeSet<TxnId> undecided = new TreeSet<>();
+        final TreeSet<TxnId> candidates = new TreeSet<>();
+        final TreeSet<TxnId> unfinished = new TreeSet<>();
+        final TreeMap<Timestamp, Command> byId = new TreeMap<>();
+        final TreeMap<Timestamp, Command> committedByExecuteAt = new TreeMap<>();
+        final Set<Timestamp> executeAts = new HashSet<>();
+        boolean closing;
+        int undecidedCount;
+
+        static final EnumMap<SaveStatus, SaveStatus[]> TRANSITIONS = new EnumMap<>(SaveStatus.class);
+
+        void set(Command prev, Command next)
+        {
+            byId.put(next.txnId(), next);
+            if (next.hasBeen(Status.Committed))
+            {
+                undecided.remove(next.txnId());
+                committedByExecuteAt.put(next.executeAt(), next);
+                if (next.hasBeen(Status.Stable))
+                {
+                    if (prev.saveStatus() != next.saveStatus())
+                        candidates.remove(next.txnId());
+
+                    if (next.hasBeen(Status.Applied))
+                    {
+                        unfinished.remove(next.txnId());
+                        if (!CommandsForKey.managesExecution(next.txnId()))
+                            removeWaitingOn(next.txnId(), Timestamp.MAX);
+                    }
+                    else
+                    {
+                        Command.Committed committed = next.asCommitted();
+                        if (!committed.isWaitingOnDependency() && (!prev.hasBeen(Status.Stable) || prev.asCommitted().isWaitingOnDependency()))
+                            readyToExecute(committed);
+                    }
+                }
+                if (next.hasBeen(Status.Committed) && !prev.hasBeen(Status.Committed) && !next.hasBeen(Status.Invalidated))
+                {
+                    if (!next.executeAt().equals(next.txnId()) && !CommandsForKey.manages(next.txnId()))
+                        removeWaitingOn(next.txnId(), next.executeAt());
+                }
+
+            }
+        }
+
+        private void readyToExecute(Command.Committed committed)
+        {
+            for (Command pred : committedByExecuteAt.headMap(committed.executeAt(), false).values())
+                Invariants.checkState(pred.hasBeen(Status.Applied) || !committed.txnId().kind().witnesses(pred.txnId()));
+            candidates.add(committed.txnId());
+        }
+
+        private void removeWaitingOn(TxnId waitingId, Timestamp until)
+        {
+            for (Command command : new ArrayList<>(committedByExecuteAt.subMap(waitingId, false, until, false).values()))
+            {
+                if (!command.hasBeen(Status.Stable))
+                    continue;
+
+                Command.Committed committed = command.asCommitted();
+                Command.WaitingOn waitingOn = committed.waitingOn;
+                if (waitingOn.isWaitingOn(waitingId))
+                {
+                    Command.WaitingOn.Update update = new Command.WaitingOn.Update(waitingOn);
+                    update.removeWaitingOn(waitingId);
+                    set(committed, Command.Committed.committed(committed, committed, update.build()));
+                }
+            }
+        }
+
+        @Override
+        public void notWaiting(SafeCommandStore safeStore, TxnId txnId, Key key)
+        {
+            Command.Committed prev = byId.get(txnId).asCommitted();
+            Command.WaitingOn.Update waitingOn = new Command.WaitingOn.Update(prev.waitingOn);
+            if (!waitingOn.removeWaitingOn(key))
+                return;
+
+            if (!prev.txnId().kind().awaitsOnlyDeps())
+            {
+                for (Command command : committedByExecuteAt.headMap(prev.executeAt(), false).values())
+                    Invariants.checkState(command.txnId().domain() == Domain.Range || !prev.txnId().kind().witnesses(command.txnId()) || command.saveStatus().compareTo(SaveStatus.Applied) >= 0);
+            }
+
+            if (prev.txnId().domain() == Domain.Key)
+            {
+                for (Command command : committedByExecuteAt.tailMap(prev.executeAt(), false).values())
+                    Invariants.checkState(command.txnId().kind().awaitsOnlyDeps() || !command.txnId().kind().witnesses(prev.txnId()) || command.saveStatus().compareTo(SaveStatus.Stable) < 0 || command.asCommitted().waitingOn.isWaitingOnKey(0));
+            }
+
+            Command.Committed next = Command.Committed.committed(prev, prev, waitingOn.build());
+            set(prev, next);
+        }
+
+        @Override
+        public void notWaiting(SafeCommandStore safeStore, SafeCommand safeCommand, Key key)
+        {
+            notWaiting(safeStore, safeCommand.txnId(), key);
+        }
+
+        @Override
+        public void waitingOnCommit(SafeCommandStore safeStore, TxnInfo uncommitted, Key key)
+        {
+        }
+
+        static
+        {
+            TRANSITIONS.put(SaveStatus.NotDefined, new SaveStatus[] { SaveStatus.PreAccepted, SaveStatus.AcceptedInvalidate, SaveStatus.AcceptedInvalidateWithDefinition, SaveStatus.Accepted, SaveStatus.AcceptedWithDefinition, SaveStatus.Committed, SaveStatus.Stable, SaveStatus.Invalidated });
+            TRANSITIONS.put(SaveStatus.PreAccepted, new SaveStatus[] { SaveStatus.AcceptedInvalidateWithDefinition, SaveStatus.AcceptedWithDefinition, SaveStatus.Committed, SaveStatus.Stable, SaveStatus.Invalidated });
+            // permit updated ballot and moving to other statuses
+            TRANSITIONS.put(SaveStatus.AcceptedInvalidate, new SaveStatus[] { SaveStatus.Invalidated });
+            TRANSITIONS.put(SaveStatus.AcceptedInvalidateWithDefinition, new SaveStatus[] { SaveStatus.Invalidated });
+            TRANSITIONS.put(SaveStatus.Accepted, new SaveStatus[] { SaveStatus.Committed, SaveStatus.Stable, SaveStatus.Invalidated });
+            TRANSITIONS.put(SaveStatus.AcceptedWithDefinition, new SaveStatus[] { SaveStatus.Committed, SaveStatus.Stable, SaveStatus.Invalidated });
+            TRANSITIONS.put(SaveStatus.Committed, new SaveStatus[] { SaveStatus.Stable });
+            TRANSITIONS.put(SaveStatus.Stable, new SaveStatus[] { SaveStatus.Applied });
+        }
+
+        Canon(RandomSource rnd)
+        {
+            this.rnd = rnd;
+            this.nodeIds = new Node.Id[10];
+            for (int i = 0 ; i < nodeIds.length ; ++i)
+                nodeIds[i] = new Node.Id(i + 1);
+            this.domains = Domain.values();
+        }
+
+        Canon(RandomSource rnd, Node.Id[] nodeIds)
+        {
+            this.rnd = rnd;
+            this.nodeIds = nodeIds;
+            this.domains = Domain.values();
+        }
+
+        boolean isDone()
+        {
+            return closing && unfinished.size() == 0;
+        }
+
+        void close()
+        {
+            closing = true;
+        }
+
+        CommandUpdate update(boolean hasWaitingTasks)
+        {
+            boolean generate = !closing && rnd.decide(1 / (1f + undecidedCount));
+            if (!generate && candidates.isEmpty() && hasWaitingTasks)
+                return null;
+            Invariants.checkArgument (!candidates.isEmpty() || (unfinished.size() == unwitnessed.size()));
+            Command prev = generate || candidates.isEmpty() ? unwitnessed(generateId()) : selectOne(candidates);
+            boolean invalidate = false;
+            if (prev.txnId().kind() == Txn.Kind.ExclusiveSyncPoint && !prev.hasBeen(Status.Committed))
+            {
+                // may need to invalidate
+                if (byId.tailMap(prev.txnId(), false).values().stream().anyMatch(c -> c.hasBeen(Status.Committed) && c.txnId().kind().witnesses(prev.txnId())))
+                    invalidate = true;
+            }
+            Command next = invalidate ? AbstractCommand.validate((AbstractCommand)update(prev, SaveStatus.Invalidated))
+                                      : AbstractCommand.validate((AbstractCommand)update(prev));
+            set(prev, next);
+            unwitnessed.remove(next.txnId());
+            return new CommandUpdate(prev, next);
+        }
+
+        private Command update(Command prev)
+        {
+            SaveStatus[] candidates = TRANSITIONS.get(prev.saveStatus());
+            return update(prev, candidates[rnd.nextInt(candidates.length)]);
+        }
+
+        private Command update(Command prev, SaveStatus newStatus)
+        {
+            switch (newStatus)
+            {
+                default:
+                case NotDefined:
+                case TruncatedApply:
+                case TruncatedApplyWithDeps:
+                case TruncatedApplyWithOutcome:
+                case PreApplied:
+                case PreCommitted:
+                    throw new AssertionError();
+
+                case PreAccepted:
+                    return preaccepted(prev.txnId());
+
+                case Accepted:
+                case AcceptedWithDefinition:
+                    return accepted(prev.txnId(), generateExecuteAt(prev.txnId()), newStatus);
+
+                case AcceptedInvalidate:
+                case AcceptedInvalidateWithDefinition:
+                    return acceptedInvalidated(prev.txnId(), newStatus);
+
+                case Committed:
+                    return committed(prev.txnId(), prev.executeAtIfKnown(generateExecuteAt(prev.txnId())));
+
+                case Stable:
+                    return stable(prev.txnId(), prev.executeAtIfKnown(generateExecuteAt(prev.txnId())), prev.hasBeen(Status.Committed) ? prev.asCommitted() : null);
+
+                case Applied:
+                    return applied(prev.txnId(), prev.executeAtIfKnown(generateExecuteAt(prev.txnId())), prev.hasBeen(Status.Committed) ? prev.asCommitted() : null);
+
+                case Invalidated:
+                    return invalidated(prev.txnId());
+            }
+        }
+
+        private Deps generateDeps(TxnId txnId, Timestamp executeAt, Status forStatus)
+        {
+            maybeGenerateUnwitnessed();
+            try (Deps.Builder builder = new Deps.Builder())
+            {
+                for (Command command : byId.headMap(executeAt, false).values())
+                {
+                    if (txnId.equals(command.txnId())) continue;
+                    if (!txnId.kind().witnesses(command.txnId())) continue;
+
+                    if (command.hasBeen(forStatus.compareTo(Status.Accepted) <= 0 ? Status.Committed : Status.Accepted) || rnd.nextBoolean())
+                    {
+                        unwitnessed.remove(command.txnId());
+                        builder.add(command.txnId().domain() == Domain.Key ? KEY : RANGE, command.txnId());
+                    }
+                }
+                return builder.build();
+            }
+        }
+
+        private Timestamp generateExecuteAt(TxnId txnId)
+        {
+            if (txnId.kind().awaitsOnlyDeps())
+                return txnId;
+
+            Timestamp min = Timestamp.NONE;
+            if (!committedByExecuteAt.isEmpty())
+            {
+                min = committedByExecuteAt.lastEntry().getValue().executeAt();
+                min = Timestamp.fromValues(min.epoch(), min.hlc() + 1, min.node);
+            }
+
+            if (min.compareTo(txnId) <= 0 && rnd.nextBoolean())
+                return txnId;
+
+            min = Timestamp.max(min, Timestamp.fromValues(txnId.epoch(), txnId.hlc() + 1, txnId.node));
+            Timestamp max = Timestamp.fromValues(min.epoch(), min.hlc() + 100, min.node);
+
+            Timestamp executeAt = generateTimestamp(min, max, true);
+            Invariants.checkState(executeAt.compareTo(txnId) >= 0);
+            executeAts.add(executeAt);
+            return executeAt;
+        }
+
+        private void maybeGenerateUnwitnessed()
+        {
+            int transitiveCount = rnd.decide(1 / (1f + unwitnessed.size())) ? rnd.nextInt(0, 3) : 0;
+            while (transitiveCount-- > 0)
+            {
+                // generate new unwitnessed transactions
+                TxnId next = generateId();
+                byId.put(next, unwitnessed(next));
+            }
+        }
+
+        TxnId generateId()
+        {
+            TxnId min = MIN;
+            TxnId max;
+            if (byId.isEmpty()) max = new TxnId(1, 100, Txn.Kind.Read, Domain.Key, nodeIds[0]);
+            else
+            {
+                max = byId.lastEntry().getValue().txnId();
+                switch (rnd.nextInt(3))
+                {
+                    default: throw new AssertionError();
+                    case 2:
+                        min = max;
+                    case 1:
+                        max = new TxnId(max.epoch(), max.hlc() + 100, max.kind(), max.domain(), max.node);
+                    case 0:
+
+                }
+            }
+            return generateId(min, max, true);
+        }
+
+        TxnId generateId(TxnId min, TxnId max, boolean unique)
+        {
+            TxnId result = generateId(min, max);
+            while (unique && (byId.containsKey(result) || executeAts.contains(result)))
+                result = generateId(min, max);
+            return result;
+        }
+
+        TxnId generateId(TxnId min, TxnId max)
+        {
+            long epoch = min.epoch() == max.epoch() ? min.epoch() : rnd.nextLong(min.epoch(), max.epoch());
+            long hlc = min.hlc() == max.hlc() ? min.hlc() : rnd.nextLong(min.hlc(), max.hlc());
+
+            Node.Id node;
+            if (hlc == min.hlc()) node = min.node.id == nodeIds.length + 1 ? min.node : nodeIds[rnd.nextInt(min.node.id - 1, nodeIds.length)];
+            else if (hlc == max.hlc()) node = max.node.id == 1 ? max.node : nodeIds[rnd.nextInt(0, max.node.id - 1)];
+            else node = rnd.pick(nodeIds);
+
+            Txn.Kind kind;
+            if (hlc == min.hlc()) kind = min.kind();
+            else if (hlc == max.hlc()) kind = max.kind();
+            else kind = rnd.pick(KINDS);
+
+            Domain domain;
+            if (hlc == min.hlc() && min.domain() == Domain.Range) domain = Domain.Range;
+            else if (hlc == max.hlc() && max.domain() == Domain.Key) domain = Domain.Key;
+            else domain = rnd.nextBoolean() ? Domain.Key : Domain.Range;
+
+            return new TxnId(epoch, hlc, kind, domain, node);
+        }
+
+        Timestamp generateTimestamp(Timestamp min, Timestamp max, boolean unique)
+        {
+            Timestamp result = generateTimestamp(min, max);
+            while (unique && (byId.containsKey(result) || executeAts.contains(result)))
+                result = generateTimestamp(min, max);
+            return result;
+        }
+
+        Timestamp generateTimestamp(Timestamp min, Timestamp max)
+        {
+            Invariants.checkArgument(min.flags() == 0);
+            Invariants.checkArgument(max.flags() == 0);
+            long epoch = min.epoch() == max.epoch() ? min.epoch() : rnd.nextLong(min.epoch(), max.epoch());
+            long hlc;
+            Node.Id node;
+            if (min.hlc() == max.hlc()) hlc = min.hlc();
+            else hlc = rnd.nextLong(min.hlc(), max.hlc());
+
+            if (hlc == min.hlc()) node = min.node.id == nodeIds.length + 1 ? min.node : nodeIds[rnd.nextInt(min.node.id - 1, nodeIds.length)];
+            else if (hlc == max.hlc()) node = max.node.id == 1 ? max.node : nodeIds[rnd.nextInt(0, max.node.id - 1)];
+            else node = rnd.pick(nodeIds);
+
+            Timestamp result = Timestamp.fromValues(epoch, hlc, node);
+            Invariants.checkState(result.compareTo(min) >= 0);
+            return result;
+        }
+
+        Command unwitnessed(TxnId txnId)
+        {
+            Command command = notDefined(common(txnId), Ballot.ZERO);
+            unwitnessed.add(txnId);
+            undecided.add(txnId);
+            candidates.add(txnId);
+            unfinished.add(txnId);
+            return command;
+        }
+
+        Command preaccepted(TxnId txnId)
+        {
+            return Command.PreAccepted.preAccepted(common(txnId), txnId, Ballot.ZERO);
+        }
+
+        Command acceptedInvalidated(TxnId txnId, SaveStatus saveStatus)
+        {
+            return Command.Accepted.accepted(common(txnId, saveStatus.known.definition.isKnown()),
+                                        saveStatus, txnId, Ballot.ZERO, Ballot.ZERO);
+        }
+
+        Command accepted(TxnId txnId, Timestamp executeAt, SaveStatus saveStatus)
+        {
+            Deps deps = generateDeps(txnId, txnId, Status.Accepted);
+            return Command.Accepted.accepted(common(txnId, saveStatus.known.definition.isKnown()).partialDeps(deps.slice(RANGES)),
+                                        saveStatus, executeAt, Ballot.ZERO, Ballot.ZERO);
+        }
+
+        Command committed(TxnId txnId, Timestamp executeAt)
+        {
+            Deps deps = generateDeps(txnId, executeAt, Status.Committed);
+            return Command.Committed.committed(common(txnId).partialDeps(deps.slice(RANGES)), SaveStatus.Committed, executeAt, Ballot.ZERO, Ballot.ZERO, null);
+        }
+
+        Command stable(TxnId txnId, Timestamp executeAt, @Nullable Command.Committed committed)
+        {
+            Deps deps = committed == null ? generateDeps(txnId, executeAt, Status.Stable) : committed.partialDeps();
+            CommonAttributes common = common(txnId).partialDeps(deps.slice(RANGES));
+            Command.WaitingOn waitingOn = initialiseWaitingOn(txnId, executeAt, common.route(), deps);
+            return Command.Committed.committed(common, SaveStatus.Stable, executeAt, Ballot.ZERO, Ballot.ZERO, waitingOn);
+        }
+
+        Command applied(TxnId txnId, Timestamp executeAt, @Nullable Command.Committed committed)
+        {
+            Deps deps = committed == null ? generateDeps(txnId, executeAt, Status.Applied) : committed.partialDeps();
+            CommonAttributes common = common(txnId).partialDeps(deps.slice(RANGES));
+            Command.WaitingOn waitingOn = committed == null || committed.waitingOn == null ? initialiseWaitingOn(txnId, executeAt, common.route(), deps) : committed.waitingOn;
+            return new Command.Executed(common, SaveStatus.Applied, executeAt, Ballot.ZERO, Ballot.ZERO, waitingOn,
+                                        new Writes(txnId, executeAt, KEYS, null), new Result(){});
+        }
+
+        Command invalidated(TxnId txnId)
+        {
+            return new Command.Truncated(common(txnId), SaveStatus.Invalidated, Timestamp.NONE, null, null);
+        }
+
+        CommonAttributes.Mutable common(TxnId txnId)
+        {
+            return common(txnId, true);
+        }
+
+        CommonAttributes.Mutable common(TxnId txnId, boolean withDefinition)
+        {
+            CommonAttributes.Mutable result = new CommonAttributes.Mutable(txnId)
+                   .durability(NotDurable)
+                   .route(txnId.domain() == Domain.Key ? KEY_ROUTE : RANGE_ROUTE);
+
+            if (withDefinition)
+                result.partialTxn((txnId.domain() == Domain.Key ? KEY_TXN : RANGE_TXN).slice(RANGES, true));
+
+            return result;
+        }
+
+        private Command.WaitingOn initialiseWaitingOn(TxnId txnId, Timestamp executeAt, Route<?> route, Deps deps)
+        {
+            Command.WaitingOn.Update waitingOn = Command.WaitingOn.Update.initialise(txnId, route, RANGES, deps);
+            for (int i = 0 ; i < waitingOn.txnIdCount() ; ++i)
+            {
+                TxnId dep = waitingOn.txnId(i);
+                Command command = byId.get(dep);
+                if (command.hasBeen(Status.Applied) || (command.hasBeen(Status.Committed) && command.executeAt().compareTo(executeAt) > 0))
+                    waitingOn.removeWaitingOn(dep);
+            }
+            return waitingOn.build();
+        }
+
+        private Command selectOne(TreeSet<TxnId> from)
+        {
+            TxnId bound = generateId(from.first(), from.last());
+            TxnId txnId = from.floor(bound);
+            return byId.get(txnId);
+        }
+    }
+
+    @Test
+    public void testOne()
+    {
+//        test(449555883255211L, 1000);
+        test(System.nanoTime(), 1000);
+    }
+
+    @Test
+    public void testMany()
+    {
+        long seed = System.nanoTime();
+        for (int i = 0 ; i < 1000 ; ++i)
+        {
+            test(seed++, 1000);
+        }
+    }
+
+    private static void test(long seed, int minCount)
+    {
+        logger.info("Seed {}", seed);
+        try
+        {
+            final RandomSource rnd = new DefaultRandom(seed);
+
+            final float runTaskChance = Math.max(0.01f, rnd.nextFloat());
+            final float pruneChance = rnd.nextFloat() * (rnd.nextBoolean() ? 0.1f : 0.01f);
+            final int pruneHlcDelta = 1 << rnd.nextInt(10);
+            final int pruneInterval = 1 << rnd.nextInt(5);
+            final Canon canon = new Canon(rnd);
+            TestCommandStore commandStore = new TestCommandStore(pruneInterval, pruneHlcDelta);
+            TestSafeCommandsForKey safeCfk = new TestSafeCommandsForKey(new CommandsForKey(KEY));
+            TestSafeStore safeStore = new TestSafeStore(canon, commandStore, safeCfk);
+            int c = 0;
+            while (!canon.isDone())
+            {
+                if (++c >= minCount)
+                    canon.close();
+
+                if (rnd.decide(runTaskChance - (runTaskChance/(1 + commandStore.queue.size()))))
+                    commandStore.runOneTask(safeStore);
+
+                CommandUpdate update = canon.update(!commandStore.queue.isEmpty());
+                if (update == null)
+                {
+                    commandStore.runOneTask(safeStore);
+                    continue;
+                }
+                TestSafeCommand safeCommand = new TestSafeCommand(update.next.txnId(), canon, update.next);
+
+                CommandsForKeyUpdate result;
+                if (CommandsForKey.manages(update.next.txnId()))
+                {
+                    CommandsForKey prev = safeCfk.current();
+                    result = prev.update(update.next);
+                    safeCfk.set(result.cfk());
+                    if (rnd.decide(pruneChance))
+                        safeCfk.set(safeCfk.current.maybePrune(pruneInterval, pruneHlcDelta));
+                    result.postProcess(safeStore, prev, update.next, canon);
+                }
+
+                if (!CommandsForKey.managesExecution(update.next.txnId()) && update.next.hasBeen(Status.Stable) && !update.next.hasBeen(Status.Truncated))
+                {
+                    CommandsForKey prev = safeCfk.current();
+                    result = prev.registerUnmanaged(safeStore, safeCommand, canon);
+                    safeCfk.set(result.cfk());
+                    result.postProcess(safeStore, prev, null, canon);
+                }
+            }
+        }
+        catch (Throwable t)
+        {
+            throw new AssertionError("Seed " + seed + " failed", t);
+        }
+    }
+
+
+    static class TestSafeCommand extends SafeCommand
+    {
+        final Canon canon;
+        Command current;
+        public TestSafeCommand(TxnId txnId, Canon canon, Command command)
+        {
+            super(txnId);
+            this.canon = canon;
+            current = command;
+        }
+
+        @Override
+        public Command current() { return current; }
+
+        @Override
+        public void invalidate() {}
+
+        @Override
+        public boolean invalidated() { return false; }
+
+        @Override
+        public void addListener(Command.TransientListener listener) {}
+
+        @Override
+        public boolean removeListener(Command.TransientListener listener) { return false; }
+
+        @Override
+        public Listeners<Command.TransientListener> transientListeners() { return null; }
+
+        @Override
+        protected void set(Command command)
+        {
+            canon.set(current, command);
+            current = command;
+        }
+    }
+
+    static class TestSafeCommandsForKey extends SafeCommandsForKey
+    {
+        CommandsForKey current;
+        public TestSafeCommandsForKey(CommandsForKey cfk)
+        {
+            super(cfk.key());
+            current = cfk;
+        }
+
+        @Override
+        public CommandsForKey current() { return current; }
+
+        @Override
+        protected void set(CommandsForKey command)
+        {
+            current = command;
+        }
+    }
+
+    static class TestSafeStore extends SafeCommandStore
+    {
+        final Canon canon;
+        final TestCommandStore commandStore;
+        final TestSafeCommandsForKey cfk;
+
+        TestSafeStore(Canon canon, TestCommandStore commandStore, TestSafeCommandsForKey cfk)
+        {
+            this.canon = canon;
+            this.commandStore = commandStore;
+            this.cfk = cfk;
+        }
+
+        @Override
+        protected SafeCommand getInternal(TxnId txnId)
+        {
+            return new TestSafeCommand(txnId, canon, canon.byId.get(txnId));
+        }
+
+        @Override
+        protected SafeCommand getInternalIfLoadedAndInitialised(TxnId txnId)
+        {
+            return getInternal(txnId);
+        }
+
+        @Override
+        protected SafeCommandsForKey getInternal(Key key)
+        {
+            Invariants.checkArgument(key.equals(cfk.key()));
+            return cfk;
+        }
+
+        @Override
+        public SafeCommand get(TxnId txnId, RoutingKey unseekable)
+        {
+            return getInternal(txnId);
+        }
+
+        @Override
+        public SafeCommand get(TxnId txnId)
+        {
+            return getInternal(txnId);
+        }
+
+        @Override
+        public SafeCommandsForKey get(Key key)
+        {
+            return getInternal(key);
+        }
+
+        @Override
+        public SafeCommand get(TxnId txnId, EpochSupplier toEpoch, Unseekables<?> unseekables)
+        {
+            return getInternal(txnId);
+        }
+
+        @Nullable
+        @Override
+        public SafeCommand ifInitialised(TxnId txnId)
+        {
+            return getInternal(txnId);
+        }
+
+        @Override
+        public SafeCommand ifLoadedAndInitialised(TxnId txnId)
+        {
+            if (txnId.compareTo(cfk.current.prunedBefore()) < 0)
+                return null;
+
+            return getInternal(txnId);
+        }
+
+        @Override
+        protected void update(Command prev, Command updated)
+        {
+        }
+
+        @Override
+        protected SafeCommandsForKey getInternalIfLoadedAndInitialised(Key key)
+        {
+            if (key.equals(cfk.key()))
+                return cfk;
+            return null;
+        }
+
+        @Override
+        public boolean canExecuteWith(PreLoadContext context)
+        {
+            return true;
+        }
+
+        @Override
+        public <P1, T> T mapReduceActive(Seekables<?, ?> keys, Ranges slice, @Nullable Timestamp withLowerTxnId, Txn.Kind.Kinds kinds, CommandFunction<P1, T, T> map, P1 p1, T initialValue)
+        {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public <P1, T> T mapReduceFull(Seekables<?, ?> keys, Ranges slice, TxnId testTxnId, Txn.Kind.Kinds testKind, TestStartedAt testStartedAt, TestDep testDep, TestStatus testStatus, CommandFunction<P1, T, T> map, P1 p1, T initialValue)
+        {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public CommandStore commandStore()
+        {
+            return commandStore;
+        }
+
+        @Override
+        public DataStore dataStore()
+        {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public Agent agent()
+        {
+            return commandStore;
+        }
+
+        @Override
+        public ProgressLog progressLog()
+        {
+            return new TestProgressLog();
+        }
+
+        @Override
+        public NodeTimeService time()
+        {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public CommandStores.RangesForEpoch ranges()
+        {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public void registerHistoricalTransactions(Deps deps)
+        {
+            throw new UnsupportedOperationException();
+        }
+    }
+
+    private static final class TestRead implements Read
+    {
+        final Seekables<?, ?> keys;
+        private TestRead(Seekables<?, ?> keys)
+        {
+            this.keys = keys;
+        }
+
+        @Override
+        public Seekables<?, ?> keys()
+        {
+            return keys;
+        }
+
+        @Override
+        public AsyncChain<Data> read(Seekable key, SafeCommandStore commandStore, Timestamp executeAt, DataStore store)
+        {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public Read slice(Ranges ranges)
+        {
+            return this;
+        }
+
+        @Override
+        public Read merge(Read other)
+        {
+            return this;
+        }
+    }
+
+    private static class TestQuery implements Query
+    {
+        @Override
+        public Result compute(@Nonnull TxnId txnId, @Nonnull Timestamp executeAt, @Nonnull Seekables<?, ?> keys, @Nullable Data data, @Nullable Read read, @Nullable Update update)
+        {
+            throw new UnsupportedOperationException();
+        }
+    }
+
+    private static class TestCommandStore extends CommandStore implements Agent
+    {
+        class Task extends AsyncResults.AbstractResult<Void>
+        {
+            final Consumer<? super SafeCommandStore> consumer;
+            Task(Consumer<? super SafeCommandStore> consumer)
+            {
+                this.consumer = consumer;
+            }
+
+            public void run(SafeCommandStore safeStore)
+            {
+                consumer.accept(safeStore);
+                trySetResult(null, null);
+            }
+        }
+
+        final int pruneInterval, pruneHlcDelta;
... 794 lines suppressed ...


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@cassandra.apache.org
For additional commands, e-mail: commits-help@cassandra.apache.org