From 0312cf699f2c31e35b4b4c844183ce56deb74839 Mon Sep 17 00:00:00 2001
From: Leo Galambos The session creates no scheduler. Callers own invocation cadence and must
+ * pass the opaque exclusive cursor returned by the preceding pass. A session
+ * without signing returns an empty, end-reached result.
* Once a downstream signer operation exists, the returned status is derived
- * from {@link SignatureWorkflow#status(PkiId)} using this mapping:
+ * from {@link SignatureWorkflow#status(PkiId, SignatureWorkflow.CallControl)}
+ * using this mapping:
*
*
Supported operations
*
- *
@@ -240,7 +240,9 @@ import zeroecho.sdk.ZeroEchoSession; *
*/ // The provider deliberately centralizes operation lifecycle and cleanup in one implementation. -@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.CyclomaticComplexity", "PMD.TooManyMethods" }) +@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.CyclomaticComplexity", "PMD.TooManyMethods", + "PMD.NcssCount", "PMD.CloseResource", "PMD.ExceptionAsFlowControl", + "PMD.AvoidCatchingGenericException" }) public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow, PublicKeyInfoSource { private static final Logger LOG = Logger.getLogger(ZeroEchoLibSignatureWorkflow.class.getName()); @@ -265,7 +267,8 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow, Pu private static final OperationStatus UNKNOWN_OPERATION_STATUS = new OperationStatus(State.FAILED, Instant.EPOCH, Optional.of(DC_UNKNOWN_OPERATION), Optional.empty()); - private static final int OPERATION_RECORD_VERSION = 4; + private static final int OPERATION_RECORD_VERSION = 5; + private static final int PREVIOUS_OPERATION_RECORD_VERSION = 4; private static final long MIN_FENCING_TOKEN = 1L; private final String id; @@ -281,6 +284,7 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow, Pu private final ConcurrentMap* Cancellation is serialized with completion for the same signing identifier, * validates the fencing token, and durably records {@link State#CANCELLED}. It - * succeeds only while the retained operation is non-terminal. + * A replay of an accepted cancellation with the same operation identifier and + * fence succeeds without another state change, regardless of its safe reason. *
* * @param operationId workflow operation identifier; must not be {@code null} @@ -800,7 +868,7 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow, Pu * {@code reason} is {@code null} or blank */ @Override - public boolean cancel(PkiId operationId, long fencingToken, String reason) { + public boolean cancel(PkiId operationId, long fencingToken, String reason, CallControl control) { if (operationId == null) { throw new IllegalArgumentException("operationId must not be null"); } @@ -810,18 +878,27 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow, Pu if (fencingToken < MIN_FENCING_TOKEN) { throw new IllegalArgumentException("fencingToken must be positive"); } + Objects.requireNonNull(control, "control").requireActive(now()); SignLockEntry entry = acquireOperationLock(operationId); OperationStatus cancelled = null; try { OperationStatus st = this.statuses.get(operationId); - if (st == null || st.isTerminal()) { + if (st == null) { + control.requireActive(now()); return false; } long currentFence = this.fences.getOrDefault(operationId, 0L); + if (st.isTerminal()) { + control.requireActive(now()); + return st.state() == State.CANCELLED && fencingToken == currentFence; + } if (fencingToken < currentFence) { + control.requireActive(now()); return false; } + control.requireActive(now()); this.fences.put(operationId, fencingToken); + this.cancellationReasons.putIfAbsent(operationId, cancellationReasonFingerprint(reason)); cancelled = new OperationStatus(State.CANCELLED, now(), Optional.of(DC_CANCELLED), Optional.empty()); statuses.put(operationId, cancelled); persistOperationRecord(operationId, cancelled); @@ -831,6 +908,7 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow, Pu notifySinks(operationId, cancelled); } } + control.requireActive(now()); return true; } @@ -889,6 +967,7 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow, Pu this.statuses.clear(); this.fingerprints.clear(); this.fences.clear(); + this.cancellationReasons.clear(); this.requests.clear(); this.sinks.clear(); if (keyring != null) { @@ -1200,7 +1279,7 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow, Pu for (NotificationSink sink : this.sinks.values()) { try { sink.onStatusChanged(id, st); - } catch (Throwable ignore) { // NOPMD + } catch (Throwable ignore) { // sink must not break provider logSafeFailure("CALLBACK", "NOTIFICATION_FAILED", ignore); } @@ -1304,6 +1383,7 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow, Pu requests.remove(operationId); fingerprints.remove(operationId); fences.remove(operationId); + cancellationReasons.remove(operationId); statuses.remove(operationId); Files.deleteIfExists(operationRecordPath(operationId)); } @@ -1362,13 +1442,16 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow, Pu try (java.util.stream.Stream* A {@code true} return value means only that the provider accepted the * cancellation request. It does not prove that the operation is terminal. - * Callers must re-read {@link #status(PkiId)} and may retire state only after + * Callers must re-read {@link #status(PkiId, CallControl)} and may retire state only after * an immutable terminal status is observed. *
* + *The operation identifier and fencing-token pair is idempotent across + * ambiguous acceptance and restart, regardless of the safe reason supplied on + * a replay. A provider may retain the first accepted reason for audit, but a + * replay with another reason must not cause another external effect. A lower + * fencing token must not mutate provider state, and every terminal state is + * immutable.
+ * * @param operationId operation id (never {@code null}) + * @param fencingToken monotonic fencing token * @param reason non-sensitive reason (never blank) - * @return true if cancellation was accepted; false if already terminal/unknown + * @param control cooperative call deadline and cancellation + * @return true if cancellation was accepted or the exact operation/fence pair + * replays an accepted cancellation; false if unknown, stale, superseded, + * or terminal for another outcome */ - boolean cancel(PkiId operationId, long fencingToken, String reason); + boolean cancel(PkiId operationId, long fencingToken, String reason, CallControl control); /** * Registers a notification sink for status changes. @@ -248,6 +262,34 @@ public interface SignatureWorkflow extends Closeable { @Override void close(); + /** + * Immutable cooperative deadline and cancellation control for one provider + * invocation. + * + * @param deadline absolute exclusive call deadline + * @param cancellation cooperative cancellation signal + */ + record CallControl(Instant deadline, CancellationSignal cancellation) { + /** Validates one call control. */ + public CallControl { + Objects.requireNonNull(deadline, "deadline"); + Objects.requireNonNull(cancellation, "cancellation"); + } + + /** + * Rejects work at or after the deadline or after cancellation. + * + * @param now authoritative current time + * @throws IllegalStateException when the call may no longer continue + */ + public void requireActive(Instant now) { + Objects.requireNonNull(now, "now"); + if (cancellation.isCancelled() || !now.isBefore(deadline)) { + throw new IllegalStateException("Signature workflow call is no longer active"); + } + } + } + /** * Signing request. * @@ -256,7 +298,7 @@ public interface SignatureWorkflow extends Closeable { * {@link IllegalArgumentException} at construction time. Callers building * requests for external transports are expected to catch such exceptions and * convert them to an appropriate error state before invoking - * {@link #submitSign(SignRequest)}. + * {@link #submitSign(SignRequest, CallControl)}. * * * @param submissionId stable caller-assigned submission @@ -401,7 +443,7 @@ public interface SignatureWorkflow extends Closeable { * {@link IllegalArgumentException} at construction time. Callers building * requests for external transports are expected to catch such exceptions and * convert them to an appropriate error state before invoking - * {@link #submitVerify(VerifyRequest)}. + * {@link #submitVerify(VerifyRequest, CallControl)}. * * * @param accessContext audit/governance context (never {@code null}) @@ -443,7 +485,7 @@ public interface SignatureWorkflow extends Closeable { * of an operation previously submitted via {@code submitSign} or * {@code submitVerify}. Providers must ensure that status transitions are * monotonic and observable through repeated calls to - * {@link SignatureWorkflow#status(PkiId)}. + * {@link SignatureWorkflow#status(PkiId, CallControl)}. * * *
* The callback must be treated as a best-effort notification mechanism and must
* not be relied upon as the sole source of truth; callers should always be able
- * to query the authoritative state via {@link SignatureWorkflow#status(PkiId)}.
+ * to query the authoritative state via
+ * {@link SignatureWorkflow#status(PkiId, CallControl)}.
* Delivery may be coalesced or dropped under load. Providers must not require
* callback processing to finish an operation, and sink implementations should
* return promptly without waiting for operation-level coordination.
diff --git a/pki/src/main/java/zeroecho/pki/spi/store/SignWorkflowStore.java b/pki/src/main/java/zeroecho/pki/spi/store/SignWorkflowStore.java
index ab1acf2..0a7cb6b 100644
--- a/pki/src/main/java/zeroecho/pki/spi/store/SignWorkflowStore.java
+++ b/pki/src/main/java/zeroecho/pki/spi/store/SignWorkflowStore.java
@@ -61,6 +61,18 @@ import zeroecho.pki.api.audit.Principal;
*/
public interface SignWorkflowStore {
+ /** Durable classification for one deferred reconciliation retry. */
+ enum ReconciliationFailureClass {
+ /** Submission may have been accepted. */
+ SUBMISSION_UNCERTAIN,
+ /** Provider status could not be obtained. */
+ STATUS_UNAVAILABLE,
+ /** Provider cancellation outcome is uncertain. */
+ CANCELLATION_UNCERTAIN,
+ /** Local durable reconciliation failed. */
+ LOCAL_FAILURE
+ }
+
/**
* Signing orchestration states.
*
@@ -135,7 +147,8 @@ public interface SignWorkflowStore {
record Record(PkiId submissionId, String namespace, String fingerprint, Principal owner, Instant createdAt,
Instant deadline, EncodedObject request, State state, long revision, long fence,
Optional
*
*/
@@ -136,7 +136,7 @@ public final class DurableOperatorApprovalSignatureWorkflow implements Signature
}
@Override
- public PkiId submitSign(SignRequest request) {
+ public PkiId submitSign(SignRequest request, CallControl control) {
Objects.requireNonNull(request, "request");
PkiId opId = request.submissionId();
@@ -160,7 +160,7 @@ public final class DurableOperatorApprovalSignatureWorkflow implements Signature
}
@Override
- public PkiId submitVerify(VerifyRequest request) {
+ public PkiId submitVerify(VerifyRequest request, CallControl control) {
Objects.requireNonNull(request, "request");
return new PkiId("verify-" + UUID.randomUUID());
}
@@ -194,7 +194,7 @@ public final class DurableOperatorApprovalSignatureWorkflow implements Signature
}
@Override
- public OperationStatus status(PkiId operationId) {
+ public OperationStatus status(PkiId operationId, CallControl control) {
Objects.requireNonNull(operationId, "operationId");
OperationStatus st = loadStatus(operationId);
@@ -256,7 +256,7 @@ public final class DurableOperatorApprovalSignatureWorkflow implements Signature
}
@Override
- public boolean cancel(PkiId operationId, long fencingToken, String reason) {
+ public boolean cancel(PkiId operationId, long fencingToken, String reason, CallControl control) {
Objects.requireNonNull(operationId, "operationId");
Objects.requireNonNull(reason, "reason");
if (fencingToken <= 0L) {
@@ -265,12 +265,12 @@ public final class DurableOperatorApprovalSignatureWorkflow implements Signature
if (reason.isBlank()) {
throw new IllegalArgumentException("reason must not be blank");
}
- if (!identities.acceptFence(operationId, fencingToken)) {
- return false;
- }
-
OperationStatus st = loadStatus(operationId);
if (st.isTerminal()) {
+ return st.state() == State.CANCELLED
+ && identities.fence(operationId).orElse(-1L) == fencingToken;
+ }
+ if (!identities.acceptFence(operationId, fencingToken)) {
return false;
}
diff --git a/pki/src/test/java/zeroecho/pki/testkit/InMemorySignatureWorkflow.java b/pki/src/test/java/zeroecho/pki/testkit/InMemorySignatureWorkflow.java
index 0c022df..0aff47b 100644
--- a/pki/src/test/java/zeroecho/pki/testkit/InMemorySignatureWorkflow.java
+++ b/pki/src/test/java/zeroecho/pki/testkit/InMemorySignatureWorkflow.java
@@ -86,7 +86,7 @@ public final class InMemorySignatureWorkflow implements SignatureWorkflow, Publi
}
@Override
- public PkiId submitSign(SignRequest request) {
+ public PkiId submitSign(SignRequest request, CallControl control) {
Objects.requireNonNull(request, "request");
PkiId opId = request.submissionId();
Object lock = operationLocks.computeIfAbsent(opId, ignored -> new Object());
@@ -194,7 +194,7 @@ public final class InMemorySignatureWorkflow implements SignatureWorkflow, Publi
}
@Override
- public PkiId submitVerify(VerifyRequest request) {
+ public PkiId submitVerify(VerifyRequest request, CallControl control) {
Objects.requireNonNull(request, "request");
PkiId opId = new PkiId("verify:" + (counter++));
OperationStatus st = new OperationStatus(State.FAILED, Instant.now(), Optional.of("UNSUPPORTED"),
@@ -204,7 +204,7 @@ public final class InMemorySignatureWorkflow implements SignatureWorkflow, Publi
}
@Override
- public OperationStatus status(PkiId operationId) {
+ public OperationStatus status(PkiId operationId, CallControl control) {
Objects.requireNonNull(operationId, "operationId");
OperationStatus st = status.get(operationId);
if (st == null) {
@@ -214,7 +214,7 @@ public final class InMemorySignatureWorkflow implements SignatureWorkflow, Publi
}
@Override
- public boolean cancel(PkiId operationId, long fencingToken, String reason) {
+ public boolean cancel(PkiId operationId, long fencingToken, String reason, CallControl control) {
Objects.requireNonNull(operationId, "operationId");
Objects.requireNonNull(reason, "reason");
if (fencingToken <= 0L) {
@@ -223,7 +223,14 @@ public final class InMemorySignatureWorkflow implements SignatureWorkflow, Publi
Object lock = operationLocks.computeIfAbsent(operationId, ignored -> new Object());
synchronized (lock) {
OperationStatus existing = status.get(operationId);
- if (existing == null || existing.isTerminal() || fencingToken < fences.get(operationId)) {
+ if (existing == null) {
+ return false;
+ }
+ long currentFence = fences.get(operationId);
+ if (existing.isTerminal()) {
+ return existing.state() == State.CANCELLED && fencingToken == currentFence;
+ }
+ if (fencingToken < currentFence) {
return false;
}
fences.put(operationId, fencingToken);
diff --git a/pki/src/test/java/zeroecho/pki/testkit/OperatorApprovalSignatureWorkflow.java b/pki/src/test/java/zeroecho/pki/testkit/OperatorApprovalSignatureWorkflow.java
index 12f4db7..9bb347e 100644
--- a/pki/src/test/java/zeroecho/pki/testkit/OperatorApprovalSignatureWorkflow.java
+++ b/pki/src/test/java/zeroecho/pki/testkit/OperatorApprovalSignatureWorkflow.java
@@ -68,7 +68,7 @@ import zeroecho.pki.spi.crypto.SignatureWorkflow;
* The approval flow is intentionally explicit:
*