CEP-15 Accord: NotWitnessed commands can receive an invalidate promise but would return Zero instead

patch by David Capwell; reviewed by Benedict Elliott Smith for CASSANDRA-18471
diff --git a/accord-core/src/main/java/accord/local/Command.java b/accord-core/src/main/java/accord/local/Command.java
index f8f6d00..54aec10 100644
--- a/accord-core/src/main/java/accord/local/Command.java
+++ b/accord-core/src/main/java/accord/local/Command.java
@@ -514,12 +514,6 @@
         }
 
         @Override
-        public Ballot promised()
-        {
-            return Ballot.ZERO;
-        }
-
-        @Override
         public Ballot accepted()
         {
             return Ballot.ZERO;
diff --git a/accord-core/src/main/java/accord/messages/BeginInvalidation.java b/accord-core/src/main/java/accord/messages/BeginInvalidation.java
index 1bb3ba2..d0d1c79 100644
--- a/accord-core/src/main/java/accord/messages/BeginInvalidation.java
+++ b/accord-core/src/main/java/accord/messages/BeginInvalidation.java
@@ -30,6 +30,7 @@
 import javax.annotation.Nullable;
 import java.util.Collections;
 import java.util.List;
+import java.util.Objects;
 
 import static accord.primitives.Route.castToFullRoute;
 import static accord.primitives.Route.isFullRoute;
@@ -145,6 +146,21 @@
         }
 
         @Override
+        public boolean equals(Object o)
+        {
+            if (this == o) return true;
+            if (o == null || getClass() != o.getClass()) return false;
+            InvalidateReply that = (InvalidateReply) o;
+            return acceptedFastPath == that.acceptedFastPath && Objects.equals(supersededBy, that.supersededBy) && Objects.equals(accepted, that.accepted) && status == that.status && Objects.equals(route, that.route) && Objects.equals(homeKey, that.homeKey);
+        }
+
+        @Override
+        public int hashCode()
+        {
+            return Objects.hash(supersededBy, accepted, status, acceptedFastPath, route, homeKey);
+        }
+
+        @Override
         public String toString()
         {
             return "Invalidate" + (isPromised() ? "Promised{" : "NotPromised{" + supersededBy + ",") + status + ',' + (route != null ? route: homeKey) + '}';
diff --git a/accord-core/src/test/java/accord/messages/PreAcceptTest.java b/accord-core/src/test/java/accord/messages/PreAcceptTest.java
index 29be803..c32c563 100644
--- a/accord-core/src/test/java/accord/messages/PreAcceptTest.java
+++ b/accord-core/src/test/java/accord/messages/PreAcceptTest.java
@@ -26,14 +26,9 @@
 import accord.impl.mock.*;
 import accord.local.Node;
 import accord.local.Node.Id;
-import accord.api.MessageSink;
-import accord.api.Scheduler;
 import accord.impl.mock.MockCluster.Clock;
 import accord.primitives.*;
 import accord.topology.Topology;
-import accord.utils.DefaultRandom;
-import accord.utils.EpochFunction;
-import accord.utils.ThreadPoolScheduler;
 import accord.local.*;
 
 import org.junit.jupiter.api.Assertions;
@@ -51,6 +46,7 @@
 import static accord.primitives.Routable.Domain.Key;
 import static accord.primitives.Txn.Kind.Write;
 import static accord.utils.Utils.listOf;
+import static org.assertj.core.api.Assertions.assertThat;
 
 public class PreAcceptTest
 {
@@ -137,6 +133,41 @@
     }
 
     @Test
+    void invalidatedTest()
+    {
+        RecordingMessageSink messageSink = new RecordingMessageSink(ID1, Network.BLACK_HOLE);
+        Clock clock = new Clock(100);
+        Node node = createNode(ID1, TOPOLOGY, messageSink, clock);
+        try
+        {
+            Raw key = IntKey.key(10);
+            CommandStore commandStore = node.unsafeForKey(key);
+            Assertions.assertFalse(inMemory(commandStore).hasCommandsForKey(key));
+
+            TxnId txnId = clock.idForNode(1, ID2);
+            Txn txn = writeTxn(Keys.of(key));
+
+            Unseekables<?, ?> invalidateWith = txn.keys().toUnseekables();
+            BeginInvalidation invalidate = new BeginInvalidation(ID1, node.topology().forEpoch(invalidateWith, txnId.epoch()), txnId, invalidateWith, Ballot.fromValues(txnId.epoch(), txnId.hlc(), txnId.node));
+            invalidate.process(node, ID2, REPLY_CONTEXT);
+
+            messageSink.assertHistorySizes(0, 1);
+            assertThat(messageSink.responses.get(0).payload).isEqualTo(new BeginInvalidation.InvalidateReply(null, Ballot.ZERO, Status.NotWitnessed, false, null, null));
+            messageSink.clearHistory();
+
+            PreAccept preAccept = preAccept(txnId, txn, key.toUnseekable());
+            preAccept.process(node, ID2, REPLY_CONTEXT);
+
+            messageSink.assertHistorySizes(0, 1);
+            assertThat(messageSink.responses.get(0).payload).isEqualTo(PreAccept.PreAcceptNack.INSTANCE);
+        }
+        finally
+        {
+            node.shutdown();
+        }
+    }
+
+    @Test
     void singleKeyTimestampUpdate()
     {
     }