Date: Sun, 9 Aug 2026 12:20:36 +0900
Subject: [PATCH 08/14] feat(etl): expose owner-scoped job pagination
---
.../etl/controller/EtlJobController.java | 63 +++++++++++++++++--
1 file changed, 57 insertions(+), 6 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 1d467840..336e67c4 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
@@ -1,6 +1,8 @@
package com.xtrmetl.etl.controller;
import com.xtrmetl.etl.job.EtlJobAcceptedResponse;
+import com.xtrmetl.etl.job.EtlJobPage;
+import com.xtrmetl.etl.job.EtlJobPageResponse;
import com.xtrmetl.etl.job.EtlJobService;
import com.xtrmetl.etl.job.EtlJobSnapshot;
import com.xtrmetl.etl.job.EtlJobStatusResponse;
@@ -11,6 +13,7 @@
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.dao.DataAccessException;
import org.springframework.http.CacheControl;
+import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.lang.Nullable;
@@ -20,7 +23,9 @@
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.util.UriComponentsBuilder;
import java.net.URI;
import java.security.Principal;
@@ -28,16 +33,15 @@
import java.util.UUID;
/**
- * Exposes durable asynchronous ETL job submission and owner-scoped status resources.
+ * Exposes durable asynchronous ETL job submission, discovery, and status resources.
*
* Submission requires authentication and an {@code Idempotency-Key}. The accepted response is
* intentionally noncommittal under RFC 9110: it reports the durable pending state and supplies a
- * status-monitor resource through both the representation and {@code Location} header. This intake
- * slice does not claim that worker execution has started.
+ * status-monitor resource through both the representation and {@code Location} header.
*
- * Because this bounded slice retains payloads but does not yet execute jobs or clear terminal
- * payloads, the controller is disabled by default. Operators must explicitly set
- * {@code xtrmetl.etl.jobs.intake-enabled=true} after accepting that temporary lifecycle boundary.
+ * Job discovery is owner-scoped and uses an opaque keyset cursor. A next-page link is emitted
+ * under RFC 8288 only when another page exists. The service independently binds every list query to
+ * the authenticated principal hash, so cursor contents never grant authority.
*
* 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
@@ -56,6 +60,8 @@ public class EtlJobController {
/** Response header indicating whether a prior durable submission was replayed. */
public static final String IDEMPOTENCY_REPLAYED_HEADER = "Idempotency-Replayed";
+ private static final String DEFAULT_JOB_PAGE_LIMIT_TEXT = "50";
+
private final EtlJobService etlJobService;
/**
@@ -123,6 +129,51 @@ public ResponseEntity submit(
.body(responseBody);
}
+ /**
+ * Lists one deterministic page of jobs in the authenticated principal namespace.
+ *
+ * @param cursor opaque next-page cursor, or {@code null} for the newest page
+ * @param limit canonical decimal page size from 1 through 100, or {@code null} for 50
+ * @param principal authenticated principal namespace
+ * @return owner-scoped page with an RFC 8288 next link only when another page exists
+ */
+ @GetMapping
+ @Observed(name = "etl.jobs.list", contextualName = "etl-job-list")
+ public ResponseEntity list(
+ @RequestParam(value = "cursor", required = false) @Nullable String cursor,
+ @RequestParam(value = "limit", required = false) @Nullable String limit,
+ @Nullable Principal principal
+ ) {
+ if (principal == null) {
+ throw new EtlRequestException(EtlRequestError.IDEMPOTENCY_PRINCIPAL_REQUIRED);
+ }
+
+ final EtlJobPage page;
+ try {
+ page = etlJobService.listOwned(principal.getName(), cursor, limit);
+ } catch (EtlRequestException | DataAccessException exception) {
+ throw exception;
+ } catch (RuntimeException exception) {
+ throw new EtlUnexpectedException(exception);
+ }
+
+ ResponseEntity.BodyBuilder responseBuilder = ResponseEntity.ok()
+ .cacheControl(CacheControl.noStore());
+ if (page.nextCursor() != null) {
+ String effectiveLimit = limit == null ? DEFAULT_JOB_PAGE_LIMIT_TEXT : limit;
+ String nextTarget = UriComponentsBuilder.fromPath("/api/etl/jobs")
+ .queryParam("limit", effectiveLimit)
+ .queryParam("cursor", page.nextCursor())
+ .build()
+ .toUriString();
+ responseBuilder.header(
+ HttpHeaders.LINK,
+ "<" + nextTarget + ">; rel=\"next\""
+ );
+ }
+ return responseBuilder.body(EtlJobPageResponse.from(page));
+ }
+
/**
* Returns one status resource only within the authenticated principal namespace.
*
From 4bd27f205b1cab1210f62e33f87d105e18183606 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Sun, 9 Aug 2026 12:23:16 +0900
Subject: [PATCH 09/14] test(etl): require nonblocking pagination index
migration
---
.../job/EtlJobMigrationDocumentationTest.java | 42 +++++++++++++++++++
1 file changed, 42 insertions(+)
diff --git a/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobMigrationDocumentationTest.java b/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobMigrationDocumentationTest.java
index 9ea22672..1197bf69 100644
--- a/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobMigrationDocumentationTest.java
+++ b/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobMigrationDocumentationTest.java
@@ -50,6 +50,48 @@ void migrationReservesStableWorkerStatesAndRequiresTerminalPayloadClearing() thr
assertTrue(migration.contains("request_payload IS NULL"));
}
+ @Test
+ void paginationMigrationUsesTheOwnerAndCompleteStableOrderingKey() throws IOException {
+ String migration = read(
+ "etl-service/src/main/resources/db/migration/"
+ + "V5__add_etl_job_owner_pagination_index.sql"
+ ).replaceAll("\\s+", " ");
+
+ assertTrue(migration.contains(
+ "CREATE INDEX CONCURRENTLY etl_job_owner_pagination_index"
+ ));
+ assertTrue(migration.contains(
+ "ON etl_job_records ( principal_scope_hash, created_at DESC, job_record_id DESC )"
+ ));
+ assertFalse(migration.contains(" OFFSET "));
+ assertFalse(migration.contains("principal_name"));
+ }
+
+ @Test
+ void paginationIndexMigrationDoesNotBlockProductionWriters() throws IOException {
+ String migrationPath = "etl-service/src/main/resources/db/migration/"
+ + "V5__add_etl_job_owner_pagination_index.sql";
+ String configurationPath = migrationPath + ".conf";
+ String migration = read(migrationPath).replaceAll("\\s+", " ");
+ Path configuration = projectRoot().resolve(configurationPath);
+ String configurationText = Files.exists(configuration)
+ ? Files.readString(configuration, StandardCharsets.UTF_8).trim()
+ : "";
+
+ assertTrue(
+ migration.contains("CREATE INDEX CONCURRENTLY etl_job_owner_pagination_index"),
+ "the production pagination index must not block concurrent inserts or updates"
+ );
+ assertTrue(
+ Files.exists(configuration),
+ "Flyway requires a per-script configuration for non-transactional PostgreSQL DDL"
+ );
+ assertTrue(
+ configurationText.contains("executeInTransaction=false"),
+ "CREATE INDEX CONCURRENTLY cannot run inside Flyway's default transaction"
+ );
+ }
+
@Test
void runbookDocumentsAcceptedSemanticsOwnershipAndActiveWorkerBoundary() throws IOException {
String runbook = read("docs/etl/durable-job-intake.md").replaceAll("\\s+", " ");
From 266856b8a3e950534afe00abdb14f2b0da804e0b Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Sun, 9 Aug 2026 12:29:40 +0900
Subject: [PATCH 10/14] fix(etl): restore concurrent pagination index migration
---
.../V5__add_etl_job_owner_pagination_index.sql | 10 ++++++++++
1 file changed, 10 insertions(+)
create mode 100644 etl-service/src/main/resources/db/migration/V5__add_etl_job_owner_pagination_index.sql
diff --git a/etl-service/src/main/resources/db/migration/V5__add_etl_job_owner_pagination_index.sql b/etl-service/src/main/resources/db/migration/V5__add_etl_job_owner_pagination_index.sql
new file mode 100644
index 00000000..66802e4c
--- /dev/null
+++ b/etl-service/src/main/resources/db/migration/V5__add_etl_job_owner_pagination_index.sql
@@ -0,0 +1,10 @@
+-- Support deterministic newest-first keyset pagination inside one hashed principal namespace.
+-- CONCURRENTLY preserves inserts, updates, and deletes while PostgreSQL builds the index.
+-- The companion .sql.conf disables Flyway's per-migration transaction because PostgreSQL
+-- rejects CREATE INDEX CONCURRENTLY inside a transaction block.
+CREATE INDEX CONCURRENTLY etl_job_owner_pagination_index
+ ON etl_job_records (
+ principal_scope_hash,
+ created_at DESC,
+ job_record_id DESC
+ );
From 6e9fa4eff0459ed083f628c9682bf0bde246bac9 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Sun, 9 Aug 2026 12:29:58 +0900
Subject: [PATCH 11/14] fix(etl): run pagination index migration outside
transaction
---
.../db/migration/V5__add_etl_job_owner_pagination_index.sql.conf | 1 +
1 file changed, 1 insertion(+)
create mode 100644 etl-service/src/main/resources/db/migration/V5__add_etl_job_owner_pagination_index.sql.conf
diff --git a/etl-service/src/main/resources/db/migration/V5__add_etl_job_owner_pagination_index.sql.conf b/etl-service/src/main/resources/db/migration/V5__add_etl_job_owner_pagination_index.sql.conf
new file mode 100644
index 00000000..73bd53a1
--- /dev/null
+++ b/etl-service/src/main/resources/db/migration/V5__add_etl_job_owner_pagination_index.sql.conf
@@ -0,0 +1 @@
+executeInTransaction=false
From f2ba86de2326619aaf8e6bef69497c192cb09a7c Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Sun, 9 Aug 2026 12:33:16 +0900
Subject: [PATCH 12/14] test(etl): require pagination operator contract
---
.../job/EtlJobMigrationDocumentationTest.java | 19 +++++++++++++++++++
1 file changed, 19 insertions(+)
diff --git a/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobMigrationDocumentationTest.java b/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobMigrationDocumentationTest.java
index 1197bf69..b7c75107 100644
--- a/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobMigrationDocumentationTest.java
+++ b/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobMigrationDocumentationTest.java
@@ -114,6 +114,25 @@ void runbookDocumentsAcceptedSemanticsOwnershipAndActiveWorkerBoundary() throws
assertTrue(runbook.contains("xtrmetl.etl.jobs.worker.enabled=true"));
}
+ @Test
+ void runbookDocumentsOwnerScopedKeysetPaginationAndRollback() throws IOException {
+ String runbook = read("docs/etl/durable-job-intake.md").replaceAll("\\s+", " ");
+
+ assertTrue(runbook.contains("GET /api/etl/jobs?limit=50"));
+ assertTrue(runbook.contains("created_at DESC, job_record_id DESC"));
+ assertTrue(runbook.contains("strict tuple boundary"));
+ assertTrue(runbook.contains("Link: <"));
+ assertTrue(runbook.contains("rel=\"next\""));
+ assertTrue(runbook.contains("etl_invalid_job_page_limit"));
+ assertTrue(runbook.contains("etl_invalid_job_page_cursor"));
+ assertTrue(runbook.contains("V5__add_etl_job_owner_pagination_index.sql"));
+ assertTrue(runbook.contains("executeInTransaction=false"));
+ assertTrue(runbook.contains(
+ "DROP INDEX CONCURRENTLY etl_job_owner_pagination_index"
+ ));
+ assertTrue(runbook.contains("RFC 8288"));
+ }
+
@Test
void changelogRecordsLeaseFencedDurableWorkerExecution() throws IOException {
String changelog = read("CHANGELOG.md").replaceAll("\\s+", " ");
From ef3c24006c4c37b4b87a28664e8b7f3a4b1d744b Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Sun, 9 Aug 2026 12:35:55 +0900
Subject: [PATCH 13/14] docs(etl): document owner-scoped pagination rollout
---
docs/etl/durable-job-intake.md | 103 +++++++++++++++++++++++++++++----
1 file changed, 91 insertions(+), 12 deletions(-)
diff --git a/docs/etl/durable-job-intake.md b/docs/etl/durable-job-intake.md
index 05e2b958..2ce42c45 100644
--- a/docs/etl/durable-job-intake.md
+++ b/docs/etl/durable-job-intake.md
@@ -3,10 +3,11 @@
## Scope
`POST /api/etl/jobs` creates a durable, authenticated-principal-scoped ETL job resource. Durable
-execution is now implemented as a separate opt-in worker boundary: mightyETL executes at most one
+execution is implemented as a separate opt-in worker boundary: mightyETL executes at most one
eligible durable job per worker poll, while PostgreSQL owns cross-replica claim arbitration through
`FOR UPDATE SKIP LOCKED`, lease fencing, bounded attempts, and exact conditional lifecycle
-transitions.
+transitions. Authenticated operators can list recent jobs in their own principal namespace through
+deterministic keyset pagination.
Both externally reachable intake and background execution remain disabled by default. Durable job
intake is disabled by default and is absent unless an operator explicitly sets the preferred
@@ -70,6 +71,44 @@ A retry that resolves to the same durable resource returns the same job identifi
transaction-level submission lock returns `409 etl_job_submission_in_progress` rather than waiting
without a client-visible bound.
+## List owned jobs
+
+```http
+GET /api/etl/jobs?limit=50 HTTP/1.1
+Authorization: Basic
+```
+
+The endpoint returns only jobs owned by the same authenticated principal. Rows are ordered by
+`created_at DESC, job_record_id DESC`; the UUID is a deterministic tie-breaker when multiple jobs
+share one database timestamp. The default page size is 50 and the canonical accepted range is 1
+through 100. Values such as `0`, `101`, `01`, signed values, whitespace-padded values, or non-decimal
+text fail with `400 etl_invalid_job_page_limit` before table access.
+
+The service fetches one additional row beyond the requested page size. That row is never returned;
+it only proves that another page exists. When a following page exists, the response body contains an
+opaque cursor and RFC 8288 Web Linking advertises the same continuation target:
+
+```http
+HTTP/1.1 200 OK
+Cache-Control: no-store
+Link: ; rel="next"
+Content-Type: application/json
+```
+
+The cursor is a canonical unpadded Base64 URL encoding of the final returned creation timestamp and
+job identifier. Clients must treat it as opaque. Each next-page query independently binds the
+current authenticated principal hash and applies a strict tuple boundary equivalent to “older
+creation timestamp, or the same timestamp with a lower UUID.” Cursor contents never grant authority
+and contain no payload, raw principal, submission key, or internal hash. Malformed, oversized,
+incomplete, non-canonical, or stale-format cursors fail closed with
+`400 etl_invalid_job_page_cursor` before database access. A terminal or empty page omits both the
+next cursor and the `Link` header.
+
+Pagination guarantees no duplicate or omitted rows while traversing an unchanged dataset. Concurrent
+insertions are visible according to their ordering position; an operational cursor is not a frozen
+snapshot. Consumers that require a legally frozen audit set must use an explicit transactional or
+warehouse snapshot.
+
## Read job status
```http
@@ -143,14 +182,18 @@ bounds used by synchronous ETL admission. The complete body must be a JSON array
fields are rejected, every element must be an object with a safe textual `id`, and normalized field
names must remain unique.
-Flyway migration `V2__create_etl_job_records.sql` creates `etl_job_records`; later worker migrations
-add lease-fencing columns and a partial eligibility index for claim scans. All schema objects use
-descriptive multi-word `snake_case` names. The database stores:
+Flyway migrations use descriptive multi-word `snake_case` objects:
-- an opaque UUID job identifier;
-- SHA-256 hashes of the principal scope, semantic submission key, and exact JSON text;
-- the request payload only while the job remains nonterminal;
-- status, attempt, failure, lease-owner/token/expiry, and lifecycle timestamp fields.
+- `V2__create_etl_job_records.sql` creates `etl_job_records` and the principal-scoped submission
+ uniqueness contract;
+- worker migrations add the lease-fencing fields and concurrent claim-eligibility index; and
+- `V5__add_etl_job_owner_pagination_index.sql` creates `etl_job_owner_pagination_index` on
+ `principal_scope_hash`, `created_at DESC`, and `job_record_id DESC`, matching the owner-scoped
+ keyset ordering contract.
+
+The database stores an opaque UUID job identifier; SHA-256 hashes of principal scope, semantic
+submission key, and exact JSON text; the request payload only while nonterminal; and lifecycle,
+attempt, failure, lease-owner/token/expiry, and timestamp fields.
The stable lifecycle vocabulary is `PENDING`, `RUNNING`, `SUCCEEDED`, and `FAILED`. Database checks
require a non-null request payload only for nonterminal states and require the payload to be null for
@@ -162,6 +205,30 @@ payload is sensitive operational data and inherits the classification of its sou
job is `PENDING` or `RUNNING`, operators must protect it with database access control, encryption,
backup, and retention policy appropriate to the underlying records.
+## Pagination migration and rollback
+
+The V5 owner-pagination index uses PostgreSQL `CREATE INDEX CONCURRENTLY` so inserts, updates, and
+deletes remain available while PostgreSQL builds the index. Its migration-local companion file
+`V5__add_etl_job_owner_pagination_index.sql.conf` contains `executeInTransaction=false` because
+PostgreSQL rejects concurrent index creation inside a transaction block.
+
+Concurrent index creation can wait for transactions and can leave an invalid index after a failed
+build. Production rollout therefore requires catalog inspection of index validity and readiness,
+plus representative monitoring of duration, I/O, replication lag, and transaction age. A matching
+object name alone is not proof that the index is usable.
+
+Older application binaries ignore this additive index. After rolling back binaries that depend on
+the list access path, remove the database object outside a transaction block only after list traffic
+is withdrawn and an execution-plan review confirms the operational boundary:
+
+```sql
+DROP INDEX CONCURRENTLY etl_job_owner_pagination_index;
+```
+
+Dropping the index does not change query semantics, but it can turn an owner-scoped list operation
+into an unacceptable scan, so removal is an explicit performance rollback rather than an emergency
+schema shortcut.
+
## Operational boundary
Enabling intake alone still does not start background processing; enabling the worker alone does not
@@ -172,7 +239,7 @@ safe by disabling the worker property: existing durable rows remain in PostgreSQ
manually rewrite lease tokens or terminal status to manufacture recovery.
This slice establishes durable execution, lease fencing, bounded retries, terminal payload clearing,
-and finite worker telemetry. Higher-level job-list pagination, polling advisories, conditional status
+finite worker telemetry, and owner-scoped keyset pagination. Polling advisories, conditional status
reads, cancellation, and replay remain separate later stack items and must not be represented as part
of this boundary until their own exact-head gates pass.
@@ -180,14 +247,18 @@ of this boundary until their own exact-head gates pass.
- RFC 9110 Section 15.3.3 defines `202 Accepted` as noncommittal and recommends that the response
describe current status and point to a status monitor.
+- RFC 8288 defines the Web Linking model and HTTP `Link` header used for the optional next-page
+ relationship.
- RFC 9457 supplies the problem-details representation used by deterministic submission, lookup, and
execution failures.
- RFC 9651 defines the current Structured Fields String syntax accepted for `Idempotency-Key`.
- The expired IETF HTTPAPI `Idempotency-Key` draft-07 is used only as work-in-progress design
evidence for unique client keys, request fingerprints, `422` payload conflicts, and tenant-isolation
security concerns. It expired on April 18, 2026 and is not represented as a published RFC.
-- PostgreSQL row locking and `SKIP LOCKED` semantics are the database authority for concurrent claim
- behavior; the worker does not attempt to replace that arbitration with process-local locking.
+- PostgreSQL 18 documents explicit ordering, row-locking semantics, multicolumn B-tree behavior, and
+ the availability and recovery trade-offs of concurrent index construction.
+- Flyway script configuration supports migration-local transaction overrides required by PostgreSQL
+ DDL that cannot execute in a transaction block.
### References
@@ -196,9 +267,17 @@ of this boundary until their own exact-head gates pass.
- Jena, J., & Dalal, S. (2025). *The Idempotency-Key HTTP header field*
(draft-ietf-httpapi-idempotency-key-header-07, expired April 18, 2026). Internet Engineering Task
Force. https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/
+- Nottingham, M. (2017). *Web linking* (RFC 8288). RFC Editor.
+ https://doi.org/10.17487/RFC8288
- Nottingham, M., & Wilde, E. (2023). *Problem details for HTTP APIs* (RFC 9457). RFC Editor.
https://www.rfc-editor.org/rfc/rfc9457
- Nottingham, M., & Kamp, P. (2024). *Structured field values for HTTP* (RFC 9651). RFC Editor.
https://www.rfc-editor.org/rfc/rfc9651
+- PostgreSQL Global Development Group. (2026). *CREATE INDEX*. PostgreSQL 18 documentation.
+ https://www.postgresql.org/docs/18/sql-createindex.html
+- PostgreSQL Global Development Group. (2026). *Multicolumn indexes*. PostgreSQL 18 documentation.
+ https://www.postgresql.org/docs/18/indexes-multicolumn.html
- PostgreSQL Global Development Group. (2026). *SELECT*. PostgreSQL 18 documentation.
https://www.postgresql.org/docs/18/sql-select.html
+- Redgate Software. (2026). *Flyway script configuration*. Flyway documentation.
+ https://documentation.red-gate.com/flyway/reference/script-configuration
From 2a37789e651316e9bf29cbbd68902aa827a50c63 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Sun, 9 Aug 2026 12:39:19 +0900
Subject: [PATCH 14/14] docs(changelog): record owner-scoped pagination
---
CHANGELOG.md | 3 +++
1 file changed, 3 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6f0b446e..3b40aeac 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
+- 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.
+- The durable job pagination index uses PostgreSQL `CREATE INDEX CONCURRENTLY` with migration-local Flyway `executeInTransaction=false`, preserving production writers while documenting invalid-index recovery and concurrent rollback.
- Scheduled OpenCode maintenance now performs root-cause analysis, tests remediation feasibility against live authority, protection, resource, dependency, path-ownership, and writer-lease constraints, executes and verifies the best safe option available now, and continues exactly one independent bounded mightyETL slice from protected `develop` when only external blockers remain; invalid stacks and separately leased repositories stay untouched.
- Container builds now pin Maven and Eclipse Temurin base-image tags to reviewed SHA-256 digests, with a fail-first contract test preventing mutable registry tags from re-entering the Dockerfile.
- The model-executing hourly OpenCode maintenance job now has read-only issue access; fail-first workflow-contract coverage proves `issues: write` is unnecessary while preserving issue and roadmap inspection.
@@ -34,6 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- 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`.
- Test-first doctoring for external-wait progress, root-cause analysis, realistic remediation feasibility, exact post-action verification, source-actionable pull-request classification, invalid-stack isolation, and read-only dependency leases in `docs/doctoring/hourly-opencode-nonblocking-progress-evidence.md`.
- Permanent fail-first exact-head workflow contracts and authoritative evidence in `docs/doctoring/exact-head-source-workflow-evidence.md`, including observed synthetic-merge checkout behavior, cross-platform source identity assertions, the rejected ignored Dependency Review ref-override experiment, corrective pull-request event-endpoint semantics, least-privilege boundaries, stack invalidation rules, rollback prohibition, and APA 7th GitHub references.
- A separate fail-closed hourly OpenCode maintenance workflow pinned to OpenCode 1.18.13 and `nvidia/deepseek-ai/deepseek-v4-pro`, using only the existing `NVIDIA_NIM_API_KEY` through OpenCode's `NVIDIA_API_KEY` provider variable while preserving the independent review agent and deterministic merge-disposition workflow.