Date: Tue, 11 Aug 2026 09:20:36 +0900
Subject: [PATCH 14/18] fix(etl): treat cancelled jobs as polling terminal
---
.../xtrmetl/etl/controller/EtlJobPollingAdvice.java | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/etl-service/src/main/java/com/xtrmetl/etl/controller/EtlJobPollingAdvice.java b/etl-service/src/main/java/com/xtrmetl/etl/controller/EtlJobPollingAdvice.java
index 3973f581..998139e5 100644
--- a/etl-service/src/main/java/com/xtrmetl/etl/controller/EtlJobPollingAdvice.java
+++ b/etl-service/src/main/java/com/xtrmetl/etl/controller/EtlJobPollingAdvice.java
@@ -20,9 +20,9 @@
*
* The advice is scoped to {@link EtlJobController}. When the durable worker is enabled, it
* derives a positive whole-second {@code Retry-After} value from the validated fixed delay and
- * rounds any fractional second upward. Pending and running jobs advertise that cadence; succeeded
- * and failed jobs remove the header because their state is terminal. A disabled worker also removes
- * the header so maintenance-mode intake does not imply that execution is progressing.
+ * rounds any fractional second upward. Pending and running jobs advertise that cadence; succeeded,
+ * failed, and cancelled jobs remove the header because their state is terminal. A disabled worker
+ * also removes the header so maintenance-mode intake does not imply that execution is progressing.
*
* The header is only client guidance. PostgreSQL lease fencing, principal-scoped authorization,
* lifecycle validation, rate limiting, and client-side backoff remain independent correctness and
@@ -124,7 +124,9 @@ public Object beforeBodyWrite(
response.getHeaders().remove(HttpHeaders.RETRY_AFTER);
}
}
- case SUCCEEDED, FAILED -> response.getHeaders().remove(HttpHeaders.RETRY_AFTER);
+ case SUCCEEDED, FAILED, CANCELLED -> response.getHeaders().remove(
+ HttpHeaders.RETRY_AFTER
+ );
}
}
return body;
From 15db978eee8649116ac7f9203482813175ddd6d9 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 11 Aug 2026 09:22:14 +0900
Subject: [PATCH 15/18] test(etl): prove concurrent cancellation convergence
---
...ancellationConcurrencyIntegrationTest.java | 257 ++++++++++++++++++
1 file changed, 257 insertions(+)
create mode 100644 etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobCancellationConcurrencyIntegrationTest.java
diff --git a/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobCancellationConcurrencyIntegrationTest.java b/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobCancellationConcurrencyIntegrationTest.java
new file mode 100644
index 00000000..86090241
--- /dev/null
+++ b/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobCancellationConcurrencyIntegrationTest.java
@@ -0,0 +1,257 @@
+package com.xtrmetl.etl.job;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.xtrmetl.etl.service.EtlBatchProperties;
+import com.xtrmetl.etl.service.EtlRequestError;
+import com.xtrmetl.etl.service.EtlRequestException;
+import com.xtrmetl.etl.service.EtlRequestLock;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.DataSourceTransactionManager;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType;
+import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
+import org.springframework.transaction.PlatformTransactionManager;
+import org.springframework.transaction.annotation.EnableTransactionManagement;
+
+import javax.sql.DataSource;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Proves concurrent owner cancellation requests converge on one authoritative terminal transition.
+ */
+@SpringJUnitConfig(EtlJobCancellationConcurrencyIntegrationTest.TestConfiguration.class)
+class EtlJobCancellationConcurrencyIntegrationTest {
+
+ private static final String PAYLOAD = "[{\"id\":\"record_alpha\"}]";
+ private static final String SUBMISSION_KEY = "550e8400-e29b-41d4-a716-446655440000";
+ private static final String CANCELLATION_KEY = "70dc8b50-e8b2-4e1a-8c5f-d84814708a77";
+ private static final String OTHER_CANCELLATION_KEY =
+ "a52b165f-9d45-4399-ae84-1e93e8fe1e68";
+
+ private final EtlJobService jobService;
+ private final JdbcTemplate jdbcTemplate;
+ private final ExecutorService executor = Executors.newFixedThreadPool(2);
+
+ @Autowired
+ EtlJobCancellationConcurrencyIntegrationTest(
+ EtlJobService jobService,
+ JdbcTemplate jdbcTemplate
+ ) {
+ this.jobService = jobService;
+ this.jdbcTemplate = jdbcTemplate;
+ }
+
+ @BeforeEach
+ void createJobTable() {
+ jdbcTemplate.execute("DROP TABLE IF EXISTS etl_job_records");
+ jdbcTemplate.execute("""
+ CREATE TABLE etl_job_records (
+ job_record_id UUID PRIMARY KEY,
+ principal_scope_hash CHAR(64) NOT NULL,
+ submission_key_hash CHAR(64) NOT NULL,
+ request_digest CHAR(64) NOT NULL,
+ request_payload CLOB,
+ job_status VARCHAR(32) NOT NULL,
+ attempt_count INTEGER NOT NULL DEFAULT 0,
+ failure_code VARCHAR(128),
+ lease_claim_id UUID,
+ lease_owner_id VARCHAR(128),
+ lease_expires_at TIMESTAMP WITH TIME ZONE,
+ cancellation_key_hash CHAR(64),
+ cancellation_code VARCHAR(128),
+ job_cancelled_at TIMESTAMP WITH TIME ZONE,
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT etl_job_submission_scope_unique
+ UNIQUE (principal_scope_hash, submission_key_hash)
+ )
+ """);
+ }
+
+ @AfterEach
+ void stopExecutor() throws InterruptedException {
+ executor.shutdownNow();
+ assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS));
+ }
+
+ @Test
+ void concurrentIdenticalRequestsProduceOneTransitionAndOneReplay() throws Exception {
+ UUID jobRecordId = submitPendingJob();
+ List> futures = startTogether(
+ jobRecordId,
+ List.of(CANCELLATION_KEY, CANCELLATION_KEY)
+ );
+
+ List results = List.of(
+ futures.get(0).get(10, TimeUnit.SECONDS),
+ futures.get(1).get(10, TimeUnit.SECONDS)
+ );
+
+ assertEquals(1, results.stream().filter(result -> !result.replayed()).count());
+ assertEquals(1, results.stream().filter(EtlJobCancellation::replayed).count());
+ assertTrue(results.stream().allMatch(
+ result -> result.snapshot().jobStatus() == EtlJobStatus.CANCELLED
+ ));
+ assertEquals(1, cancelledRowCount(jobRecordId));
+ }
+
+ @Test
+ void concurrentDifferentKeysProduceOneTransitionAndOneStableConflict() throws Exception {
+ UUID jobRecordId = submitPendingJob();
+ List> futures = startTogether(
+ jobRecordId,
+ List.of(CANCELLATION_KEY, OTHER_CANCELLATION_KEY)
+ );
+
+ int successes = 0;
+ int keyConflicts = 0;
+ for (Future future : futures) {
+ try {
+ EtlJobCancellation result = future.get(10, TimeUnit.SECONDS);
+ assertFalse(result.replayed());
+ successes++;
+ } catch (ExecutionException exception) {
+ Throwable cause = exception.getCause();
+ EtlRequestException requestException = assertThrows(
+ EtlRequestException.class,
+ () -> {
+ throw cause;
+ }
+ );
+ assertEquals(
+ EtlRequestError.JOB_CANCELLATION_KEY_REUSED,
+ requestException.error()
+ );
+ keyConflicts++;
+ }
+ }
+
+ assertEquals(1, successes);
+ assertEquals(1, keyConflicts);
+ assertEquals(1, cancelledRowCount(jobRecordId));
+ }
+
+ private UUID submitPendingJob() {
+ return jobService.submit(PAYLOAD, SUBMISSION_KEY, "tenant_alpha").jobRecordId();
+ }
+
+ private List> startTogether(
+ UUID jobRecordId,
+ List cancellationKeys
+ ) throws InterruptedException {
+ CountDownLatch ready = new CountDownLatch(cancellationKeys.size());
+ CountDownLatch start = new CountDownLatch(1);
+ List> futures = new ArrayList<>();
+ for (String cancellationKey : cancellationKeys) {
+ futures.add(executor.submit(() -> {
+ ready.countDown();
+ if (!start.await(5, TimeUnit.SECONDS)) {
+ throw new IllegalStateException("concurrent cancellation start timed out");
+ }
+ return jobService.cancelOwned(
+ jobRecordId,
+ cancellationKey,
+ "tenant_alpha"
+ );
+ }));
+ }
+ assertTrue(ready.await(5, TimeUnit.SECONDS));
+ start.countDown();
+ return List.copyOf(futures);
+ }
+
+ private int cancelledRowCount(UUID jobRecordId) {
+ Integer count = jdbcTemplate.queryForObject(
+ """
+ SELECT COUNT(*)
+ FROM etl_job_records
+ WHERE job_record_id = ?
+ AND job_status = 'CANCELLED'
+ AND request_payload IS NULL
+ AND lease_claim_id IS NULL
+ AND lease_owner_id IS NULL
+ AND lease_expires_at IS NULL
+ AND cancellation_key_hash IS NOT NULL
+ AND cancellation_code = ?
+ AND job_cancelled_at IS NOT NULL
+ """,
+ Integer.class,
+ jobRecordId,
+ EtlJobService.CANCELLED_BY_OWNER_CODE
+ );
+ return count == null ? 0 : count;
+ }
+
+ /** Minimal transaction-enabled context for concurrent cancellation integration. */
+ @Configuration
+ @EnableTransactionManagement
+ static class TestConfiguration {
+
+ @Bean
+ DataSource dataSource() {
+ return new EmbeddedDatabaseBuilder()
+ .generateUniqueName(true)
+ .setType(EmbeddedDatabaseType.H2)
+ .build();
+ }
+
+ @Bean
+ JdbcTemplate jdbcTemplate(DataSource dataSource) {
+ return new JdbcTemplate(dataSource);
+ }
+
+ @Bean
+ PlatformTransactionManager transactionManager(DataSource dataSource) {
+ return new DataSourceTransactionManager(dataSource);
+ }
+
+ @Bean
+ ObjectMapper objectMapper() {
+ return new ObjectMapper();
+ }
+
+ @Bean
+ EtlBatchProperties etlBatchProperties() {
+ return new EtlBatchProperties();
+ }
+
+ @Bean
+ EtlRequestLock etlRequestLock() {
+ return lockHash -> true;
+ }
+
+ @Bean
+ EtlJobService etlJobService(
+ JdbcTemplate jdbcTemplate,
+ ObjectMapper objectMapper,
+ EtlBatchProperties batchProperties,
+ EtlRequestLock requestLock
+ ) {
+ return new EtlJobService(
+ jdbcTemplate,
+ objectMapper,
+ batchProperties,
+ requestLock
+ );
+ }
+ }
+}
From 1f86c4e438bc2b653a829183b2989e562fa698ed Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 11 Aug 2026 09:26:22 +0900
Subject: [PATCH 16/18] test(etl): prove cancellation key domain separation
---
...bCancellationKeyDomainIntegrationTest.java | 209 ++++++++++++++++++
1 file changed, 209 insertions(+)
create mode 100644 etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobCancellationKeyDomainIntegrationTest.java
diff --git a/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobCancellationKeyDomainIntegrationTest.java b/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobCancellationKeyDomainIntegrationTest.java
new file mode 100644
index 00000000..7a457de1
--- /dev/null
+++ b/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobCancellationKeyDomainIntegrationTest.java
@@ -0,0 +1,209 @@
+package com.xtrmetl.etl.job;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.xtrmetl.etl.service.EtlBatchProperties;
+import com.xtrmetl.etl.service.EtlRequestLock;
+import com.xtrmetl.etl.service.Sha256Digest;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.DataSourceTransactionManager;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType;
+import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
+import org.springframework.transaction.PlatformTransactionManager;
+import org.springframework.transaction.annotation.EnableTransactionManagement;
+
+import javax.sql.DataSource;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Proves durable cancellation idempotency hashes are purpose-, owner-, and job-bound without
+ * persisting the raw cancellation key.
+ */
+@SpringJUnitConfig(EtlJobCancellationKeyDomainIntegrationTest.TestConfiguration.class)
+class EtlJobCancellationKeyDomainIntegrationTest {
+
+ private static final String CANCELLATION_DOMAIN =
+ "mightyetl:durable-job-cancellation:v1:";
+ private static final String PAYLOAD = "[{\"id\":\"record_alpha\"}]";
+ private static final String CANCELLATION_KEY =
+ "70dc8b50-e8b2-4e1a-8c5f-d84814708a77";
+ private static final String SUBMISSION_KEY_ALPHA =
+ "550e8400-e29b-41d4-a716-446655440000";
+ private static final String SUBMISSION_KEY_BETA =
+ "1d38ad67-48d8-446c-bca1-76bfe2ba8eef";
+ private static final String SUBMISSION_KEY_OTHER_OWNER =
+ "11cf0982-4fe9-4a35-b981-58390596163f";
+
+ private final EtlJobService jobService;
+ private final JdbcTemplate jdbcTemplate;
+
+ @Autowired
+ EtlJobCancellationKeyDomainIntegrationTest(
+ EtlJobService jobService,
+ JdbcTemplate jdbcTemplate
+ ) {
+ this.jobService = jobService;
+ this.jdbcTemplate = jdbcTemplate;
+ }
+
+ @BeforeEach
+ void createJobTable() {
+ jdbcTemplate.execute("DROP TABLE IF EXISTS etl_job_records");
+ jdbcTemplate.execute("""
+ CREATE TABLE etl_job_records (
+ job_record_id UUID PRIMARY KEY,
+ principal_scope_hash CHAR(64) NOT NULL,
+ submission_key_hash CHAR(64) NOT NULL,
+ request_digest CHAR(64) NOT NULL,
+ request_payload CLOB,
+ job_status VARCHAR(32) NOT NULL,
+ attempt_count INTEGER NOT NULL DEFAULT 0,
+ failure_code VARCHAR(128),
+ lease_claim_id UUID,
+ lease_owner_id VARCHAR(128),
+ lease_expires_at TIMESTAMP WITH TIME ZONE,
+ cancellation_key_hash CHAR(64),
+ cancellation_code VARCHAR(128),
+ job_cancelled_at TIMESTAMP WITH TIME ZONE,
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT etl_job_submission_scope_unique
+ UNIQUE (principal_scope_hash, submission_key_hash)
+ )
+ """);
+ }
+
+ @Test
+ void bindsTheSameRawCancellationKeyToItsPurposeOwnerAndJob() {
+ UUID alphaJob = submit(SUBMISSION_KEY_ALPHA, "tenant_alpha");
+ UUID betaJob = submit(SUBMISSION_KEY_BETA, "tenant_alpha");
+ UUID otherOwnerJob = submit(SUBMISSION_KEY_OTHER_OWNER, "tenant_beta");
+
+ jobService.cancelOwned(alphaJob, CANCELLATION_KEY, "tenant_alpha");
+ jobService.cancelOwned(betaJob, CANCELLATION_KEY, "tenant_alpha");
+ jobService.cancelOwned(otherOwnerJob, CANCELLATION_KEY, "tenant_beta");
+
+ String alphaHash = cancellationHash(alphaJob);
+ String betaHash = cancellationHash(betaJob);
+ String otherOwnerHash = cancellationHash(otherOwnerJob);
+
+ assertEquals(expectedHash("tenant_alpha", alphaJob), alphaHash);
+ assertEquals(expectedHash("tenant_alpha", betaJob), betaHash);
+ assertEquals(expectedHash("tenant_beta", otherOwnerJob), otherOwnerHash);
+ assertNotEquals(alphaHash, betaHash);
+ assertNotEquals(alphaHash, otherOwnerHash);
+ assertNotEquals(betaHash, otherOwnerHash);
+ assertNotEquals(CANCELLATION_KEY, alphaHash);
+ assertEquals(64, alphaHash.length());
+ }
+
+ @Test
+ void normalizesQuotedAndLegacyRawKeysToOneReplayIdentity() {
+ UUID jobRecordId = submit(SUBMISSION_KEY_ALPHA, "tenant_alpha");
+
+ EtlJobCancellation first = jobService.cancelOwned(
+ jobRecordId,
+ CANCELLATION_KEY,
+ "tenant_alpha"
+ );
+ String firstHash = cancellationHash(jobRecordId);
+ EtlJobCancellation replay = jobService.cancelOwned(
+ jobRecordId,
+ "\"" + CANCELLATION_KEY + "\"",
+ "tenant_alpha"
+ );
+
+ assertFalse(first.replayed());
+ assertTrue(replay.replayed());
+ assertEquals(first.snapshot(), replay.snapshot());
+ assertEquals(firstHash, cancellationHash(jobRecordId));
+ }
+
+ private UUID submit(String submissionKey, String principalScope) {
+ return jobService.submit(PAYLOAD, submissionKey, principalScope).jobRecordId();
+ }
+
+ private String cancellationHash(UUID jobRecordId) {
+ return jdbcTemplate.queryForObject(
+ "SELECT cancellation_key_hash FROM etl_job_records WHERE job_record_id = ?",
+ String.class,
+ jobRecordId
+ );
+ }
+
+ private static String expectedHash(String principalScope, UUID jobRecordId) {
+ String principalScopeHash = Sha256Digest.digest(principalScope);
+ return Sha256Digest.digest(
+ CANCELLATION_DOMAIN
+ + principalScopeHash
+ + ":"
+ + jobRecordId
+ + ":"
+ + CANCELLATION_KEY
+ );
+ }
+
+ /** Minimal transaction-enabled context for cancellation-domain integration tests. */
+ @Configuration
+ @EnableTransactionManagement
+ static class TestConfiguration {
+
+ @Bean
+ DataSource dataSource() {
+ return new EmbeddedDatabaseBuilder()
+ .generateUniqueName(true)
+ .setType(EmbeddedDatabaseType.H2)
+ .build();
+ }
+
+ @Bean
+ JdbcTemplate jdbcTemplate(DataSource dataSource) {
+ return new JdbcTemplate(dataSource);
+ }
+
+ @Bean
+ PlatformTransactionManager transactionManager(DataSource dataSource) {
+ return new DataSourceTransactionManager(dataSource);
+ }
+
+ @Bean
+ ObjectMapper objectMapper() {
+ return new ObjectMapper();
+ }
+
+ @Bean
+ EtlBatchProperties etlBatchProperties() {
+ return new EtlBatchProperties();
+ }
+
+ @Bean
+ EtlRequestLock etlRequestLock() {
+ return lockHash -> true;
+ }
+
+ @Bean
+ EtlJobService etlJobService(
+ JdbcTemplate jdbcTemplate,
+ ObjectMapper objectMapper,
+ EtlBatchProperties batchProperties,
+ EtlRequestLock requestLock
+ ) {
+ return new EtlJobService(
+ jdbcTemplate,
+ objectMapper,
+ batchProperties,
+ requestLock
+ );
+ }
+ }
+}
From 7c53c8fd8e8b317a263652149742a56395dd01a0 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 11 Aug 2026 09:35:50 +0900
Subject: [PATCH 17/18] test(etl): restore cancellation documentation contract
---
...rableJobCancellationDocumentationTest.java | 121 ++++++++++++++++++
1 file changed, 121 insertions(+)
create mode 100644 etl-service/src/test/java/com/xtrmetl/etl/documentation/DurableJobCancellationDocumentationTest.java
diff --git a/etl-service/src/test/java/com/xtrmetl/etl/documentation/DurableJobCancellationDocumentationTest.java b/etl-service/src/test/java/com/xtrmetl/etl/documentation/DurableJobCancellationDocumentationTest.java
new file mode 100644
index 00000000..6ff9d0f4
--- /dev/null
+++ b/etl-service/src/test/java/com/xtrmetl/etl/documentation/DurableJobCancellationDocumentationTest.java
@@ -0,0 +1,121 @@
+package com.xtrmetl.etl.documentation;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Keeps the durable cancellation API, race contract, replay identity, rollout, rollback, and
+ * changelog aligned.
+ */
+class DurableJobCancellationDocumentationTest {
+
+ @Test
+ void operationsRunbookDocumentsTheAuthoritativeCancellationContract() throws IOException {
+ String runbook = read("docs/operations/durable-job-cancellation.md")
+ .replaceAll("\\s+", " ");
+
+ assertTrue(runbook.contains("POST /api/etl/jobs/{job_record_id}/cancellation"));
+ assertTrue(runbook.contains("Idempotency-Replayed: false"));
+ assertTrue(runbook.contains("Idempotency-Replayed: true"));
+ assertTrue(runbook.contains("etl_job_cancellation_key_reused"));
+ assertTrue(runbook.contains("etl_job_already_succeeded"));
+ assertTrue(runbook.contains("etl_job_already_failed"));
+ assertTrue(runbook.contains("Cancellation commits first"));
+ assertTrue(runbook.contains("Success commits first"));
+ assertTrue(runbook.contains("StaleEtlJobLeaseException"));
+ assertTrue(runbook.contains(
+ "rolls back its target and `etl_idempotency_records` writes"
+ ));
+ assertTrue(runbook.contains("V6__add_etl_job_cancellation.sql"));
+ assertTrue(runbook.contains("mightyetl.etl.jobs.intake-enabled=false"));
+ assertTrue(runbook.contains("does not claim arbitrary external side-effect reversal"));
+ assertTrue(runbook.contains(
+ "Never silently map `CANCELLED` to `FAILED` or `SUCCEEDED`"
+ ));
+ assertTrue(runbook.contains("RFC 9110"));
+ assertTrue(runbook.contains("RFC 9457"));
+ assertTrue(runbook.contains("PostgreSQL Global Development Group. (2026)"));
+ }
+
+ @Test
+ void designAndPlanKeepDatabaseAuthorityAndVerificationExplicit() throws IOException {
+ String design = read(
+ "docs/superpowers/specs/2026-08-06-durable-job-cancellation-design.md"
+ ).replaceAll("\\s+", " ");
+ String plan = read(
+ "docs/superpowers/plans/2026-08-06-durable-job-cancellation.md"
+ ).replaceAll("\\s+", " ");
+
+ assertTrue(design.contains("One conditional `UPDATE` is the cancellation authority"));
+ assertTrue(design.contains("Exactly one terminal outcome wins"));
+ assertTrue(design.contains("same-key replay"));
+ assertTrue(design.contains("transactional target effects"));
+ assertTrue(plan.contains("Added production statement and branch coverage remains 100%"));
+ assertTrue(plan.contains("No project test may be skipped"));
+ assertTrue(plan.contains("Run all verification"));
+ }
+
+ @Test
+ void doctoringDocumentsDomainSeparatedReplayIdentityAndCompatibility() throws IOException {
+ String evidence = read(
+ "docs/doctoring/durable-job-cancellation-key-domain-separation.md"
+ ).replaceAll("\\s+", " ");
+
+ assertTrue(evidence.contains("mightyetl:durable-job-cancellation:v1:"));
+ assertTrue(evidence.contains("principal_scope_hash"));
+ assertTrue(evidence.contains("job_record_id"));
+ assertTrue(evidence.contains("normalized_cancellation_key"));
+ assertTrue(evidence.contains("same normalized raw key"));
+ assertTrue(evidence.contains("another job in the same principal namespace"));
+ assertTrue(evidence.contains("another principal namespace"));
+ assertTrue(evidence.contains("EtlJobCancellationKeyDomainIntegrationTest"));
+ assertTrue(evidence.contains("Changing it would make every existing cancelled row fail"));
+ assertTrue(evidence.contains("does not claim to use cSHAKE"));
+ assertTrue(evidence.contains("NIST Special Publication 800-185"));
+ }
+
+ @Test
+ void changelogRecordsTheBuyerVisibleCancellationSlice() throws IOException {
+ String changelog = read("CHANGELOG.md").replaceAll("\\s+", " ");
+
+ assertTrue(changelog.contains("Owner-scoped durable-job cancellation"));
+ assertTrue(changelog.contains("CANCELLED"));
+ assertTrue(changelog.contains("cancellation_key_hash"));
+ assertTrue(changelog.contains("Cancellation-first"));
+ assertTrue(changelog.contains("transactional target and response-ledger effects"));
+ }
+
+ private static String read(String relativePath) throws IOException {
+ return Files.readString(projectRoot().resolve(relativePath), StandardCharsets.UTF_8);
+ }
+
+ /**
+ * Finds the reactor root from repository-root or module-scoped Maven execution.
+ *
+ * @return repository root containing the Maven reactor
+ */
+ private static Path projectRoot() {
+ Path current = Paths.get(System.getProperty("user.dir")).toAbsolutePath();
+ Path lastPomParent = null;
+ while (current != null) {
+ if (Files.exists(current.resolve(".git"))) {
+ return current;
+ }
+ if (Files.exists(current.resolve("pom.xml"))) {
+ lastPomParent = current;
+ }
+ current = current.getParent();
+ }
+ if (lastPomParent != null) {
+ return lastPomParent;
+ }
+ throw new IllegalStateException("Could not find project root");
+ }
+}
From 1fd2691dde1ebfd235c53703e6018eee11249f04 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 11 Aug 2026 09:39:20 +0900
Subject: [PATCH 18/18] docs(etl): restore cancellation operating evidence
---
CHANGELOG.md | 1 +
...-job-cancellation-key-domain-separation.md | 88 +++++++
docs/operations/durable-job-cancellation.md | 244 ++++++++++++++++++
.../2026-08-06-durable-job-cancellation.md | 91 +++++++
...6-08-06-durable-job-cancellation-design.md | 99 +++++++
5 files changed, 523 insertions(+)
create mode 100644 docs/doctoring/durable-job-cancellation-key-domain-separation.md
create mode 100644 docs/operations/durable-job-cancellation.md
create mode 100644 docs/superpowers/plans/2026-08-06-durable-job-cancellation.md
create mode 100644 docs/superpowers/specs/2026-08-06-durable-job-cancellation-design.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c5e12622..68e92d3b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
+- Owner-scoped durable-job cancellation now commits an idempotent `CANCELLED` terminal state through one principal-bound conditional update, stores the domain-separated `cancellation_key_hash` without raw identity, clears payload and lease state, invalidates stale workers, removes cancelled-job polling guidance, and exposes a no-store authenticated cancellation resource. Cancellation-first races roll back transactional target and response-ledger effects; external non-transactional side effects remain outside this guarantee.
- Owner-scoped durable-job status responses now emit deterministic weak SHA-256 `ETag` validators; ordinary and wildcard `If-None-Match` requests return an empty RFC 9110 `304 Not Modified` response only after authenticated owner-safe lookup, while `Cache-Control: no-store` remains unchanged.
- Active durable-job status responses now emit an RFC 9110 `Retry-After` delay for `PENDING` and `RUNNING` states only when local worker execution is enabled, derived from the bounded worker fixed-delay configuration with upward whole-second rounding; terminal states and intake-only maintenance mode omit the advisory.
- Durable job operators can now list only their own jobs through bounded newest-first keyset pagination with canonical opaque cursors, deterministic timestamp-plus-UUID ordering, `Cache-Control: no-store`, and RFC 8288 next-page links without offset drift or cross-tenant existence leakage.
diff --git a/docs/doctoring/durable-job-cancellation-key-domain-separation.md b/docs/doctoring/durable-job-cancellation-key-domain-separation.md
new file mode 100644
index 00000000..498572f0
--- /dev/null
+++ b/docs/doctoring/durable-job-cancellation-key-domain-separation.md
@@ -0,0 +1,88 @@
+# Durable-job cancellation replay identity domain separation
+
+## Decision
+
+mightyETL never stores a raw cancellation `Idempotency-Key`. It stores one lowercase SHA-256 replay identity computed from an explicit versioned domain, the authenticated-principal hash, the durable job identifier, and the normalized key:
+
+```text
+SHA-256(
+ "mightyetl:durable-job-cancellation:v1:"
+ || principal_scope_hash
+ || ":"
+ || job_record_id
+ || ":"
+ || normalized_cancellation_key
+)
+```
+
+This value is used only to prove that a later request addresses the same principal, the same job, and the same semantic cancellation key. It is not an authentication credential and grants no job authority; every transition and replay read independently binds the owner hash and job identifier in SQL.
+
+## Threat addressed
+
+Hashing the raw client key alone would hide its plaintext but preserve equality across every row. A database observer could correlate two jobs or tenants that reused the same cancellation key even though ordinary API representations never reveal the key or hash.
+
+The versioned contextual prefix and explicit principal/job components partition the replay identity. The same normalized raw key therefore produces:
+
+- the same stored hash for the same principal and job, preserving deterministic replay;
+- a different stored hash for another job in the same principal namespace;
+- a different stored hash for the same job-shaped identifier under another principal namespace;
+- a different stored hash after a deliberate future domain-version change.
+
+The implementation does not claim to use cSHAKE, TupleHash, or another NIST SP 800-185 primitive. It uses the existing SHA-256 utility with an unambiguous fixed-layout contextual input. NIST SP 800-185 is cited as primary methodological evidence for customization and tuple/domain separation concepts, not as an implementation-conformance claim. NIST announced in March 2025 that SP 800-185 will be revised; until a replacement is finalized, this document cites the current final publication and the revision decision separately.
+
+## Compatibility boundary
+
+The exact domain string is persisted protocol behavior:
+
+```text
+mightyetl:durable-job-cancellation:v1:
+```
+
+Changing it would make every existing cancelled row fail same-key replay comparison. A future `v2` requires an explicit migration and dual-read compatibility window or a documented replay-breaking release. Silent replacement of the prefix is prohibited.
+
+The current concatenation is unambiguous because:
+
+- `principal_scope_hash` is exactly 64 lowercase hexadecimal characters;
+- the separator is a literal colon;
+- `job_record_id` is the canonical UUID text form;
+- the second separator is a literal colon;
+- the normalized cancellation key follows the bounded safe-ASCII profile and is the final component.
+
+If a later version introduces variable-width or independently nested components, use explicit length prefixes or a tuple-hash construction rather than extending this layout informally.
+
+## Test-first evidence
+
+`EtlJobCancellationKeyDomainIntegrationTest` uses the same raw cancellation key for:
+
+1. two different jobs owned by one principal;
+2. one job owned by another principal.
+
+The test requires three distinct 64-character stored hashes. Existing service-integration tests separately prove that a quoted and legacy-raw representation of the same key on the same job replay one cancellation, while a genuinely different key fails with `etl_job_cancellation_key_reused`.
+
+## Privacy and logging
+
+The raw cancellation key and resulting hash remain absent from:
+
+- HTTP response bodies and headers;
+- RFC 9457 problem details;
+- ordinary logs;
+- metric labels;
+- status, list, polling, and ETag representations;
+- worker lease models.
+
+The hash is a replay identity stored in `cancellation_key_hash`; it is not safe to publish merely because it is one-way. Database access, backups, exports, and support tooling must treat it as internal pseudonymous security data.
+
+## Rollback
+
+Rolling application code back across this change can make same-key replay behavior inconsistent if an older binary derives a raw-key-only hash. Keep the domain-separated implementation deployed while rows created by it are active. A rollback requires either:
+
+- retaining the new comparison algorithm in the older release line; or
+- a reviewed data migration with explicit compatibility evidence.
+
+Never rewrite hashes from user-supplied guesses and never log candidate keys while diagnosing replay mismatches.
+
+## References — APA 7th
+
+Kelsey, J., Chang, S., & Perlner, R. (2016). *SHA-3 derived functions: cSHAKE, KMAC, TupleHash, and ParallelHash* (NIST Special Publication 800-185). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-185
+
+National Institute of Standards and Technology. (2025, March 12). *Decision to update FIPS 202 and revise SP 800-185*. https://csrc.nist.gov/news/2025/decision-to-update-fips-202-and-revise-sp-800-185
diff --git a/docs/operations/durable-job-cancellation.md b/docs/operations/durable-job-cancellation.md
new file mode 100644
index 00000000..ecc33022
--- /dev/null
+++ b/docs/operations/durable-job-cancellation.md
@@ -0,0 +1,244 @@
+# Durable ETL job cancellation
+
+## Purpose
+
+Authenticated operators can stop a durable ETL job that is still `PENDING` or `RUNNING` through:
+
+```http
+POST /api/etl/jobs/{job_record_id}/cancellation HTTP/1.1
+Authorization: Basic
+Idempotency-Key: "70dc8b50-e8b2-4e1a-8c5f-d84814708a77"
+```
+
+A successful response proves that the database transition committed or that the same principal,
+job identifier, and normalized cancellation key had already committed. HTTP request acceptance by
+itself is never treated as cancellation success.
+
+The first slice establishes a database-owned terminal state. It does not forcibly terminate a Java
+thread, interrupt arbitrary connector computation, or compensate a non-transactional external
+warehouse.
+
+## HTTP contract
+
+A first cancellation returns:
+
+```http
+HTTP/1.1 200 OK
+Cache-Control: no-store
+Idempotency-Replayed: false
+ETag: W/""
+Content-Type: application/json
+
+{
+ "jobRecordId": "cf4f083f-8c90-4f34-a8b6-b53761de44ef",
+ "jobStatus": "CANCELLED",
+ "attemptCount": 1,
+ "createdAt": "2026-08-05T01:00:00Z",
+ "updatedAt": "2026-08-06T03:00:00Z"
+}
+```
+
+Repeating the same semantic key returns `Idempotency-Replayed: true` and the same terminal resource.
+A different key for the already-cancelled job returns
+`422 etl_job_cancellation_key_reused`. Malformed, missing, and foreign-owned identifiers share the
+same `404 etl_job_not_found` response. Cancellation after committed success or failure returns
+`409 etl_job_already_succeeded` or `409 etl_job_already_failed` under RFC 9110 current-state conflict
+semantics.
+
+Every success and covered failure uses `Cache-Control: no-store`. The response exposes no payload,
+principal, cancellation key, key hash, lease identifier, SQL, exception message, or target identity.
+
+## State machine
+
+```mermaid
+stateDiagram-v2
+ [*] --> PENDING
+ PENDING --> RUNNING: lease-fenced claim
+ PENDING --> CANCELLED: owner cancellation
+ RUNNING --> PENDING: exact-lease retry
+ RUNNING --> SUCCEEDED: target + ledger + exact-lease commit
+ RUNNING --> FAILED: exact-lease terminal failure
+ RUNNING --> CANCELLED: owner cancellation wins
+ CANCELLED --> CANCELLED: same-key replay
+```
+
+`SUCCEEDED`, `FAILED`, and `CANCELLED` are terminal. Cancelled status never receives `Retry-After`.
+Its state and `updatedAt` value also invalidate every earlier weak status `ETag`.
+
+## Database authority
+
+`EtlJobService.cancelOwned` first validates the job identifier, cancellation key, and authenticated
+principal, then performs one conditional update inside a Spring transaction:
+
+```sql
+UPDATE etl_job_records
+SET job_status = 'CANCELLED',
+ request_payload = NULL,
+ failure_code = NULL,
+ lease_claim_id = NULL,
+ lease_owner_id = NULL,
+ lease_expires_at = NULL,
+ cancellation_key_hash = ?,
+ cancellation_code = 'etl_job_cancelled_by_owner',
+ job_cancelled_at = CURRENT_TIMESTAMP,
+ updated_at = CURRENT_TIMESTAMP
+WHERE job_record_id = ?
+ AND principal_scope_hash = ?
+ AND job_status IN ('PENDING', 'RUNNING');
+```
+
+The update count is the authority. A follow-up owner-scoped read classifies a zero-row result as:
+
+- identical `CANCELLED` replay;
+- conflicting cancellation key;
+- already `SUCCEEDED`;
+- already `FAILED`;
+- an active row whose concurrent transition is still unresolved; or
+- owner-safe not found.
+
+The raw principal and key are never stored. Their lowercase SHA-256 values are used only for owner
+selection and replay identity.
+
+## Cancellation-versus-success race
+
+### Cancellation commits first
+
+```mermaid
+sequenceDiagram
+ participant C as Cancellation request
+ participant D as PostgreSQL
+ participant W as Worker transaction
+ C->>D: conditional PENDING/RUNNING → CANCELLED
+ D-->>C: one row committed
+ W->>D: target + response ledger writes
+ W->>D: markSucceeded(exact former lease)
+ D-->>W: zero rows updated
+ W-->>D: rollback target + ledger
+```
+
+The cancelled row has no lease fields. The former worker's exact-live-lease success predicate updates
+zero rows and raises `StaleEtlJobLeaseException`; Spring rolls back its target and
+`etl_idempotency_records` writes.
+
+### Success commits first
+
+```mermaid
+sequenceDiagram
+ participant W as Worker transaction
+ participant D as PostgreSQL
+ participant C as Cancellation request
+ W->>D: target + response ledger + SUCCEEDED commit
+ C->>D: conditional PENDING/RUNNING → CANCELLED
+ D-->>C: zero rows updated
+ C->>D: owner-scoped terminal read
+ D-->>C: SUCCEEDED
+ C-->>C: 409 etl_job_already_succeeded
+```
+
+Exactly one terminal state wins. The endpoint never rewrites `SUCCEEDED` or `FAILED` as cancelled.
+
+## Migration
+
+`V6__add_etl_job_cancellation.sql` adds these descriptive multi-word `snake_case` columns:
+
+- `cancellation_key_hash`;
+- `cancellation_code`;
+- `job_cancelled_at`.
+
+It replaces lifecycle checks so that:
+
+- `PENDING` and `RUNNING` retain a payload;
+- `SUCCEEDED`, `FAILED`, and `CANCELLED` have no payload;
+- only `RUNNING` has lease fields;
+- only `FAILED` has `failure_code`;
+- only `CANCELLED` has the three cancellation fields;
+- hash and code values satisfy bounded fixed formats.
+
+The migration is transactional. Before production rollout, rehearse it against a representative
+PostgreSQL 18 copy and confirm that no out-of-contract legacy row violates the replacement checks.
+Monitor migration duration, lock wait, transaction age, replication lag, and application error rates.
+
+## Rollout
+
+1. Verify PR exact-head CI on Ubuntu, macOS, and Windows with no skipped project test.
+2. Verify dependency review, CycloneDX SBOM, SAST, security scan, unresolved-thread, and independent
+ current-head approval gates.
+3. Apply Flyway V6 before serving the new route.
+4. Keep `mightyetl.etl.jobs.intake-enabled=false` during a conservative schema-only rollout if the
+ deployment process cannot guarantee application/schema ordering.
+5. Enable the new application build and perform an owner-isolation smoke test with a disposable job.
+6. Verify a first cancellation, same-key replay, different-key rejection, and a status read.
+7. Confirm cancelled rows have null payload and lease fields and a fixed cancellation code.
+8. Observe worker `stale` outcomes during deliberate running-job cancellation; this is expected
+ fencing evidence, not a duplicate-execution success.
+
+## Monitoring
+
+The cancellation endpoint uses fixed observation name `etl.jobs.cancel`. Do not attach job IDs,
+principals, raw keys, key hashes, lease IDs, payloads, SQL, exception classes, messages, target
+identities, or queue depth as metric labels.
+
+Monitor at least:
+
+- request rate and HTTP outcome count;
+- cancellation latency;
+- database update latency and lock waits;
+- worker `stale` outcome changes;
+- cancellation replay and key-conflict rate;
+- cancelled rows retaining payload or lease fields, which must remain zero;
+- target or ledger effects associated with cancellation-first tests, which must remain zero.
+
+## Incident response
+
+### Cancellation returns `etl_job_cancellation_in_progress`
+
+Re-read the owner-scoped status. A concurrent claim, retry, success, failure, or cancellation may have
+won after the request's conditional update. Do not retry with a new idempotency key until the current
+terminal or active state is understood.
+
+### Worker reports stale after cancellation
+
+This is the expected safety outcome when cancellation invalidates a running lease. Confirm the target
+and response-ledger transaction rolled back. Repeated stale outcomes without operator cancellations
+may indicate lease expiry, another worker, or database clock/latency problems.
+
+### Cancelled row retains payload or lease data
+
+Treat this as a high-severity lifecycle integrity incident. Stop intake and workers, preserve the row
+and transaction evidence, verify the deployed schema constraints and application SHA, and do not
+manually rewrite the state until the root cause and rollback effects are understood.
+
+## Rollback
+
+Stop serving the cancellation endpoint before application rollback. Older binaries do not understand
+`CANCELLED`, so they must not read or process cancelled rows as if only four states existed.
+
+Do not drop V6 columns or restore the old status constraint while any cancelled row remains. A
+controlled database rollback must first archive cancelled resources and their audit evidence under an
+approved retention policy. Never silently map `CANCELLED` to `FAILED` or `SUCCEEDED`.
+
+After cancelled rows are safely removed and every older binary is deployed, an explicit reviewed
+migration may drop the V6 constraints and columns and restore the four-state lifecycle. Do not edit or
+repair the applied V6 migration file in place.
+
+## Connector limitation
+
+The cancellation-first rollback guarantee is valid for target and response-ledger writes that join the
+same transaction and database as the job state. A remote warehouse, file upload, external API, or
+message broker that cannot participate in that transaction requires connector-native cancellation,
+idempotency, or compensation before the same guarantee can be advertised. This release deliberately
+does not claim arbitrary external side-effect reversal.
+
+## References — APA 7th
+
+Fielding, R., Nottingham, M., & Reschke, J. (2022). *HTTP semantics* (RFC 9110). RFC Editor.
+https://www.rfc-editor.org/rfc/rfc9110
+
+Nottingham, M., Wilde, E., & Dalal, S. (2023). *Problem details for HTTP APIs* (RFC 9457). RFC Editor.
+https://www.rfc-editor.org/rfc/rfc9457
+
+PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: Data consistency checks at
+the application level*. https://www.postgresql.org/docs/18/applevel-consistency.html
+
+PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: UPDATE*.
+https://www.postgresql.org/docs/18/sql-update.html
diff --git a/docs/superpowers/plans/2026-08-06-durable-job-cancellation.md b/docs/superpowers/plans/2026-08-06-durable-job-cancellation.md
new file mode 100644
index 00000000..760dcd0a
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-06-durable-job-cancellation.md
@@ -0,0 +1,91 @@
+# Durable ETL Job Cancellation Implementation Plan
+
+**Goal:** Deliver owner-safe, idempotent, lease-fenced cancellation for pending and running durable ETL jobs on the repaired stack rooted at the exact conditional-status predecessor.
+
+**Architecture:** Keep PostgreSQL as terminal-state authority. A single conditional cancellation update clears payload and lease state, records only bounded pseudonymous replay identity, and lets exact-lease worker predicates reject stale commits. The HTTP layer exposes only the existing operator-safe status model.
+
+## Global constraints
+
+- Preserve standalone operation and modular MSA integration.
+- Do not weaken branch protection, security, independent review, or exact-head evidence.
+- Do not use `COPILOT_GITHUB_TOKEN` or invent credentials.
+- Database objects use descriptive multi-word `snake_case` names.
+- Raw principals, cancellation keys, payloads, hashes, lease identifiers, SQL, and exception messages do not enter client responses, logs, or metric labels.
+- Added production statement and branch coverage remains 100%.
+- Every public production API has beginner-readable Javadoc.
+- No project test may be skipped.
+- Update authoritative operations, doctoring, and `CHANGELOG.md` evidence before merge.
+
+## Task 1 — lifecycle and migration
+
+1. Write fail-first lifecycle assertions for `CANCELLED`, cancellation identity, payload clearing, and lease clearing.
+2. Add `V6__add_etl_job_cancellation.sql` with immutable Flyway history and descriptive constraints.
+3. Verify migration documentation and clean-install/upgrade invariants.
+
+Acceptance: `CANCELLED` is terminal; only active states retain payload; only `RUNNING` owns lease fields; only `CANCELLED` owns cancellation fields.
+
+## Task 2 — service cancellation authority
+
+1. Write fail-first service tests for pending/running cancellation, same-key replay, different-key rejection, owner isolation, completed-state conflicts, and invalid input before JDBC access.
+2. Implement `EtlJobCancellation`, `EtlJobStatus.CANCELLED`, stable RFC 9457 errors, and `EtlJobService.cancelOwned`.
+3. Keep one conditional update as authority; classify a zero-row result through an owner-scoped read.
+4. Verify replay identity is domain-separated by principal and job.
+
+Acceptance: one committed cancellation or one competing terminal transition wins; raw identity never persists.
+
+## Task 3 — authenticated HTTP action
+
+1. Add failing controller tests before the route.
+2. Expose `POST /api/etl/jobs/{jobRecordId}/cancellation`.
+3. Require authentication and a bounded `Idempotency-Key` before service access.
+4. Return `200`, `Cache-Control: no-store`, a weak `ETag`, `Idempotency-Replayed`, and only operator-safe status fields.
+5. Cover malformed identifiers, typed conflicts, data-access failures, and unexpected failures without leaking internal messages.
+
+Acceptance: exact controller tests and strict controller coverage pass.
+
+## Task 4 — worker and representation compatibility
+
+1. Prove cancellation clears the worker lease and prevents former exact-lease success from committing.
+2. Prove transactional target and response-ledger effects roll back when cancellation wins first.
+3. Prove `CANCELLED` never retains `Retry-After`.
+4. Prove cancellation invalidates the prior conditional status validator.
+5. Prove success-first and cancellation-first races each leave exactly one terminal state.
+
+Acceptance: concurrency tests demonstrate stale-lease rejection rather than duplicate terminal commit.
+
+## Task 5 — operator evidence and rollback
+
+1. Add documentation contract tests before missing docs are restored.
+2. Document endpoint semantics, replay, database authority, race outcomes, observability, migration rehearsal, rollback, and external connector limitation.
+3. Record domain-separated replay identity in doctoring with current NIST publication/revision evidence.
+4. Update `CHANGELOG.md` under Unreleased with the buyer-visible cancellation slice.
+5. Preserve old PR #133 and its fail-first history until this replacement proves equivalent or stronger exact-head behavior; old checks and approvals do not transfer.
+
+## Run all verification
+
+Run all verification after the final source/documentation change:
+
+```bash
+./mvnw -B test
+git diff --check
+```
+
+Then refetch the exact head, live base tip, CI, Dependency Review, SBOM, commit statuses, formal reviews, unresolved threads, and mergeability. SAST or security evidence absent because this PR is stacked on a non-default base remains not passing and must not be inferred from a predecessor or synthetic merge.
+
+## Merge and rollout acceptance
+
+- Exact ancestry from the immediate predecessor remains intact.
+- Exact-head CI, dependency, SBOM, security, coverage, and commit-status gates pass where required.
+- Zero actionable unresolved review thread remains.
+- A qualifying independent non-author formal `APPROVED` review is anchored to the exact unchanged head.
+- Migration/rollback and compatibility evidence are complete.
+- Protected merge uses expected-head semantics without bypass.
+- Post-merge operational proof confirms the protected branch serves the cancellation contract before the next dependent slice deepens the stack.
+
+## References — APA 7th
+
+Fielding, R., Nottingham, M., & Reschke, J. (2022). *HTTP semantics* (RFC 9110). RFC Editor. https://www.rfc-editor.org/rfc/rfc9110
+
+Nottingham, M., Wilde, E., & Dalal, S. (2023). *Problem details for HTTP APIs* (RFC 9457). RFC Editor. https://www.rfc-editor.org/rfc/rfc9457
+
+PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: UPDATE*. https://www.postgresql.org/docs/18/sql-update.html
diff --git a/docs/superpowers/specs/2026-08-06-durable-job-cancellation-design.md b/docs/superpowers/specs/2026-08-06-durable-job-cancellation-design.md
new file mode 100644
index 00000000..40a5b402
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-06-durable-job-cancellation-design.md
@@ -0,0 +1,99 @@
+# Durable ETL Job Cancellation Design
+
+## Purpose
+
+mightyETL durable jobs need an operator-controlled stop action that preserves owner isolation, idempotent replay, database lease fencing, and truthful transactional guarantees. Cancellation is therefore modeled as a database-owned terminal transition, not as a best-effort thread interrupt.
+
+## API contract
+
+```http
+POST /api/etl/jobs/{job_record_id}/cancellation
+Authorization:
+Idempotency-Key: "client-generated-safe-key"
+```
+
+A successful first cancellation returns the existing operator-safe status representation, `200 OK`, `Cache-Control: no-store`, a weak `ETag`, and `Idempotency-Replayed: false`. The same principal, job, and normalized key replays the terminal resource with `Idempotency-Replayed: true`.
+
+## State machine
+
+```mermaid
+stateDiagram-v2
+ [*] --> PENDING
+ PENDING --> RUNNING: exact database claim
+ PENDING --> CANCELLED: owner cancellation
+ RUNNING --> SUCCEEDED: target + ledger + exact lease commit
+ RUNNING --> FAILED: exact lease terminal failure
+ RUNNING --> PENDING: exact lease retry release
+ RUNNING --> CANCELLED: owner cancellation wins row update
+ CANCELLED --> CANCELLED: same-key replay
+```
+
+`SUCCEEDED`, `FAILED`, and `CANCELLED` are terminal. Exactly one terminal outcome wins. Completed success or failure is never rewritten as cancellation.
+
+## Database authority
+
+One conditional `UPDATE` is the cancellation authority:
+
+```sql
+UPDATE etl_job_records
+SET job_status = 'CANCELLED',
+ request_payload = NULL,
+ failure_code = NULL,
+ lease_claim_id = NULL,
+ lease_owner_id = NULL,
+ lease_expires_at = NULL,
+ cancellation_key_hash = ?,
+ cancellation_code = 'etl_job_cancelled_by_owner',
+ job_cancelled_at = CURRENT_TIMESTAMP,
+ updated_at = CURRENT_TIMESTAMP
+WHERE job_record_id = ?
+ AND principal_scope_hash = ?
+ AND job_status IN ('PENDING', 'RUNNING');
+```
+
+The affected-row count determines whether cancellation committed. A zero-row update is classified only by an owner-scoped read as same-key replay, conflicting key, already succeeded, already failed, concurrent active transition, or owner-safe not found. Read-then-write logic is not cancellation authority.
+
+## Replay identity
+
+Raw principals and raw cancellation keys are never persisted. Replay identity uses an explicit versioned cancellation domain together with the authenticated-principal hash, job identifier, and normalized key. The same-key replay is deterministic for one principal/job while the same client key on another job or principal produces a distinct stored identity.
+
+## Race and transaction semantics
+
+When cancellation commits first, it clears the live lease. A former worker can continue computing in memory, but its exact-live-lease success transition must affect zero rows and raise `StaleEtlJobLeaseException`. Transactional target effects and the durable response ledger then roll back with that worker transaction.
+
+When success commits first, cancellation affects zero rows, the owner-scoped terminal read observes `SUCCEEDED`, and the API returns `409 etl_job_already_succeeded`. The corresponding failed-job race returns `409 etl_job_already_failed`.
+
+The phrase transactional target effects is deliberate: this guarantee covers effects participating in the same transaction as the job-state and response-ledger commit. Remote warehouses, file uploads, external APIs, and message brokers require connector-native idempotency, cancellation, or compensation before equivalent rollback can be claimed.
+
+## HTTP representation
+
+The wire representation exposes `jobStatus=CANCELLED` without exposing cancellation hashes, codes, raw keys, principals, payloads, or lease identifiers. Cancellation changes lifecycle state and update time, so the weak status `ETag` changes. `CANCELLED` is terminal and therefore never retains `Retry-After` polling guidance.
+
+## Persistence and migration
+
+`V6__add_etl_job_cancellation.sql` adds descriptive multi-word `snake_case` columns `cancellation_key_hash`, `cancellation_code`, and `job_cancelled_at`. Lifecycle checks require payloads only while work is active, leases only while `RUNNING`, failure metadata only for `FAILED`, and cancellation metadata only for `CANCELLED`.
+
+The migration is immutable once applied. Rollback must first stop serving cancellation, preserve or archive cancelled-row evidence, and use a new reviewed migration. Never silently map `CANCELLED` to `FAILED` or `SUCCEEDED`.
+
+## Error taxonomy
+
+- `400 etl_job_cancellation_key_required`: missing or invalid key.
+- `404 etl_job_not_found`: malformed, missing, or foreign-owned identifier.
+- `409 etl_job_cancellation_in_progress`: an eligible transition remained unresolved after the authoritative update.
+- `409 etl_job_already_succeeded`: success won the race.
+- `409 etl_job_already_failed`: failure won the race.
+- `422 etl_job_cancellation_key_reused`: a different key addresses an already-cancelled job.
+
+Problem responses use the existing RFC 9457 handler and never disclose SQL, exception text, principal identity, keys, hashes, payloads, or leases.
+
+## Verification contract
+
+Exact-head verification must prove pending and running cancellation, payload and lease clearing, same-key replay, different-key rejection, owner isolation, completed-state conflicts, malformed input, controller failure mapping, stale-lease fencing, cancellation polling terminality, conditional-validator change, migration invariants, and rollback documentation. Added production statements and branches remain fully covered.
+
+## References — APA 7th
+
+Fielding, R., Nottingham, M., & Reschke, J. (2022). *HTTP semantics* (RFC 9110). RFC Editor. https://www.rfc-editor.org/rfc/rfc9110
+
+Nottingham, M., Wilde, E., & Dalal, S. (2023). *Problem details for HTTP APIs* (RFC 9457). RFC Editor. https://www.rfc-editor.org/rfc/rfc9457
+
+PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: UPDATE*. https://www.postgresql.org/docs/18/sql-update.html