From eae47298f3f2a41bd800c3cff9d59f04b8a51633 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 20:07:59 +0900 Subject: [PATCH 01/11] test(etl): require conditional status validators --- .../etl/job/EtlJobConditionalStatusTest.java | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobConditionalStatusTest.java diff --git a/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobConditionalStatusTest.java b/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobConditionalStatusTest.java new file mode 100644 index 00000000..56d9a438 --- /dev/null +++ b/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobConditionalStatusTest.java @@ -0,0 +1,175 @@ +package com.xtrmetl.etl.job; + +import com.xtrmetl.etl.controller.EtlApiProblemHandler; +import com.xtrmetl.etl.controller.EtlJobController; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.security.Principal; +import java.time.Instant; +import java.util.UUID; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.startsWith; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Defines conditional-request behavior for owner-scoped durable-job status polling. + */ +class EtlJobConditionalStatusTest { + + private static final String JOBS_PATH = "/api/etl/jobs"; + private static final UUID JOB_RECORD_ID = UUID.fromString( + "cf4f083f-8c90-4f34-a8b6-b53761de44ef" + ); + private static final Principal PRINCIPAL = () -> "tenant_alpha"; + private static final Instant CREATED_AT = Instant.parse("2026-08-05T01:00:00Z"); + private static final Instant UPDATED_AT = Instant.parse("2026-08-05T01:00:05Z"); + + private EtlJobService etlJobService; + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + etlJobService = mock(EtlJobService.class); + mockMvc = MockMvcBuilders + .standaloneSetup(new EtlJobController(etlJobService)) + .setControllerAdvice(new EtlApiProblemHandler()) + .build(); + } + + @Test + void unchangedStatusUsesAWeakEntityTagForAnEmptyNotModifiedResponse() throws Exception { + when(etlJobService.findOwned(JOB_RECORD_ID, "tenant_alpha")) + .thenReturn(snapshot(EtlJobStatus.PENDING, 0, null, UPDATED_AT)); + + MvcResult initialResult = mockMvc.perform(statusRequest()) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, "no-store")) + .andExpect(header().string(HttpHeaders.ETAG, startsWith("W/\""))) + .andReturn(); + String entityTag = initialResult.getResponse().getHeader(HttpHeaders.ETAG); + assertNotNull(entityTag); + assertTrue(entityTag.endsWith("\"")); + + mockMvc.perform(statusRequest().header(HttpHeaders.IF_NONE_MATCH, entityTag)) + .andExpect(status().isNotModified()) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, "no-store")) + .andExpect(header().string(HttpHeaders.ETAG, entityTag)) + .andExpect(content().string("")); + + verify(etlJobService, times(2)).findOwned(JOB_RECORD_ID, "tenant_alpha"); + } + + @Test + void changedStatusInvalidatesThePriorEntityTag() throws Exception { + when(etlJobService.findOwned(JOB_RECORD_ID, "tenant_alpha")) + .thenReturn( + snapshot(EtlJobStatus.PENDING, 0, null, UPDATED_AT), + snapshot(EtlJobStatus.RUNNING, 1, null, UPDATED_AT.plusSeconds(5)) + ); + + String priorEntityTag = mockMvc.perform(statusRequest()) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getHeader(HttpHeaders.ETAG); + assertNotNull(priorEntityTag); + + mockMvc.perform(statusRequest().header(HttpHeaders.IF_NONE_MATCH, priorEntityTag)) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.ETAG, not(equalTo(priorEntityTag)))) + .andExpect(jsonPath("$.jobStatus").value("RUNNING")) + .andExpect(jsonPath("$.attemptCount").value(1)); + } + + @Test + void changedFailureCodeInvalidatesTheTagEvenAtTheSameTimestamp() throws Exception { + when(etlJobService.findOwned(JOB_RECORD_ID, "tenant_alpha")) + .thenReturn( + snapshot(EtlJobStatus.FAILED, 3, "etl_source_failure", UPDATED_AT), + snapshot(EtlJobStatus.FAILED, 3, "etl_target_failure", UPDATED_AT) + ); + + String priorEntityTag = mockMvc.perform(statusRequest()) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getHeader(HttpHeaders.ETAG); + assertNotNull(priorEntityTag); + + mockMvc.perform(statusRequest().header(HttpHeaders.IF_NONE_MATCH, priorEntityTag)) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.ETAG, not(equalTo(priorEntityTag)))) + .andExpect(jsonPath("$.failureCode").value("etl_target_failure")); + } + + @Test + void wildcardIfNoneMatchRecognizesTheExistingOwnerScopedRepresentation() throws Exception { + when(etlJobService.findOwned(JOB_RECORD_ID, "tenant_alpha")) + .thenReturn(snapshot(EtlJobStatus.SUCCEEDED, 1, null, UPDATED_AT)); + + mockMvc.perform(statusRequest().header(HttpHeaders.IF_NONE_MATCH, "*")) + .andExpect(status().isNotModified()) + .andExpect(header().string(HttpHeaders.ETAG, startsWith("W/\""))) + .andExpect(content().string("")); + } + + @Test + void submissionResponsesDoNotReceiveAStatusEntityTag() throws Exception { + String requestPayload = "[{\"id\":\"record_alpha\"}]"; + String idempotencyKey = "\"550e8400-e29b-41d4-a716-446655440000\""; + when(etlJobService.submit(requestPayload, idempotencyKey, "tenant_alpha")) + .thenReturn(new EtlJobSubmission( + JOB_RECORD_ID, + EtlJobStatus.PENDING, + false + )); + + mockMvc.perform(post(JOBS_PATH) + .principal(PRINCIPAL) + .header("Idempotency-Key", idempotencyKey) + .contentType(MediaType.APPLICATION_JSON) + .content(requestPayload)) + .andExpect(status().isAccepted()) + .andExpect(header().doesNotExist(HttpHeaders.ETAG)); + } + + private static org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder + statusRequest() { + return get(JOBS_PATH + "/" + JOB_RECORD_ID).principal(PRINCIPAL); + } + + private static EtlJobSnapshot snapshot( + EtlJobStatus jobStatus, + int attemptCount, + String failureCode, + Instant updatedAt + ) { + return new EtlJobSnapshot( + JOB_RECORD_ID, + jobStatus, + attemptCount, + failureCode, + CREATED_AT, + updatedAt + ); + } +} From a19715703159f8f1d0d2cc9b0b53e8923abd0daa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 20:11:32 +0900 Subject: [PATCH 02/11] feat(etl): validate unchanged job status with weak ETags --- .../etl/controller/EtlJobController.java | 54 +++++++++++++++++-- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/etl-service/src/main/java/com/xtrmetl/etl/controller/EtlJobController.java b/etl-service/src/main/java/com/xtrmetl/etl/controller/EtlJobController.java index 336e67c4..d03bd9f1 100644 --- a/etl-service/src/main/java/com/xtrmetl/etl/controller/EtlJobController.java +++ b/etl-service/src/main/java/com/xtrmetl/etl/controller/EtlJobController.java @@ -9,6 +9,7 @@ import com.xtrmetl.etl.job.EtlJobSubmission; import com.xtrmetl.etl.service.EtlRequestError; import com.xtrmetl.etl.service.EtlRequestException; +import com.xtrmetl.etl.service.Sha256Digest; import io.micrometer.observation.annotation.Observed; import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.dao.DataAccessException; @@ -45,7 +46,9 @@ * *

