diff --git a/docs/pki-server-acme.md b/docs/pki-server-acme.md
index 8b0f443..72046ab 100644
--- a/docs/pki-server-acme.md
+++ b/docs/pki-server-acme.md
@@ -8,7 +8,7 @@ An ACME directory is an immutable policy revision bound to one realm, logical au
Direct deployments use server-authenticated TLS. A client TLS certificate is not ACME account authority; ACME identity is the account key authenticated by JWS. Trusted-reverse-proxy deployments require the established mutually authenticated proxy-to-ZeroEcho TLS hop and a dedicated enabled proxy principal with `FORWARD_AUTHENTICATED_CLIENT_IDENTITY`. Forwarded administrative identity is not used as an ACME account. Source addresses and `Forwarded` or `X-Forwarded-*` headers never authorize ACME.
-The server configuration schema is version 5. ACME is disabled explicitly with:
+The server configuration schema is version 6. ACME is disabled explicitly with:
```json
"acmeListener": {"enabled": false}
diff --git a/docs/pki-server-admin-https.md b/docs/pki-server-admin-https.md
index 30baef0..bbd47b7 100644
--- a/docs/pki-server-admin-https.md
+++ b/docs/pki-server-admin-https.md
@@ -84,8 +84,23 @@ Responses are deterministic version-one JSON. They carry only safe typed results
Transport workers, transport backlog, concurrent operation workers, waiting operation capacity, admitted requests, request body size, deadlines, and both shutdown periods are finite configuration values. Saturation never runs a request on the caller thread and a rejected request never invokes the gateway.
+Configuration schema version 6 adds `signingReconciliation`. Its production
+defaults are enabled, 256 examined records, 64 provider calls, a 30-second pass
+timeout, a 10-second provider-call timeout, and a fixed 2-second idle interval.
+`maxRecords` is 1 through 4096, `maxCalls` is 1 through the record
+bound, all durations are positive, and the provider timeout cannot exceed the
+pass timeout. Version 5 configurations migrate to these defaults. Set `enabled`
+to `false` only when an external owner deliberately invokes the transport-neutral
+session reconciliation API.
+
Shutdown first removes readiness and admission, stops listener acceptance, drains for the configured graceful period, cooperatively cancels remaining work, waits only for the forced period, then closes the one realm context. Already committed PKI changes remain committed.
+Signing-provider deadlines and cancellation are cooperative for trusted
+in-process implementations. A provider call that remains live after forced
+shutdown causes `SIGNING_RECONCILIATION_SHUTDOWN_TIMEOUT`; the session, provider,
+and store stay open and `close` may be retried after the call returns. Hard
+isolation for non-cooperative providers requires an out-of-process boundary.
+
## Request correlation
Clients may supply `X-ZeroEcho-Request-Id` using 16–128 ASCII letters, digits, underscore, or hyphen. Otherwise the server creates a random opaque identifier. It is correlation metadata—not an idempotency key, object identity, approval, or capability token.
diff --git a/docs/pki-server-architecture.md b/docs/pki-server-architecture.md
index 4edff45..25f0ad1 100644
--- a/docs/pki-server-architecture.md
+++ b/docs/pki-server-architecture.md
@@ -243,9 +243,47 @@ retires advisory state before reporting that marker. The retained record does no
regain a live content reference. Store restart recovery uses authoritative live
references to reclaim the orphaned staged file safely.
+Each fully opened realm owns exactly one signing-reconciliation worker. The
+worker starts immediately before the server becomes ready, runs one synchronous
+bounded pass at a time, and self-schedules the next pass only after the current
+pass returns. It has no queue of missed ticks and creates no active-active lease;
+the existing realm, provider, and filesystem ownership locks remain the
+single-active authority.
+
+The reconciliation cursor is an advisory exclusive key into a metadata snapshot
+ordered by canonical submission identifier. A pass examines no more than its
+record bound, makes no more than its independent provider-call budget, and wraps
+to the beginning after reaching the end so deferred records cannot starve other
+records. Per-record failures are isolated and persisted separately from the
+provider detail code with a store-time retry schedule of 2, 4, 8, 16, then 30
+seconds. Terminal and retired records carry no retry metadata.
+
+Shutdown first stops reconciliation admission and scheduling, then cooperatively
+cancels and drains the active pass before closing the realm session, providers,
+or store. If a provider ignores cancellation beyond the forced-shutdown period,
+the server reports `SIGNING_RECONCILIATION_SHUTDOWN_TIMEOUT` and leaves those
+dependencies open so close can be retried without use-after-close behavior.
+
+`SignatureWorkflow` implementations are trusted in-process components and every
+submit, status, verify, and cancel call now requires a `CallControl` carrying an
+absolute deadline and cancellation signal. This is an intentionally breaking
+provider-SPI migration: implementations must check the control before and
+between I/O, before an externally visible effect, and before returning. These
+controls are cooperative bounds; the server does not kill provider threads or
+close their dependencies while a violating call remains live. Providers that
+need enforcement against untrusted or non-cooperative code require a future
+out-of-process provider boundary with process or RPC isolation.
+
Payload staging remains streaming `O(n)` time and `O(1)` aggregate auxiliary heap
excluding the signature. Synchronous waiting performs
-`O(TTL / polling interval)` status observations.
+`O(TTL / polling interval)` status observations. For `M` metadata keys and a
+bound `B`, an ordered reconciliation page is `O(log M + B)` traversal and each
+candidate point access is `O(log M)`, for `O(B log M)` candidate access in the
+worst case. Normal pass auxiliary memory is `O(B)`. A concurrent metadata commit
+may detach the currently pinned index generation once in `O(M)` time and memory;
+later commits do not copy again merely because an older generation remains
+pinned. A pass uses at most `maximumProviderCalls` external calls, and each realm
+owns `O(1)` scheduler state.
## 7. Authorization architecture
diff --git a/docs/pki-server-loopback-example.json b/docs/pki-server-loopback-example.json
index 2f1cfe1..c100b6a 100644
--- a/docs/pki-server-loopback-example.json
+++ b/docs/pki-server-loopback-example.json
@@ -1,5 +1,5 @@
{
- "version": 5,
+ "version": 6,
"serverName": "zeroecho-admin",
"realm": {
"realmId": "production",
@@ -74,6 +74,7 @@
"gracefulShutdownMillis": 30000,
"forcedShutdownMillis": 10000
},
+ "signingReconciliation": {"enabled": true, "maxRecords": 256, "maxCalls": 64, "passTimeoutMillis": 30000, "providerCallTimeoutMillis": 10000, "idleIntervalMillis": 2000},
"publicListener": {
"enabled": true,
"address": "127.0.0.1",
diff --git a/docs/pki-server-production-example.json b/docs/pki-server-production-example.json
index a7a5d0b..3dd2a60 100644
--- a/docs/pki-server-production-example.json
+++ b/docs/pki-server-production-example.json
@@ -1,5 +1,5 @@
{
- "version": 5,
+ "version": 6,
"serverName": "zeroecho-admin",
"realm": {
"realmId": "production",
@@ -74,6 +74,7 @@
"gracefulShutdownMillis": 30000,
"forcedShutdownMillis": 10000
},
+ "signingReconciliation": {"enabled": true, "maxRecords": 256, "maxCalls": 64, "passTimeoutMillis": 30000, "providerCallTimeoutMillis": 10000, "idleIntervalMillis": 2000},
"publicListener": {"enabled": false},
"acmeListener": {"enabled": false},
"runtime": {}
diff --git a/docs/pki-server-trusted-proxy-nginx-example.json b/docs/pki-server-trusted-proxy-nginx-example.json
index 3aaecd6..a8a5061 100644
--- a/docs/pki-server-trusted-proxy-nginx-example.json
+++ b/docs/pki-server-trusted-proxy-nginx-example.json
@@ -1,5 +1,5 @@
{
- "version": 5,
+ "version": 6,
"serverName": "zeroecho-admin-nginx",
"realm": {
"realmId": "production",
@@ -40,6 +40,7 @@
"maximumForwardedChainBytes": 524288
},
"execution": {"transportWorkers":8,"transportQueueCapacity":64,"operationWorkers":4,"operationQueueCapacity":32,"maximumAdmittedRequests":96,"defaultDeadlineMillis":30000,"maximumDeadlineMillis":120000,"gracefulShutdownMillis":30000,"forcedShutdownMillis":10000},
+ "signingReconciliation": {"enabled":true,"maxRecords":256,"maxCalls":64,"passTimeoutMillis":30000,"providerCallTimeoutMillis":10000,"idleIntervalMillis":2000},
"publicListener": {"enabled":false},
"acmeListener": {"enabled":false},
"runtime": {}
diff --git a/docs/pki-server-trusted-proxy-rfc9440-example.json b/docs/pki-server-trusted-proxy-rfc9440-example.json
index 3e6efdf..5855c29 100644
--- a/docs/pki-server-trusted-proxy-rfc9440-example.json
+++ b/docs/pki-server-trusted-proxy-rfc9440-example.json
@@ -1,5 +1,5 @@
{
- "version": 5,
+ "version": 6,
"serverName": "zeroecho-admin-proxy",
"realm": {
"realmId": "production",
@@ -41,6 +41,7 @@
"maximumForwardedChainBytes": 524288
},
"execution": {"transportWorkers":8,"transportQueueCapacity":64,"operationWorkers":4,"operationQueueCapacity":32,"maximumAdmittedRequests":96,"defaultDeadlineMillis":30000,"maximumDeadlineMillis":120000,"gracefulShutdownMillis":30000,"forcedShutdownMillis":10000},
+ "signingReconciliation": {"enabled":true,"maxRecords":256,"maxCalls":64,"passTimeoutMillis":30000,"providerCallTimeoutMillis":10000,"idleIntervalMillis":2000},
"publicListener": {
"enabled":true,
"address":"127.0.0.1",
diff --git a/pki-server/src/main/java/zeroecho/pki/server/PkiHttpsServer.java b/pki-server/src/main/java/zeroecho/pki/server/PkiHttpsServer.java
index 66e1182..debbe9b 100644
--- a/pki-server/src/main/java/zeroecho/pki/server/PkiHttpsServer.java
+++ b/pki-server/src/main/java/zeroecho/pki/server/PkiHttpsServer.java
@@ -70,6 +70,7 @@ public final class PkiHttpsServer implements AutoCloseable {
private final Optional 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:
*