Success and covered failure responses use {@code Cache-Control: no-store}. Malformed, absent, * and foreign-owned job identifiers use the same owner-safe not-found classification so the status - * endpoint does not become a cross-principal existence oracle.

+ * endpoint does not become a cross-principal existence oracle. Successful status responses also + * carry a weak entity tag so an authenticated client can explicitly validate an unchanged + * representation without authorizing shared-cache persistence.

*/ @ConditionalOnBooleanProperty( prefix = "xtrmetl.etl.jobs", @@ -177,9 +180,15 @@ public ResponseEntity list( /** * Returns one status resource only within the authenticated principal namespace. * + *

The response includes a weak {@code ETag} derived from every operator-visible status + * field. Spring MVC applies RFC 9110 weak comparison to {@code If-None-Match}; a matching GET + * returns {@code 304 Not Modified} with no representation body. Authentication and owner-safe + * lookup still occur before the validator is produced, and {@code Cache-Control: no-store} + * remains in force.

+ * * @param jobRecordIdText opaque durable job identifier text * @param principal authenticated principal namespace - * @return operator-safe status representation + * @return operator-safe status representation or an empty not-modified response */ @GetMapping("/{jobRecordId}") @Observed(name = "etl.jobs.status", contextualName = "etl-job-status") @@ -200,9 +209,48 @@ public ResponseEntity status( } catch (RuntimeException exception) { throw new EtlUnexpectedException(exception); } + EtlJobStatusResponse responseBody = EtlJobStatusResponse.from(snapshot); return ResponseEntity.ok() .cacheControl(CacheControl.noStore()) - .body(EtlJobStatusResponse.from(snapshot)); + .eTag(statusEntityTag(responseBody)) + .body(responseBody); + } + + /** + * Builds an opaque weak validator from the complete status representation. + * + *

Each field is length-prefixed before SHA-256 hashing, so adjacent values cannot create + * ambiguous material. The digest prevents the response header from exposing even the + * operator-safe values themselves. Payloads, principals, idempotency keys, lease identifiers, + * SQL text, and exception text are not part of the response model and therefore cannot enter + * this tag.

+ * + * @param responseBody complete owner-authorized status representation + * @return syntactically valid weak HTTP entity tag + */ + private static String statusEntityTag(EtlJobStatusResponse responseBody) { + EtlJobStatusResponse requiredResponse = Objects.requireNonNull( + responseBody, + "responseBody must not be null" + ); + String canonicalRepresentation = canonicalField(requiredResponse.jobRecordId()) + + canonicalField(requiredResponse.jobStatus()) + + canonicalField(requiredResponse.attemptCount()) + + canonicalField(requiredResponse.failureCode()) + + canonicalField(requiredResponse.createdAt()) + + canonicalField(requiredResponse.updatedAt()); + return "W/\"" + Sha256Digest.digest(canonicalRepresentation) + "\""; + } + + /** + * Encodes one possibly-null value without delimiter ambiguity. + * + * @param value representation field value + * @return decimal character length, a colon, and the canonical text value + */ + private static String canonicalField(@Nullable Object value) { + String canonicalValue = Objects.toString(value, ""); + return canonicalValue.length() + ":" + canonicalValue; } private static UUID parseJobRecordId(String jobRecordIdText) { From 242db256cc53a054d0c0aaa7c6bfbb45240b6679 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 20:12:33 +0900 Subject: [PATCH 03/11] docs(etl): document conditional status polling --- docs/etl/durable-job-polling.md | 74 ++++++++++++++++++++++++++------- 1 file changed, 58 insertions(+), 16 deletions(-) diff --git a/docs/etl/durable-job-polling.md b/docs/etl/durable-job-polling.md index 5043a529..af272a72 100644 --- a/docs/etl/durable-job-polling.md +++ b/docs/etl/durable-job-polling.md @@ -1,18 +1,24 @@ -# Durable job polling advisory +# Durable job polling contract ## Purpose -Authenticated clients poll `GET /api/etl/jobs/{job_record_id}` while a durable ETL job is active. Without a machine-readable cadence, independently implemented clients tend to use arbitrary tight loops that waste control-plane capacity and produce inconsistent operator behavior. +Authenticated clients poll `GET /api/etl/jobs/{job_record_id}` while a durable ETL job is active. Without a machine-readable cadence, independently implemented clients tend to use arbitrary tight loops that waste control-plane capacity and produce inconsistent operator behavior. Without a representation validator, every unchanged poll also retransmits the complete JSON status body. -mightyETL therefore emits the RFC 9110 `Retry-After` response field only when the owner-scoped status representation is `PENDING` or `RUNNING`. The field is advisory: it does not grant authority, alter the job state machine, extend a worker lease, or replace client-side exponential backoff and jitter. +mightyETL therefore provides two complementary RFC 9110 mechanisms: + +- `Retry-After` is emitted only while the owner-scoped status is `PENDING` or `RUNNING`, giving clients a bounded minimum polling delay. +- A weak `ETag` is emitted on every successful owner-scoped status response, allowing an authenticated client to send `If-None-Match` and receive an empty `304 Not Modified` response when the complete operator-visible representation is unchanged. + +Neither mechanism grants authority, alters the job state machine, extends a worker lease, authorizes shared-cache storage, or replaces client-side exponential backoff and jitter. ## Wire contract -An active job returns the existing operator-safe status representation, `Cache-Control: no-store`, and a whole-second polling delay: +An active job returns the existing operator-safe status representation, `Cache-Control: no-store`, a weak entity tag, and a whole-second polling delay: ```http HTTP/1.1 200 OK Cache-Control: no-store +ETag: W/"86b79e..." Retry-After: 5 Content-Type: application/json @@ -25,7 +31,24 @@ Content-Type: application/json } ``` -`SUCCEEDED` and `FAILED` are terminal and omit `Retry-After`. Submission responses, list responses, problem responses, and unrelated controllers are unchanged. +The client can explicitly validate that representation on its next owner-authorized request: + +```http +GET /api/etl/jobs/cf4f083f-8c90-4f34-a8b6-b53761de44ef HTTP/1.1 +Authorization: Bearer +If-None-Match: W/"86b79e..." +``` + +When the complete representation is unchanged, Spring MVC applies RFC 9110 weak comparison and returns no representation body: + +```http +HTTP/1.1 304 Not Modified +Cache-Control: no-store +ETag: W/"86b79e..." +Retry-After: 5 +``` + +When any represented value changes, including lifecycle state, attempt count, failure code, or update timestamp, the request returns `200 OK`, the current JSON representation, and a new weak tag. `SUCCEEDED` and `FAILED` are terminal and omit `Retry-After` while retaining their validator. Submission responses, list responses, problem responses, and unrelated controllers do not receive a status entity tag. ## Cadence derivation @@ -41,32 +64,51 @@ Fractional seconds are rounded upward, so one millisecond through one second adv The worker schedule remains a local execution setting rather than a completion promise. Queue depth, target latency, retries, process restarts, and lease recovery can make a job remain active for multiple polling intervals. +## Entity-tag derivation + +The controller first authenticates the caller, parses the opaque UUID, and performs the existing owner-scoped lookup. Only the resulting operator-safe `EtlJobStatusResponse` is eligible for a validator. Every response field is converted to canonical length-prefixed text and then SHA-256 hashed. The hexadecimal digest becomes a weak HTTP entity tag. + +Length prefixes prevent adjacent field values from creating ambiguous input. Hashing keeps even the operator-safe values out of the header. The tag changes when any represented field changes and is deterministic across replicas for the same representation. It is intentionally weak because the contract validates semantic status equivalence rather than byte-for-byte transfer encoding. + +`Cache-Control: no-store` remains authoritative: intermediaries and clients must not persist the tenant-scoped representation for reuse. The validator supports an explicit authenticated conditional request and does not turn the status endpoint into a publicly cacheable resource. + ## Security and privacy boundary -The polling field contains only a bounded integer. It exposes no request payload, authenticated principal, idempotency key, internal hash, job or lease identifier, SQL, exception message, target identity, or queue depth. +The polling delay contains only a bounded integer. The entity tag contains only a one-way digest of the complete operator-safe status representation. Neither header contains the request payload, raw authenticated principal, idempotency key, internal principal hash, lease identifier, SQL, exception message, target identity, or queue depth. + +The response still requires authenticated owner scope. A malformed, absent, or foreign-owned identifier returns the same owner-safe not-found problem and receives neither a status validator nor an active-job polling advisory. Conditional evaluation occurs after that lookup, so `If-None-Match`, including `*`, cannot become a cross-principal existence oracle. -The response still requires authenticated owner scope. A malformed, absent, or foreign-owned identifier returns the same owner-safe not-found problem and does not receive an active-job polling advisory. The advice is scoped to `EtlJobController` and modifies only `EtlJobStatusResponse` bodies. +The polling advice is scoped to `EtlJobController` and modifies only `EtlJobStatusResponse` bodies. The entity tag is created only by the controller's single-job status method. Submission, list, problem, and unrelated responses remain outside both mechanisms. ## Client guidance Clients should: -1. treat `Retry-After` as the minimum delay before the next status request; -2. add bounded jitter when many jobs are polled concurrently; -3. apply a larger local backoff after transport failures or `429`/`503` responses; -4. stop polling when the returned state is `SUCCEEDED` or `FAILED`; -5. retain their own overall timeout and operator escalation policy. +1. retain the most recent status `ETag` only in process memory when permitted by their own security policy; +2. send that value in `If-None-Match` on the next authenticated status request; +3. treat `304 Not Modified` as confirmation that the previously received representation is still current, not as a new representation; +4. treat `Retry-After` as the minimum delay before the next status request; +5. add bounded jitter when many jobs are polled concurrently; +6. apply a larger local backoff after transport failures or `429`/`503` responses; +7. stop polling when the returned state is `SUCCEEDED` or `FAILED`; +8. retain their own overall timeout and operator escalation policy. + +Clients must not interpret either header as a guarantee that the job will finish within one interval or as permission to access another principal's resource. -Clients must not interpret the field as a guarantee that the job will finish within one interval. +## Compatibility and rollback -## Rollback +This slice adds no database object, migration, persisted state, API body field, shared cache, or mutation precondition. Existing clients that do not send `If-None-Match` continue receiving the same `200` JSON representation plus ignorable response headers. Conditional clients must tolerate a `200` response with a replacement tag whenever the representation changes or the validator is unavailable after rollback. -The slice adds no database object, migration, persisted state, or API body field. Rolling back the application removes the advisory header while preserving submission, list, status, worker, and lease behavior. Older clients already operate without the header; newer clients must tolerate its absence because HTTP response fields are optional unless a separate client contract makes them mandatory. +Rolling back the application removes the entity-tag builder and conditional response behavior together with the advisory header if the preceding slice is also rolled back. Submission, list, status body, worker, lease, authorization, and persistence behavior remain intact. No database rollback is required. ## Standards basis RFC 9110 Section 10.2.3 defines `Retry-After` as either an HTTP-date or a non-negative decimal number of seconds indicating how long a user agent ought to wait before a follow-up request. mightyETL uses the bounded `delay-seconds` form because it is deterministic, timezone-independent, and directly derived from the configured worker cadence. -### Reference +RFC 9110 Sections 8.8 and 13.1.2 define entity tags and require weak comparison for `If-None-Match`. For GET and HEAD, a false `If-None-Match` precondition produces `304 Not Modified`. Spring MVC documents that an `ETag` supplied through `ResponseEntity` participates in conditional request processing and yields an empty `304` response when unchanged. + +### References Fielding, R., Nottingham, M., & Reschke, J. (2022). *HTTP semantics* (RFC 9110). RFC Editor. https://www.rfc-editor.org/rfc/rfc9110 + +Spring Framework. (2026). *HTTP caching: Spring Web MVC*. https://docs.spring.io/spring-framework/reference/6.2/web/webmvc/mvc-caching.html From ce6c3b9f05cd88d19586843574c53b6668dc37b6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 20:15:17 +0900 Subject: [PATCH 04/11] fix(etl): honor wildcard conditional status requests --- .../etl/controller/EtlJobController.java | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/etl-service/src/main/java/com/xtrmetl/etl/controller/EtlJobController.java b/etl-service/src/main/java/com/xtrmetl/etl/controller/EtlJobController.java index d03bd9f1..f408ab1c 100644 --- a/etl-service/src/main/java/com/xtrmetl/etl/controller/EtlJobController.java +++ b/etl-service/src/main/java/com/xtrmetl/etl/controller/EtlJobController.java @@ -15,6 +15,7 @@ import org.springframework.dao.DataAccessException; import org.springframework.http.CacheControl; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.lang.Nullable; @@ -181,12 +182,15 @@ public ResponseEntity list( * Returns one status resource only within the authenticated principal namespace. * *

The response includes a weak {@code ETag} derived from every operator-visible status - * field. Spring MVC applies RFC 9110 weak comparison to {@code If-None-Match}; a matching GET - * returns {@code 304 Not Modified} with no representation body. Authentication and owner-safe - * lookup still occur before the validator is produced, and {@code Cache-Control: no-store} - * remains in force.

+ * field. Spring MVC applies RFC 9110 weak comparison to ordinary {@code If-None-Match} entity + * tags. The controller handles the RFC wildcard after the existing owner-safe lookup because + * Spring Framework 6.2.19 does not treat {@code If-None-Match: *} as a match for safe methods. + * Either matching form returns {@code 304 Not Modified} with no representation body. + * Authentication and owner-safe lookup still occur before validation, and + * {@code Cache-Control: no-store} remains in force.

* * @param jobRecordIdText opaque durable job identifier text + * @param ifNoneMatch optional conditional request field * @param principal authenticated principal namespace * @return operator-safe status representation or an empty not-modified response */ @@ -194,6 +198,8 @@ public ResponseEntity list( @Observed(name = "etl.jobs.status", contextualName = "etl-job-status") public ResponseEntity status( @PathVariable("jobRecordId") String jobRecordIdText, + @RequestHeader(value = HttpHeaders.IF_NONE_MATCH, required = false) + @Nullable String ifNoneMatch, @Nullable Principal principal ) { if (principal == null) { @@ -210,9 +216,16 @@ public ResponseEntity status( throw new EtlUnexpectedException(exception); } EtlJobStatusResponse responseBody = EtlJobStatusResponse.from(snapshot); + String entityTag = statusEntityTag(responseBody); + if ("*".equals(Objects.toString(ifNoneMatch, "").trim())) { + return ResponseEntity.status(HttpStatus.NOT_MODIFIED) + .cacheControl(CacheControl.noStore()) + .eTag(entityTag) + .build(); + } return ResponseEntity.ok() .cacheControl(CacheControl.noStore()) - .eTag(statusEntityTag(responseBody)) + .eTag(entityTag) .body(responseBody); } From 72978cc218fecec200dec8cab54f381107637a5d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 20:15:56 +0900 Subject: [PATCH 05/11] docs(etl): record wildcard validator compatibility --- docs/etl/durable-job-polling.md | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/docs/etl/durable-job-polling.md b/docs/etl/durable-job-polling.md index af272a72..bea7df7a 100644 --- a/docs/etl/durable-job-polling.md +++ b/docs/etl/durable-job-polling.md @@ -39,15 +39,16 @@ Authorization: Bearer If-None-Match: W/"86b79e..." ``` -When the complete representation is unchanged, Spring MVC applies RFC 9110 weak comparison and returns no representation body: +When the complete representation is unchanged, ordinary entity tags use Spring MVC's RFC 9110 weak comparison and return no representation body: ```http HTTP/1.1 304 Not Modified Cache-Control: no-store ETag: W/"86b79e..." -Retry-After: 5 ``` +The RFC wildcard form `If-None-Match: *` is evaluated explicitly after the same authenticated owner-safe lookup. This narrow compatibility handling is required because Spring Framework 6.2.19 does not recognize the wildcard as a matching safe-method validator, even though RFC 9110 requires it to match any selected current representation. + When any represented value changes, including lifecycle state, attempt count, failure code, or update timestamp, the request returns `200 OK`, the current JSON representation, and a new weak tag. `SUCCEEDED` and `FAILED` are terminal and omit `Retry-After` while retaining their validator. Submission responses, list responses, problem responses, and unrelated controllers do not receive a status entity tag. ## Cadence derivation @@ -87,11 +88,12 @@ Clients should: 1. retain the most recent status `ETag` only in process memory when permitted by their own security policy; 2. send that value in `If-None-Match` on the next authenticated status request; 3. treat `304 Not Modified` as confirmation that the previously received representation is still current, not as a new representation; -4. treat `Retry-After` as the minimum delay before the next status request; -5. add bounded jitter when many jobs are polled concurrently; -6. apply a larger local backoff after transport failures or `429`/`503` responses; -7. stop polling when the returned state is `SUCCEEDED` or `FAILED`; -8. retain their own overall timeout and operator escalation policy. +4. treat `Retry-After` on a `200` active-status representation as the minimum delay before the next request; +5. retain the last applicable local cadence when a bodyless `304` does not repeat `Retry-After`; +6. add bounded jitter when many jobs are polled concurrently; +7. apply a larger local backoff after transport failures or `429`/`503` responses; +8. stop polling when the returned state is `SUCCEEDED` or `FAILED`; +9. retain their own overall timeout and operator escalation policy. Clients must not interpret either header as a guarantee that the job will finish within one interval or as permission to access another principal's resource. @@ -105,10 +107,12 @@ Rolling back the application removes the entity-tag builder and conditional resp RFC 9110 Section 10.2.3 defines `Retry-After` as either an HTTP-date or a non-negative decimal number of seconds indicating how long a user agent ought to wait before a follow-up request. mightyETL uses the bounded `delay-seconds` form because it is deterministic, timezone-independent, and directly derived from the configured worker cadence. -RFC 9110 Sections 8.8 and 13.1.2 define entity tags and require weak comparison for `If-None-Match`. For GET and HEAD, a false `If-None-Match` precondition produces `304 Not Modified`. Spring MVC documents that an `ETag` supplied through `ResponseEntity` participates in conditional request processing and yields an empty `304` response when unchanged. +RFC 9110 Sections 8.8 and 13.1.2 define entity tags and require weak comparison for `If-None-Match`. For GET and HEAD, a false `If-None-Match` precondition produces `304 Not Modified`. Spring MVC documents that an `ETag` supplied through `ResponseEntity` participates in ordinary conditional request processing and yields an empty `304` response when unchanged. The Spring Framework 6.2.19 primary source also establishes the exact wildcard limitation compensated by the controller. ### References Fielding, R., Nottingham, M., & Reschke, J. (2022). *HTTP semantics* (RFC 9110). RFC Editor. https://www.rfc-editor.org/rfc/rfc9110 +Spring Framework. (2025). *ServletWebRequest.java* (Version 6.2.19) [Source code]. GitHub. https://github.com/spring-projects/spring-framework/blob/v6.2.19/spring-web/src/main/java/org/springframework/web/context/request/ServletWebRequest.java + Spring Framework. (2026). *HTTP caching: Spring Web MVC*. https://docs.spring.io/spring-framework/reference/6.2/web/webmvc/mvc-caching.html From be16b64dc59f2a7fc2b5ca6bb8383c0d2024a31f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 20:17:48 +0900 Subject: [PATCH 06/11] docs(changelog): record conditional job polling --- CHANGELOG.md | 141 ++------------------------------------------------- 1 file changed, 4 insertions(+), 137 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e82aa8b..a5e7259a 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 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, derived from the bounded worker fixed-delay configuration with upward whole-second rounding; terminal states omit the advisory. - The durable job pagination index now uses PostgreSQL `CREATE INDEX CONCURRENTLY` with a migration-local Flyway `executeInTransaction=false` companion configuration, preserving production writers and documenting invalid-index recovery and concurrent rollback. - 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. @@ -31,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Deterministic ordinary, wildcard, changed-state, changed-failure-code, and unrelated-response conditional polling tests, complete controller Javadoc, privacy and rollback guidance, and APA 7th standards evidence in `docs/etl/durable-job-polling.md`. - Controller-scoped polling advice, deterministic active/terminal lifecycle tests, sub-second rounding coverage, rollback guidance, and APA 7th standards evidence in `docs/etl/durable-job-polling.md`. - Owner-scoped durable job list models and HTTP contract, strict cursor and page-limit validation, one-extra-row next-page detection, the descriptive `etl_job_owner_pagination_index`, deterministic tenant-isolation and equal-timestamp tests, migration rollback guidance, and APA 7th standards evidence in `docs/etl/durable-job-intake.md`. - PostgreSQL `FOR UPDATE SKIP LOCKED` durable-job claiming, per-process and per-claim lease fencing, expiry reclaim, bounded attempts, exact-live-lease transitions, terminal payload clearing, stable failure codes, and finite-cardinality worker metrics. @@ -67,6 +69,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- Conditional status validators are SHA-256 digests of only the complete owner-authorized operator-safe representation; payloads, raw principals, idempotency keys, internal hashes, leases, SQL, and exception text remain excluded, and wildcard evaluation occurs only after owner-safe lookup. - Job-list cursors contain only ordering keys, never authority or sensitive values; every page query independently binds the hashed authenticated principal, malformed and non-canonical cursors fail before database access, and list responses exclude payloads, principals, keys, hashes, lease identifiers, SQL, and exception text. - Durable-worker metrics and ordinary logs exclude payloads, raw principals, raw idempotency keys, hashes, job and lease identifiers, SQL, exception messages, and unbounded exception labels. - Retained payload or response-ledger identity conflicts fail closed with `etl_job_integrity_failure`; an expired or superseded lease rolls back target, ledger, and terminal-state effects. @@ -137,140 +140,4 @@ existing xtrmETL platform. - Key features overview - System architecture summary - Technology stack - - Use cases - - API specifications - - Quick start guide - - Future improvements - - Technical debt assessment - -#### Project Understanding - -Through code analysis, identified the platform as: - -- **Enterprise ETL and CDC Platform** -- Microservices-based architecture using Spring Cloud -- Real-time Change Data Capture using Debezium -- Data transformation pipelines with parallel processing -- JWT-based security with role-based access control -- Event streaming via Apache Kafka -- Service discovery with Netflix Eureka -- Distributed tracing with Zipkin - -#### Key Components Documented - -1. **CDC Service** (Port 8001) - - PostgreSQL change data capture - - Debezium embedded engine - - Kafka event publishing - - Real-time monitoring capabilities - -2. **ETL Service** (Port 8000) - - JSON data processing - - Parallel record processing - - Configurable transformations - - Automatic retry mechanism - - Target database loading - -3. **Zuul Gateway** (Port 8080) - - API Gateway with routing - - JWT authentication filter - - Load balancing - - Request routing to services - -4. **Eureka Server** (Port 8761) - - Service discovery - - Service registration - - Health monitoring - -5. **Config Server** (Port 8888) - - Centralized configuration (planned) - -6. **Zipkin** (Port 9412) - - Distributed tracing - - Performance monitoring - -#### Technology Stack Documented - -- Java 25 -- Spring Boot 2.7.14 -- Spring Cloud 2021.0.8 -- Debezium 2.3.x - 2.5.x -- PostgreSQL 12+ -- Apache Kafka -- Netflix Zuul -- Netflix Eureka -- Maven - -#### Identified Technical Debt - -- Common module referenced but not implemented -- MyBatis dependencies present but unused -- Redis integration configured but not utilized -- Config Server implemented but not actively used -- Missing Spring Boot Actuator health checks - -#### Future Enhancements Documented - -- Multi-database CDC support (MySQL, Oracle, SQL Server) -- Custom transformation functions -- Data quality validation -- Web UI for configuration and monitoring -- Schema registry integration -- Dead Letter Queue for failed messages -- Enhanced metrics dashboard - -### Files Changed - -- `CHANGELOG.md` (new) -- `README.md` (new) -- `PRD.md` (new) -- `ARCHITECTURE.md` (new) -- `SUMMARY_KR.md` (new) - -### Issue Resolved - -This release addresses the GitHub issue requesting reverse-engineering of the program's purpose and PRD creation. The issue noted: "이 프로그램이 무엇을 하고 싶었던 프로그램인지 역추적하고 PRD 작성. 아마도 데이터베이스 CDC 프로그램이었던 것 같음." - -**Confirmation**: Yes, this is a database CDC (Change Data Capture) program, specifically an enterprise-grade ETL and CDC platform for real-time data integration. - -### Documentation Statistics - -- Total lines of documentation: 1,925 -- Total files created: 4 -- Total size: ~75 KB -- Languages: English (primary), Korean (summary) - -### Related Documents - -For more information, see: - -- [README.md](README.md) - Quick start guide -- [PRD.md](PRD.md) - Product Requirements Document -- [ARCHITECTURE.md](ARCHITECTURE.md) - Technical architecture -- [SUMMARY_KR.md](SUMMARY_KR.md) - Korean summary -- Original design notes (Korean) in project files - ---- - -## Notes on Versioning - -Since this is documentation work on an existing codebase: - -- Version 1.0.0 represents the first documented release -- The actual codebase existed before this documentation -- Future versions will track both code and documentation changes - -## Changelog Maintenance - -This changelog will be updated: - -- When new features are added -- When bugs are fixed -- When documentation is significantly updated -- For each release or milestone - ---- - -**Changelog Version**: 1.0 -**Last Updated**: 2026-08-05 -**Maintained By**: Development Team \ No newline at end of file + - Use cases \ No newline at end of file From 9e1402a366ac2972197cf3a8d3342bca7007882a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 20:19:10 +0900 Subject: [PATCH 07/11] fix(changelog): preserve historical release record --- CHANGELOG.md | 138 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 137 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5e7259a..e6ad3246 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -140,4 +140,140 @@ existing xtrmETL platform. - Key features overview - System architecture summary - Technology stack - - Use cases \ No newline at end of file + - Use cases + - API specifications + - Quick start guide + - Future improvements + - Technical debt assessment + +#### Project Understanding + +Through code analysis, identified the platform as: + +- **Enterprise ETL and CDC Platform** +- Microservices-based architecture using Spring Cloud +- Real-time Change Data Capture using Debezium +- Data transformation pipelines with parallel processing +- JWT-based security with role-based access control +- Event streaming via Apache Kafka +- Service discovery with Netflix Eureka +- Distributed tracing with Zipkin + +#### Key Components Documented + +1. **CDC Service** (Port 8001) + - PostgreSQL change data capture + - Debezium embedded engine + - Kafka event publishing + - Real-time monitoring capabilities + +2. **ETL Service** (Port 8000) + - JSON data processing + - Parallel record processing + - Configurable transformations + - Automatic retry mechanism + - Target database loading + +3. **Zuul Gateway** (Port 8080) + - API Gateway with routing + - JWT authentication filter + - Load balancing + - Request routing to services + +4. **Eureka Server** (Port 8761) + - Service discovery + - Service registration + - Health monitoring + +5. **Config Server** (Port 8888) + - Centralized configuration (planned) + +6. **Zipkin** (Port 9412) + - Distributed tracing + - Performance monitoring + +#### Technology Stack Documented + +- Java 25 +- Spring Boot 2.7.14 +- Spring Cloud 2021.0.8 +- Debezium 2.3.x - 2.5.x +- PostgreSQL 12+ +- Apache Kafka +- Netflix Zuul +- Netflix Eureka +- Maven + +#### Identified Technical Debt + +- Common module referenced but not implemented +- MyBatis dependencies present but unused +- Redis integration configured but not utilized +- Config Server implemented but not actively used +- Missing Spring Boot Actuator health checks + +#### Future Enhancements Documented + +- Multi-database CDC support (MySQL, Oracle, SQL Server) +- Custom transformation functions +- Data quality validation +- Web UI for configuration and monitoring +- Schema registry integration +- Dead Letter Queue for failed messages +- Enhanced metrics dashboard + +### Files Changed + +- `CHANGELOG.md` (new) +- `README.md` (new) +- `PRD.md` (new) +- `ARCHITECTURE.md` (new) +- `SUMMARY_KR.md` (new) + +### Issue Resolved + +This release addresses the GitHub issue requesting reverse-engineering of the program's purpose and PRD creation. The issue noted: "이 프로그램이 무엇을 하고 싶었던 프로그램인지 역추적하고 PRD 작성. 아마도 데이터베이스 CDC 프로그램이었던 것 같음." + +**Confirmation**: Yes, this is a database CDC (Change Data Capture) program, specifically an enterprise-grade ETL and CDC platform for real-time data integration. + +### Documentation Statistics + +- Total lines of documentation: 1,925 +- Total files created: 4 +- Total size: ~75 KB +- Languages: English (primary), Korean (summary) + +### Related Documents + +For more information, see: + +- [README.md](README.md) - Quick start guide +- [PRD.md](PRD.md) - Product Requirements Document +- [ARCHITECTURE.md](ARCHITECTURE.md) - Technical architecture +- [SUMMARY_KR.md](SUMMARY_KR.md) - Korean summary +- Original design notes (Korean) in project files + +--- + +## Notes on Versioning + +Since this is documentation work on an existing codebase: + +- Version 1.0.0 represents the first documented release +- The actual codebase existed before this documentation +- Future versions will track both code and documentation changes + +## Changelog Maintenance + +This changelog will be updated: + +- When new features are added +- When bugs are fixed +- When documentation is significantly updated +- For each release or milestone + +--- + +**Changelog Version**: 1.0 +**Last Updated**: 2026-08-05 +**Maintained By**: Development Team From 0e50cca2d9982db6562e7d0f77e41570821ec724 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 20:21:27 +0900 Subject: [PATCH 08/11] test(etl): preserve owner boundary for wildcard validation --- .../etl/job/EtlJobConditionalStatusTest.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobConditionalStatusTest.java b/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobConditionalStatusTest.java index 56d9a438..ed853629 100644 --- a/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobConditionalStatusTest.java +++ b/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobConditionalStatusTest.java @@ -2,6 +2,8 @@ import com.xtrmetl.etl.controller.EtlApiProblemHandler; import com.xtrmetl.etl.controller.EtlJobController; +import com.xtrmetl.etl.service.EtlRequestError; +import com.xtrmetl.etl.service.EtlRequestException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.http.HttpHeaders; @@ -130,6 +132,22 @@ void wildcardIfNoneMatchRecognizesTheExistingOwnerScopedRepresentation() throws .andExpect(status().isNotModified()) .andExpect(header().string(HttpHeaders.ETAG, startsWith("W/\""))) .andExpect(content().string("")); + + verify(etlJobService).findOwned(JOB_RECORD_ID, "tenant_alpha"); + } + + @Test + void wildcardDoesNotBypassTheOwnerSafeNotFoundBoundary() throws Exception { + when(etlJobService.findOwned(JOB_RECORD_ID, "tenant_alpha")) + .thenThrow(new EtlRequestException(EtlRequestError.JOB_NOT_FOUND)); + + mockMvc.perform(statusRequest().header(HttpHeaders.IF_NONE_MATCH, "*")) + .andExpect(status().isNotFound()) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, "no-store")) + .andExpect(header().doesNotExist(HttpHeaders.ETAG)) + .andExpect(jsonPath("$.errorCode").value("etl_job_not_found")); + + verify(etlJobService).findOwned(JOB_RECORD_ID, "tenant_alpha"); } @Test From c69f63c5b7689b78ea07716905e81246cd6134f9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 20:25:26 +0900 Subject: [PATCH 09/11] test(etl): distinguish null and empty status fields --- .../etl/job/EtlJobConditionalStatusTest.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobConditionalStatusTest.java b/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobConditionalStatusTest.java index ed853629..1525d3ac 100644 --- a/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobConditionalStatusTest.java +++ b/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobConditionalStatusTest.java @@ -123,6 +123,28 @@ void changedFailureCodeInvalidatesTheTagEvenAtTheSameTimestamp() throws Exceptio .andExpect(jsonPath("$.failureCode").value("etl_target_failure")); } + @Test + void nullAndEmptyFailureCodesHaveDifferentRepresentationValidators() throws Exception { + when(etlJobService.findOwned(JOB_RECORD_ID, "tenant_alpha")) + .thenReturn( + snapshot(EtlJobStatus.FAILED, 3, null, UPDATED_AT), + snapshot(EtlJobStatus.FAILED, 3, "", UPDATED_AT) + ); + + String priorEntityTag = mockMvc.perform(statusRequest()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.failureCode").doesNotExist()) + .andReturn() + .getResponse() + .getHeader(HttpHeaders.ETAG); + assertNotNull(priorEntityTag); + + mockMvc.perform(statusRequest().header(HttpHeaders.IF_NONE_MATCH, priorEntityTag)) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.ETAG, not(equalTo(priorEntityTag)))) + .andExpect(jsonPath("$.failureCode").value("")); + } + @Test void wildcardIfNoneMatchRecognizesTheExistingOwnerScopedRepresentation() throws Exception { when(etlJobService.findOwned(JOB_RECORD_ID, "tenant_alpha")) From 4e56fd4f227b23becd25ee4195baa51f229dd439 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 20:27:57 +0900 Subject: [PATCH 10/11] fix(etl): distinguish null and empty status validators --- .../etl/controller/EtlJobController.java | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/etl-service/src/main/java/com/xtrmetl/etl/controller/EtlJobController.java b/etl-service/src/main/java/com/xtrmetl/etl/controller/EtlJobController.java index f408ab1c..bb2c1e0e 100644 --- a/etl-service/src/main/java/com/xtrmetl/etl/controller/EtlJobController.java +++ b/etl-service/src/main/java/com/xtrmetl/etl/controller/EtlJobController.java @@ -232,11 +232,12 @@ public ResponseEntity status( /** * Builds an opaque weak validator from the complete status representation. * - *

Each field is length-prefixed before SHA-256 hashing, so adjacent values cannot create - * ambiguous material. The digest prevents the response header from exposing even the - * operator-safe values themselves. Payloads, principals, idempotency keys, lease identifiers, - * SQL text, and exception text are not part of the response model and therefore cannot enter - * this tag.

+ *

Each non-null field is marked and length-prefixed before SHA-256 hashing, while a + * dedicated marker represents {@code null}. This prevents adjacent-value ambiguity and keeps + * an omitted nullable JSON field distinct from an explicitly empty string. The digest prevents + * the response header from exposing even the operator-safe values themselves. Payloads, + * principals, idempotency keys, lease identifiers, SQL text, and exception text are not part of + * the response model and therefore cannot enter this tag.

* * @param responseBody complete owner-authorized status representation * @return syntactically valid weak HTTP entity tag @@ -256,14 +257,17 @@ private static String statusEntityTag(EtlJobStatusResponse responseBody) { } /** - * Encodes one possibly-null value without delimiter ambiguity. + * Encodes one possibly-null value without delimiter or null/empty ambiguity. * * @param value representation field value - * @return decimal character length, a colon, and the canonical text value + * @return a null marker or a value marker followed by length-prefixed canonical text */ private static String canonicalField(@Nullable Object value) { - String canonicalValue = Objects.toString(value, ""); - return canonicalValue.length() + ":" + canonicalValue; + if (value == null) { + return "N;"; + } + String canonicalValue = value.toString(); + return "V" + canonicalValue.length() + ":" + canonicalValue; } private static UUID parseJobRecordId(String jobRecordIdText) { From f675b6e976efbd8821bcf889ecb0133c60c6e53d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 20:28:33 +0900 Subject: [PATCH 11/11] docs(etl): distinguish null and empty status fields --- docs/etl/durable-job-polling.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/etl/durable-job-polling.md b/docs/etl/durable-job-polling.md index bea7df7a..cbcc29f7 100644 --- a/docs/etl/durable-job-polling.md +++ b/docs/etl/durable-job-polling.md @@ -67,9 +67,9 @@ The worker schedule remains a local execution setting rather than a completion p ## Entity-tag derivation -The controller first authenticates the caller, parses the opaque UUID, and performs the existing owner-scoped lookup. Only the resulting operator-safe `EtlJobStatusResponse` is eligible for a validator. Every response field is converted to canonical length-prefixed text and then SHA-256 hashed. The hexadecimal digest becomes a weak HTTP entity tag. +The controller first authenticates the caller, parses the opaque UUID, and performs the existing owner-scoped lookup. Only the resulting operator-safe `EtlJobStatusResponse` is eligible for a validator. Every response field is converted to canonical text and then SHA-256 hashed. The hexadecimal digest becomes a weak HTTP entity tag. -Length prefixes prevent adjacent field values from creating ambiguous input. Hashing keeps even the operator-safe values out of the header. The tag changes when any represented field changes and is deterministic across replicas for the same representation. It is intentionally weak because the contract validates semantic status equivalence rather than byte-for-byte transfer encoding. +Every non-null value receives a value marker and decimal length prefix before its canonical text. A dedicated null marker is distinct from the value marker and a zero-length value, preserving the wire distinction between an omitted nullable field and an explicitly empty string. These markers and length prefixes prevent adjacent field values from creating ambiguous input. Hashing keeps even the operator-safe values out of the header. The tag changes when any represented field changes and is deterministic across replicas for the same representation. It is intentionally weak because the contract validates semantic status equivalence rather than byte-for-byte transfer encoding. `Cache-Control: no-store` remains authoritative: intermediaries and clients must not persist the tenant-scoped representation for reuse. The validator supports an explicit authenticated conditional request and does not turn the status endpoint into a publicly cacheable resource.