diff --git a/backend/build.gradle b/backend/build.gradle
index 085dff9..bc477c0 100644
--- a/backend/build.gradle
+++ b/backend/build.gradle
@@ -61,7 +61,7 @@ dependencies {
tasks.named('test') {
useJUnitPlatform {
- excludeTags 'benchmark', 'minio-integration', 'claim-concurrency', 'local-e2e', 'storage-worker-e2e', 'real-pdf-version-e2e', 'vector-search-performance', 'vector-storage-performance', 'worker-indexing-throughput', 'worker-horizontal-scaling', 'worker-queue-backpressure', 'document-indexing-e2e-load', 'chunk-quality-performance', 'opensql-ha-connection'
+ excludeTags 'benchmark', 'minio-integration', 'claim-concurrency', 'local-e2e', 'storage-worker-e2e', 'real-pdf-version-e2e', 'vector-search-performance', 'vector-storage-performance', 'worker-indexing-throughput', 'worker-horizontal-scaling', 'worker-queue-backpressure', 'document-indexing-e2e-load', 'chunk-quality-performance', 'opensql-ha-connection', 'opensql-ha-contract'
}
}
@@ -512,3 +512,24 @@ tasks.register('openSqlHaConnectionTest', Test) {
}
}
}
+
+tasks.register('openSqlOpenProxyContractTest', Test) {
+ group = 'verification'
+ description = '실제 OpenProxy 두 대의 라우팅, prepared statement, 시간대 계약을 검증합니다.'
+ testClassesDirs = sourceSets.test.output.classesDirs
+ classpath = sourceSets.test.runtimeClasspath
+ useJUnitPlatform {
+ includeTags 'opensql-ha-contract'
+ }
+ maxParallelForks = 1
+ outputs.upToDateWhen { false }
+ doFirst {
+ // 1. 외부 환경이 빠졌을 때 로컬 기본 DB에 잘못 연결해 통과하는 것을 막는다.
+ ['OPENSQL_PROXY_A_JDBC_URL', 'OPENSQL_PROXY_B_JDBC_URL',
+ 'OPENSQL_APP_USER', 'OPENSQL_APP_PASSWORD'].each { name ->
+ if (!System.getenv(name)?.trim()) {
+ throw new GradleException("필수 OpenProxy 계약 환경 변수가 비어 있습니다: ${name}")
+ }
+ }
+ }
+}
diff --git a/backend/src/test/java/com/opensource/docgrid/opensql/OpenSqlOpenProxyContractTest.java b/backend/src/test/java/com/opensource/docgrid/opensql/OpenSqlOpenProxyContractTest.java
new file mode 100644
index 0000000..89535a6
--- /dev/null
+++ b/backend/src/test/java/com/opensource/docgrid/opensql/OpenSqlOpenProxyContractTest.java
@@ -0,0 +1,277 @@
+package com.opensource.docgrid.opensql;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.time.LocalDateTime;
+import java.time.OffsetDateTime;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.UUID;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import org.postgresql.PGStatement;
+
+/**
+ * 실제 두 OpenProxy에서 트랜잭션 라우팅, SQL/pgJDBC 준비문, 시간대 경계를 확인한다.
+ *
+ *
외부 3노드 전용 수동 시험이다. 설정·스키마를 바꾸지 않으며 역할과 backend 개수만 출력한다.
+ * SQL이 어느 물리 노드에 갔는지는 별도 프록시·DB 통계와 교차 확인한다.
+ */
+@Tag("opensql-ha-contract")
+@DisplayName("OpenProxy 라우팅·세션 계약")
+class OpenSqlOpenProxyContractTest {
+
+ private static final String[] PROXIES = {"OPENSQL_PROXY_A_JDBC_URL", "OPENSQL_PROXY_B_JDBC_URL"};
+
+ @Test
+ @Timeout(60)
+ @DisplayName("자동 커밋 조회와 명시적 읽기·쓰기 트랜잭션의 DB 역할을 구분한다")
+ void routesQueriesByTransactionContract() throws SQLException {
+ for (String proxy : PROXIES) {
+ // 1. 자동 커밋 조회의 실제 결과는 기록한다. 읽기 분산을 미리 가정하지 않는다.
+ try (Connection connection = connect(proxy, null)) {
+ report(proxy, "autocommit-select", isStandby(connection));
+ }
+
+ // 2. SELECT 다음 UPDATE를 같은 명시적 트랜잭션에서 실행해 primary 고정을 확인한다.
+ try (Connection connection = connect(proxy, null)) {
+ connection.setAutoCommit(false);
+ try {
+ boolean standby = isStandby(connection);
+ report(proxy, "read-write-transaction", standby);
+ assertThat(standby).isFalse();
+ try (PreparedStatement update = connection.prepareStatement(
+ "UPDATE documents SET id = id WHERE id = ?")) {
+ update.setLong(1, -1L);
+ assertThat(update.executeUpdate()).isZero();
+ }
+ } finally {
+ connection.rollback();
+ }
+ }
+
+ // 3. read-only 트랜잭션은 primary를 보장한다고 가정하지 않고 실제 역할을 기록한다.
+ try (Connection connection = connect(proxy, null)) {
+ connection.setReadOnly(true);
+ connection.setAutoCommit(false);
+ try {
+ boolean standby = isStandby(connection);
+ report(proxy, "read-only-transaction", standby);
+ } finally {
+ connection.rollback();
+ }
+ }
+ }
+ }
+
+ @Test
+ @Timeout(60)
+ @DisplayName("Worker의 FOR UPDATE SKIP LOCKED 조회는 standby에서 거부되지 않는다")
+ void workerLockingSelectUsesPrimary() throws SQLException {
+ for (String proxy : PROXIES) {
+ try (Connection connection = connect(proxy, null)) {
+ // 1. 실제 Worker가 사용하는 세 테이블에서 빈 결과만 잠금 조회한다.
+ for (String table : new String[] {"embedding_jobs", "sync_outbox_events", "rag_responses"}) {
+ try (Statement statement = connection.createStatement();
+ ResultSet result = statement.executeQuery(
+ "SELECT id FROM " + table + " WHERE 1 = 0 "
+ + "ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED")) {
+ assertThat(result.next()).isFalse();
+ }
+ // 2. standby는 FOR UPDATE를 실행할 수 없으므로 성공 자체가 primary 라우팅 증거다.
+ System.out.printf("CONTRACT_LOCK proxy=%s table=%s route=primary%n", alias(proxy), table);
+ }
+ }
+ }
+ }
+
+ @Test
+ @Timeout(90)
+ @DisplayName("기본·강제 server-side prepared statement를 여러 트랜잭션에서 반복한다")
+ void preparedStatementsSurviveTransactionPooling() throws SQLException {
+ for (String proxy : PROXIES) {
+ for (int threshold : new int[] {5, 1}) {
+ // 1. 같은 물리 JDBC 연결과 PreparedStatement를 유지한 채 트랜잭션만 바꾼다.
+ try (Connection connection = connect(proxy, threshold)) {
+ connection.setAutoCommit(false);
+ Set backends = new HashSet<>();
+ long preparedOnLastBackend = 0;
+ try (PreparedStatement statement = connection.prepareStatement("SELECT ?::integer")) {
+ PGStatement pgStatement = statement.unwrap(PGStatement.class);
+ assertThat(pgStatement.getPrepareThreshold()).isEqualTo(threshold);
+ for (int value = 1; value <= 15; value++) {
+ statement.setInt(1, value);
+ try (ResultSet result = statement.executeQuery()) {
+ assertThat(result.next()).isTrue();
+ assertThat(result.getInt(1)).isEqualTo(value);
+ }
+ backends.add(backendIdentity(connection));
+ if (value == 15) {
+ // 2. 같은 트랜잭션에 묶어 pg_prepared_statements를 같은 backend에서 읽는다.
+ preparedOnLastBackend = queryLong(connection,
+ "SELECT count(*) FROM pg_prepared_statements");
+ }
+ connection.commit();
+ }
+ // 3. 드라이버가 server-side prepare 단계에 들어갔는지 확인한다.
+ assertThat(pgStatement.isUseServerPrepare()).isTrue();
+ assertThat(preparedOnLastBackend).isPositive();
+ } finally {
+ connection.rollback();
+ }
+ System.out.printf("CONTRACT_PREPARED proxy=%s threshold=%d executions=15 "
+ + "distinct_backends=%d prepared_on_last_backend=%d%n",
+ alias(proxy), threshold, backends.size(), preparedOnLastBackend);
+ }
+ }
+ }
+ }
+
+ @Test
+ @Timeout(60)
+ @DisplayName("SQL-level PREPARE/EXECUTE를 pgJDBC 프로토콜 준비문과 구별한다")
+ void sqlLevelPrepareIsObservedSeparately() throws SQLException {
+ String baseline = null;
+ for (String proxy : PROXIES) {
+ try (Connection connection = connect(proxy, null)) {
+ connection.setAutoCommit(false);
+ // 1. SQL PREPARE는 드라이버의 반복 실행 임계값과 무관한 서버 명령이다.
+ String name = "contract_" + UUID.randomUUID().toString().replace("-", "");
+ String outcome;
+ try (Statement statement = connection.createStatement()) {
+ statement.execute("PREPARE " + name + "(integer) AS SELECT $1::integer");
+ try (ResultSet result = statement.executeQuery("EXECUTE " + name + "(41)")) {
+ assertThat(result.next()).isTrue();
+ assertThat(result.getInt(1)).isEqualTo(41);
+ }
+ // 2. 같은 트랜잭션 안에서 명시적으로 제거해 풀의 다른 요청에 남기지 않는다.
+ statement.execute("DEALLOCATE " + name);
+ outcome = "same_transaction_pass";
+ } finally {
+ connection.rollback();
+ }
+ if (baseline == null) {
+ baseline = outcome;
+ } else {
+ assertThat(outcome).isEqualTo(baseline);
+ }
+ System.out.printf("CONTRACT_SQL_PREPARE proxy=%s result=%s%n", alias(proxy), outcome);
+ }
+ }
+ }
+
+ @Test
+ @Timeout(60)
+ @DisplayName("timestamp와 timestamptz의 한국 시간 자정 경계를 보존한다")
+ void timeZoneBoundaryIsStableAcrossProxies() throws SQLException {
+ LocalDateTime wallClock = LocalDateTime.of(2026, 9, 27, 0, 0, 1);
+ OffsetDateTime instant = wallClock.atOffset(ZoneOffset.ofHours(9));
+ String baseline = null;
+ String serverVersion = null;
+ ZoneId jvmTimeZone = ZoneId.systemDefault();
+ for (String proxy : PROXIES) {
+ try (Connection connection = connect(proxy, null)) {
+ // 1. JVM·DB와 OS 시간대가 달라도 양 프록시의 날짜 변환은 같아야 한다.
+ String timeZone = queryString(connection, "SHOW TimeZone");
+ String observedServerVersion = connection.getMetaData().getDatabaseProductVersion();
+ if (baseline == null) {
+ baseline = timeZone;
+ serverVersion = observedServerVersion;
+ } else {
+ assertThat(timeZone).isEqualTo(baseline);
+ assertThat(observedServerVersion).isEqualTo(serverVersion);
+ }
+ try (PreparedStatement statement = connection.prepareStatement(
+ "SELECT ?::timestamp, ?::timestamptz")) {
+ statement.setObject(1, wallClock);
+ statement.setObject(2, instant);
+ try (ResultSet result = statement.executeQuery()) {
+ assertThat(result.next()).isTrue();
+ assertThat(result.getObject(1, LocalDateTime.class)).isEqualTo(wallClock);
+ assertThat(result.getObject(2, OffsetDateTime.class).toInstant())
+ .isEqualTo(instant.toInstant());
+ }
+ }
+
+ // 2. SET LOCAL은 트랜잭션 밖 다음 요청으로 새지 않아야 한다.
+ connection.setAutoCommit(false);
+ try (Statement statement = connection.createStatement()) {
+ statement.execute("SET LOCAL TIME ZONE 'Pacific/Honolulu'");
+ assertThat(queryString(connection, "SHOW TimeZone")).isEqualTo("Pacific/Honolulu");
+ } finally {
+ connection.rollback();
+ }
+ connection.setAutoCommit(true);
+ assertThat(queryString(connection, "SHOW TimeZone")).isEqualTo(timeZone);
+ System.out.printf("CONTRACT_TIMEZONE proxy=%s jvm=%s db=%s boundary=pass%n",
+ alias(proxy), jvmTimeZone, timeZone);
+ System.out.printf("CONTRACT_SERVER proxy=%s postgresql=%s%n",
+ alias(proxy), observedServerVersion);
+ System.out.printf("CONTRACT_DRIVER proxy=%s pgjdbc=%s%n",
+ alias(proxy), connection.getMetaData().getDriverVersion());
+ }
+ }
+ }
+
+ private Connection connect(String name, Integer threshold) throws SQLException {
+ String url = System.getenv(name);
+ if (threshold != null) {
+ url += (url.contains("?") ? "&" : "?") + "prepareThreshold=" + threshold;
+ }
+ return DriverManager.getConnection(url, System.getenv("OPENSQL_APP_USER"),
+ System.getenv("OPENSQL_APP_PASSWORD"));
+ }
+
+ private boolean isStandby(Connection connection) throws SQLException {
+ try (Statement statement = connection.createStatement();
+ ResultSet result = statement.executeQuery("SELECT pg_is_in_recovery()")) {
+ assertThat(result.next()).isTrue();
+ return result.getBoolean(1);
+ }
+ }
+
+ private String backendIdentity(Connection connection) throws SQLException {
+ try (Statement statement = connection.createStatement();
+ ResultSet result = statement.executeQuery(
+ "SELECT pg_postmaster_start_time()::text || ':' || pg_backend_pid()")) {
+ assertThat(result.next()).isTrue();
+ return result.getString(1);
+ }
+ }
+
+ private long queryLong(Connection connection, String sql) throws SQLException {
+ try (Statement statement = connection.createStatement();
+ ResultSet result = statement.executeQuery(sql)) {
+ assertThat(result.next()).isTrue();
+ return result.getLong(1);
+ }
+ }
+
+ private String queryString(Connection connection, String sql) throws SQLException {
+ try (Statement statement = connection.createStatement();
+ ResultSet result = statement.executeQuery(sql)) {
+ assertThat(result.next()).isTrue();
+ return result.getString(1);
+ }
+ }
+
+ private void report(String proxy, String operation, boolean standby) {
+ System.out.printf("CONTRACT_ROUTE proxy=%s operation=%s role=%s%n",
+ alias(proxy), operation, standby ? "standby" : "primary");
+ }
+
+ private String alias(String proxy) {
+ return proxy.equals(PROXIES[0]) ? "proxy-a" : "proxy-b";
+ }
+}
diff --git a/docs/test-results/gimin-opensql-openproxy-hikari-troubleshooting-20260924.md b/docs/test-results/gimin-opensql-openproxy-hikari-troubleshooting-20260924.md
index f300776..ed9fbc9 100644
--- a/docs/test-results/gimin-opensql-openproxy-hikari-troubleshooting-20260924.md
+++ b/docs/test-results/gimin-opensql-openproxy-hikari-troubleshooting-20260924.md
@@ -1,6 +1,6 @@
# OpenProxy × HikariCP: 연결은 성공했는데 쓰기는 standby로 간 이유
-> 2026-09-24 실행 기록 및 기술 블로그 초안. 공개 가능한 내용만 담았다. VM·프로젝트 식별자, 사설 IP, 비밀번호, 라이선스 파일 및 서버 경로는 제외했다.
+> 2026-09-24 실행 기록. 공개 가능한 내용만 담았다. VM·프로젝트 식별자, 사설 IP, 비밀번호, 라이선스 파일 및 서버 경로는 제외했다.
## 결과부터
diff --git a/docs/test-results/gimin-opensql-openproxy-routing-session-contract-20260926.md b/docs/test-results/gimin-opensql-openproxy-routing-session-contract-20260926.md
new file mode 100644
index 0000000..2afe10d
--- /dev/null
+++ b/docs/test-results/gimin-opensql-openproxy-routing-session-contract-20260926.md
@@ -0,0 +1,102 @@
+# OpenSQL 3노드 OpenProxy 라우팅·세션 계약 실측 (2026-09-26)
+
+## 목적과 판정 범위
+
+두 OpenProxy를 거친 실제 SQL의 primary/standby 역할, SQL-level `PREPARE/EXECUTE`와 pgJDBC 프로토콜 준비문, 시간대 경계, HA 관련 설치·실행 설정을 **운영 데이터를 영구 변경하지 않고** 확인했다. 이 문서는 장애 전환, 물리 standby별 부하 분산, Hikari/JPA 전체 경로의 무중단성을 합격 처리하지 않는다. 기존 Hikari/JPA 쓰기 검증은 [별도 결과](gimin-opensql-openproxy-hikari-troubleshooting-20260924.md)를 참조한다.
+
+공개 증거는 논리 별칭 `node1~3`, `proxy-a/b`만 사용한다. 프로젝트 ID, IP, 관리자·앱 암호, 라이선스 파일은 포함하지 않는다. 수집 당시 `node1`이 leader, `node2/3`이 streaming replica였고, 프록시는 각각 `node2/3`에서 실행됐다. 세 컨테이너 모두 Rocky Linux 9.7 `x86_64`였다.
+
+실행 코드 기준은 `origin/develop@98324caeb176`에 이 PR의 테스트·수집기 커밋 `5ec0d53`을 더한 상태다. 최종 전체 계약 시험은 2026-09-26 12:46 UTC에 **5개 테스트/실패 0건**으로 완료됐다. 이후 Java import·주석만 정리하고 재컴파일했다. 머신 고유명이 들어 있는 Gradle XML은 커밋하지 않고, 테스트 이름·판정·허용된 관측값만 [JUnit 요약](opensql-contract-evidence/junit-summary.json)에 남겼다.
+
+## 재현 절차
+
+1. [`capture_live_ha_contract.sh`](../../scripts/opensql/capture_live_ha_contract.sh)에 승인된 GCP 계정·프로젝트, 기존 VM의 존과 SSH 키 경로를 환경 변수로 주고 실행한다. 스크립트는 실제 활성 계정·프로젝트가 지정한 대상과 다르면 시작 전에 종료한다. 각 Rocky 컨테이너에서는 [`capture_ha_contract.py`](../../scripts/opensql/capture_ha_contract.py)의 `collect`를 **etcd 실행 사용자**로 실행한다. OS·제품 버전, `patronictl show-config/list`, etcd 초기 멤버 파일·실행 멤버 목록·프로세스 입력·설치 바이너리 기본값, OpenProxy TOML의 허용된 키만 출력한다. 원본 설정 파일 전체는 복사하지 않는다.
+2. 같은 자동 수집 과정에서 각 VM 호스트의 `runtime`을 별도로 실행해 Docker 재시작 정책, 호스트 bootstrap unit, 살아 있는 OpenProxy 프로세스의 부모 PID, VM 시간대를 기록한다. `merge`가 컨테이너·VM 결과의 노드 별칭을 검증하고 자동 병합한다. 기존 파일처럼 사람이 `runtime` 필드를 옮겨 적지 않는다.
+3. `node2/3` 컨테이너의 `admin`은 관리자 암호를 **컨테이너 메모리에서만** 읽고 서비스 포트에서 `SHOW CONFIG`, `SHOW STATS`, `SHOW SERVERS`를 실행한다. 허용된 설정값과 역할별 누적 숫자만 출력한다. `proxy-a=node2`, `proxy-b=node3` 별칭도 수집기가 만든다.
+4. `assemble`이 노드 3개와 관리자 결과 2개를 함께 대조하고 canonical JSON의 SHA-256을 만든다. OS·OpenSQL 제품 버전·Patroni 동적 설정·A/B 설치 및 관리 설정 불일치나 민감 문자열은 실패시킨다. 수집 시각은 **2026-09-26 12:57:23~12:58:14 UTC**, 공개 증거의 SHA-256은 `0a0b86eff71c4c24740a99ea0f334fca70e5c11e1a813c3a3bc646a825549794`이다. 해시는 공개 JSON의 무결성을 검사할 뿐 GCP가 발급한 원본 증명은 아니다.
+5. A/B JDBC URL과 최소 권한 앱 계정 환경 변수를 주입해 `./backend/gradlew -p backend openSqlOpenProxyContractTest --rerun-tasks --offline`를 실행한다. `junit` 수집 모드가 XML의 호스트명을 버리고 5개 시험의 이름·판정·허용된 `CONTRACT_*` 출력만 보존한다. `connectTimeout=5&socketTimeout=15`는 **이번 수동 시험 URL에만** 붙였고 앱 운영 URL 변경은 아니다. SSH 터널은 접근 경로일 뿐 장애·성능 측정 경로로 사용하지 않는다.
+
+수집기의 비밀값 차단·중복 감지·제품 버전 추출·etcd 시간 설정 우선순위·자동 병합·관리값 포함 해시·JUnit 비식별화는 `python3 -m unittest scripts.opensql.test_capture_ha_contract -v`로 **11개 시험/실패 0건**을 확인했다.
+
+별도로 `./backend/gradlew -p backend test --offline`도 실행했으나 **1,124개 중 104개가 실패하여 전체 테스트는 통과하지 않았다.** 다수의 PostgreSQL 통합 테스트에서 `Connection refused`가 관측됐고 이 실행 환경에서는 로컬 Docker 데몬도 연결되지 않았다. 모든 실패가 동일 원인이라고 단정하지 않으며, 로컬 인프라를 복구한 뒤 전체 테스트를 다시 실행해야 한다. 이 결과를 위의 외부 클러스터 전용 5개 계약 시험 성공과 섞어 ‘전체 빌드 성공’이라고 주장하지 않는다.
+
+공개한 **비식별 수집 결과**: [node1](opensql-contract-evidence/node1.json), [node2](opensql-contract-evidence/node2.json), [node3](opensql-contract-evidence/node3.json), [proxy-a 관리값](opensql-contract-evidence/proxy-a-admin.json), [proxy-b 관리값](opensql-contract-evidence/proxy-b-admin.json), [통합 manifest](opensql-contract-evidence/contract-manifest.json), [JUnit 요약](opensql-contract-evidence/junit-summary.json), [이전 캐시 카운터 전후](opensql-contract-evidence/prepared-cache-delta.json). 마지막 캐시 카운터 파일은 **이전 실행의 관측값**이며 이번 통합 스냅샷·JUnit 실행과 같은 시점의 수치가 아니다. 원본 설정과 Gradle XML은 민감정보·머신 고유명 때문에 공개하지 않는다.
+
+## 설치·실행 계약
+
+| 항목 | 실측 결과 | 해석 |
+| --- | --- | --- |
+| 제품 | OpenSQL v3.17.8.7(세 노드), OpenProxy 1.1.3 revision 723(node2/3), Patroni 4.0.5, etcd 3.6.5, PostgreSQL 서버 실행 파일·클라이언트 17.8 | 세 노드의 설치 바이너리를 조회했다. 양쪽 프록시 JDBC 연결의 서버 메타데이터도 PostgreSQL 17.8이었다. |
+| 앱 시험 의존성 | pgJDBC 42.7.11, HikariCP 6.3.3 | Gradle `dependencyInsight --configuration testRuntimeClasspath`로 각각의 해석된 버전을 조회했다. JDBC 실측의 드라이버 메타데이터도 42.7.11이다. 이 계약 시험은 `DriverManager`를 사용하므로 **Hikari를 실행해 시험한 것은 아니다.** |
+| Patroni 동적 설정 | `ttl=30`, `loop_wait=10`, `retry_timeout=10`, `maximum_lag_on_failover=1048576`, `failsafe_mode=true` | `primary_start_timeout`, 동기 복제 모드는 동적 설정에 명시되지 않음. 미설정을 `0`으로 해석하지 않는다. |
+| etcd 멤버·시간 설정 | 초기 구성 이름과 실행 멤버 모두 `node1/2/3`, 정족수 `2`; heartbeat `100ms`, election timeout `1000ms` | 실행 프로세스의 관련 환경·인자에 재정의가 없고 설치 바이너리 `--help` 기본값이 위 수치였다. 파일의 `initial_cluster_state=new`는 **부트스트랩 입력값**이지 현재 클러스터가 새로 생성 중이라는 뜻은 아니다. |
+| DocGrid 풀 | `pool_mode=transaction`, `default_role=primary`, query parser와 read/write splitting 활성, `primary_reads_enabled=false`, standby 선택 모드 `Random` | A/B 설치 파일과 관리 콘솔의 공통 항목이 일치한다. 물리 standby별 요청 비율을 이번 SQL 시험으로 판정하지는 않는다. |
+| 캐시 설정 | 설치 TOML 일반 섹션 `prepared_statements_cache_size=1000`; `SHOW CONFIG`의 **풀 항목**은 `0` | 두 표기가 상충한다. `0`을 “실제 캐시 완전 비활성”이라고 단정하지 않는다. 아래 실측과 공급사 확인이 필요하다. |
+| 실행 감독·OS 시간대 | 컨테이너 정책 `unless-stopped`; 호스트 bootstrap unit `inactive`; 살아 있는 OpenProxy의 부모는 컨테이너 PID 1. 세 VM·컨테이너 시간대 표기는 `UTC+0000` | 동봉된 `Restart=always`, `RestartSec=1` systemd **예시 파일**이 현재 프로세스를 감독한다는 증거가 없다. OpenProxy 프로세스 종료 후 자동 재시작 방식·시간은 장애 시험에서 따로 잰다. |
+
+관리 설정의 `admin_port=6433`은 컨테이너 localhost에서 열려 있지 않았고, 실제 관리 `SHOW` 명령은 서비스 포트 `6432`로 성공했다. 이 차이는 설치 빌드의 관리 포트 의미를 추가 확인할 항목이다.
+
+## SQL 라우팅 결과
+
+| SQL/세션 조건 | proxy-a | proxy-b | 근거·주의 |
+| --- | --- | --- | --- |
+| 자동 커밋 `SELECT pg_is_in_recovery()` | standby | standby | 각 SQL이 돌려준 DB 역할. 어느 물리 standby였는지는 판정하지 않음. |
+| 명시적 트랜잭션의 `SELECT → UPDATE` | primary | primary | `UPDATE ... WHERE id=-1`은 0행이고 트랜잭션을 rollback함. |
+| JDBC read-only 트랜잭션의 SELECT | standby | standby | **`@Transactional(readOnly=true)`만으로 primary 일관성을 보장할 수 없다는 반례.** Spring 자체의 실제 라우팅은 후속 앱 통합 시험에서 재확인해야 함. |
+| `FOR UPDATE SKIP LOCKED` 빈 결과 조회 | primary 성공 | primary 성공 | Embedding Job·Outbox·RAG 응답 테이블 각각 0행 잠금 조회. standby는 `FOR UPDATE`를 실행할 수 없으므로 성공이 primary 라우팅의 증거다. 실제 Worker claim 트랜잭션 전체를 시험한 것은 아님. |
+
+따라서 권한 회수 후 최신 역할을 반드시 읽어야 하는 경로에 read-only 어노테이션만 붙이는 수정은 금지한다. 전용 primary 경로 또는 명시적인 라우팅 정책을 별도로 설계·검증해야 한다. OpenProxy가 잘못 동작했다는 결론이 아니라, 애플리케이션의 일관성 경계를 제품 라우팅과 맞춰야 한다는 결론이다.
+
+## SQL-level·pgJDBC prepared statement 구분
+
+두 방식은 이름이 비슷하지만 다른 계약이다. SQL-level 방식은 애플리케이션이 서버 SQL 명령 `PREPARE 이름(integer) AS SELECT ...`, `EXECUTE 이름(41)`, `DEALLOCATE 이름`을 명시적으로 보낸다. 이번 시험은 A/B 모두 **같은 트랜잭션 안에서** 값 41을 확인하고 정리한 뒤 rollback했다. 이는 SQL-level 준비문이 여러 트랜잭션이나 서로 다른 DB backend에서도 유지된다는 증거는 아니다.
+
+pgJDBC 프로토콜 방식은 JDBC `PreparedStatement`가 확장 쿼리 프로토콜을 사용한다. 각 프록시에서 `prepareThreshold=5`와 `1`로 같은 SQL을 15회, 트랜잭션을 매번 커밋하며 반복했다. 네 조합 모두 결과가 정확했고, 드라이버의 server-prepare 플래그와 마지막 backend의 `pg_prepared_statements > 0`을 **assert**했다. 최종 실측에서 네 조합의 서로 다른 backend 수는 각각 **1개**였다. 이전 [캐시 카운터 실험](opensql-contract-evidence/prepared-cache-delta.json)의 합산값은 각 프록시 hit +224, miss +80, eviction 0이었지만, 이번 최종 5개 JUnit 시험과 시점이 달라 수치를 합치지 않는다.
+
+따라서 **backend 교체 시 named statement 재준비·캐시 적중은 아직 검증하지 않았다.** `SHOW CONFIG` 풀 값 `0`과 일반 TOML `1000`의 우선순위도 이번 시험만으로 확정하지 않는다. 공급사에 확인하고, 이후 backend 교체를 강제한 별도 회귀 시험이 필요하다.
+
+## JVM·DB·OS 시간대와 날짜 경계
+
+시험 JVM 기본 시간대는 `Asia/Seoul`, 양쪽 프록시를 거친 DB 세션의 `SHOW TimeZone`도 `Asia/Seoul`이었다. 반면 세 VM 호스트와 Rocky 컨테이너의 OS 시간대는 `UTC+0000`이었다. **시간대 설정이 전부 동일했던 것은 아니다.** 그 조건에서 한국 시간 `2026-09-27 00:00:01`의 `timestamp` 벽시계값과 `timestamptz`가 나타내는 순간이 A/B에서 각각 왕복 보존됐다. 트랜잭션 안의 `SET LOCAL TIME ZONE 'Pacific/Honolulu'`는 rollback 뒤 다음 요청에 남지 않았다. 전체 애플리케이션의 JSON 직렬화·모든 날짜 유형·다른 JVM 기본 시간대까지 보증하는 결과는 아니다.
+
+## 이번 계약 시험의 완료 판정
+
+아래의 ‘완료’는 **처음 정한 라우팅·세션 계약 확인 항목**에 대한 판정이지, 장애·부하 시험까지 합격했다는 뜻이 아니다.
+
+| 성공 기준 | 판정과 증거 | 주장하지 않는 범위 |
+| --- | --- | --- |
+| 1. OpenSQL·OpenProxy·Patroni·etcd·PostgreSQL·pgJDBC·Hikari 버전 | 완료. 노드 JSON의 설치 바이너리·JDBC 서버/드라이버 메타데이터·Gradle 해석 결과를 기록했다. | Hikari를 이 시험에서 실행한 것은 아니다. |
+| 2. Patroni·etcd·OpenProxy·systemd 관련 설정 | 완료. Patroni 동적 설정, etcd 멤버 및 실행 프로세스·바이너리 시간 입력, OpenProxy 설치·관리 설정, 실제 Docker/호스트 unit 상태를 구분해 기록했다. | `primary_start_timeout`처럼 명시되지 않은 Patroni 값과 이후 장애 동작은 추정하지 않는다. |
+| 3. 프록시 A/B 계약 설정 일치 | 완료. `assemble`이 설치 TOML 허용 항목과 `SHOW CONFIG` 허용 항목을 각각 비교했다. | 모든 비밀값·주소를 포함한 원본 파일의 byte-for-byte 일치는 공개하지 않는다. |
+| 4. A/B 명시적 쓰기 트랜잭션 → primary | 완료. 두 프록시에서 0행 UPDATE를 같은 트랜잭션에서 실행하고 rollback했다. | 실제 대량 쓰기 처리량은 측정하지 않았다. |
+| 5. `prepareThreshold=5/1` 반복 | 완료. 양 프록시 × 두 임계값 × 15회, 결과·server prepare·마지막 backend 준비문 존재를 확인했다. | backend 교체 후의 재준비는 미검증이다. |
+| 6. 자동 커밋·read-only·read-write 도착 역할 | 완료. JDBC 관측값은 각각 standby·standby·primary였다. | Spring `@Transactional` 전체 호출 경로와 물리 standby별 분포는 미검증이다. |
+| 7. SQL-level과 프로토콜 준비문 구분 | 완료. `Statement`의 SQL-level `PREPARE/EXECUTE/DEALLOCATE`와 `PGStatement`의 프로토콜 server prepare를 별도 시험했다. | SQL-level 준비문의 트랜잭션 간·backend 간 유지 여부는 미검증이다. |
+| 8. JVM·DB·OS 시간대와 경계 | 완료. JVM/DB `Asia/Seoul`, VM/컨테이너 `UTC+0000`을 기록하고 양 프록시의 자정 경계·`SET LOCAL` 복원을 확인했다. | 모든 날짜 직렬화 경로까지 검증하지 않았다. |
+| 9. 비식별 canonical JSON·SHA-256 | 완료. 새 자동 수집·병합 경로가 노드 3개와 관리자 결과 2개를 검증·해시한다. | 해시는 수집 코드와 공개 파일의 일관성 지표이지 클라우드 제공자의 서명은 아니다. |
+| 10. 후속 HA 판정 기준 | 완료. 바로 아래에 사전조건·통과·경고·즉시 중단 기준을 명시했다. | 장애 결과가 나왔다는 뜻은 아니다. |
+
+각 항목의 **실행 위치·명령/코드·목적·관측값·해석**은 별도의 상세 기록으로 나눴다: [제품·드라이버 버전](opensql-contract-verification/product-and-driver-versions.md), [HA 설정·실행 감독](opensql-contract-verification/ha-settings-and-runtime-supervision.md), [A/B 설정 일치](opensql-contract-verification/openproxy-a-b-config-parity.md), [쓰기 트랜잭션](opensql-contract-verification/explicit-write-transaction-primary.md), [pgJDBC 반복 준비문](opensql-contract-verification/pgjdbc-prepared-threshold-repeat.md), [자동 커밋·read-only·read-write 라우팅](opensql-contract-verification/autocommit-readonly-readwrite-routing.md), [SQL-level·프로토콜 준비문 구분](opensql-contract-verification/sql-prepare-vs-protocol-prepare.md), [시간대·자정 경계](opensql-contract-verification/timezone-and-midnight-boundary.md), [비식별 JSON·해시](opensql-contract-verification/sanitized-snapshot-and-sha256.md), [후속 HA 판정 기준](opensql-contract-verification/ha-fault-test-acceptance-gates.md). 마지막 문서는 **실행한 장애 결과가 아니라 앞으로 적용할 판정 규칙**이다.
+
+## 후속 HA 시험의 사전 판정 기준
+
+아래 시간은 **DocGrid의 시험 목표값**이며 OpenSQL의 제품 보장 수치가 아니다. 부하 발생기·앱은 DB 노드 밖의 GCP 내부에서 실행한다. 모든 요청의 `request_id`, HTTP 결과와 최종 DB 반영을 대조한 뒤 판정한다. RTO는 장애 시각이 아니라 **마지막 정상 성공부터 30초 연속 안정 구간의 시작까지**로 정의한다. RPO는 성공 응답을 받은 `request_id` 중 최종 DB에 없는 개수로 보고한다. 재시도 주체(HTTP 부하 발생기·앱·Worker)를 각각 구분한다.
+
+| 시험 | 시작 전 필수 확인 | 통과 목표 | 경고·실패 또는 즉시 중단 |
+| --- | --- | --- | --- |
+| OpenProxy A/B 지속 중단 | A/B 설정 일치, 양쪽 경로에서 위 5개 계약 시험 통과, 정상 최대 안정 처리량 측정 후 60~70% 부하, 실제 앱 URL의 유한한 연결·소켓 제한시간 확인 | 한쪽 중단 후 새 연결이 다른 프록시로 도달, RTO 30초 이내, 성공 응답 누락·중복 0건, 오류·결과 불명 요청 수 공개 | RTO 30~60초 경고, 60초 초과 실패. 상대 프록시에도 접속하지 못하거나 성공 응답이 사라지면 즉시 중단한다. |
+| PostgreSQL 프로세스 종료 | Patroni 역할·timeline·WAL LSN 기록, 복구 경로 확인 | 같은 노드 재시작인지 새 리더 선출인지 **실제 결과로** 분류, RTO 60초 이내, 성공 응답 누락·중복 0건 | 프로세스 종료를 곧바로 ‘새 리더 선출’로 세지 않는다. 이중 writable primary 또는 원장 대조 불가능 시 즉시 중단한다. |
+| 리더 VM 상실 | 기존 리더 격리 방식·복구 절차 확정, Patroni/etcd 건강 상태 3/3, 요청 원장 DB 밖 보관 | 새 단일 리더 선출과 라우팅 회복 RTO 120초 이내, RPO와 모든 실패·재시도 건수 공개 | RTO 초과 실패. 비동기 복제에서 RPO>0이면 **DocGrid의 무손실 목표 실패**로 보고하고 제품이 무조건 0손실을 보장한다고 주장하지 않는다. 이중 리더면 즉시 중단한다. |
+| Worker 인덱싱 중 리더 상실 | 시험 전용 pause 지점과 lease 만료·재처리 관측, 최종 DB 중복 키 점검 | 최종 완료 문서·청크·임베딩·Outbox 누락 및 중복 0건 | 최종 상태 불일치나 재처리 불능이면 실패. 미완료 작업을 지우고 재시도 성공으로 꾸미지 않는다. |
+| etcd 멤버 장애 | 스냅샷·복구 절차 검증 후 별도 실행, `failsafe_mode=true`와 정족수 2/3 확인 | 1대 상실과 2대 상실을 구분하고 단일 writable primary·복구 후 정상 복제를 증명 | 2대 상실 시 무조건 쓰기 정지를 기대하지 않는다. 이중 리더, DCS 복구 불능, 스냅샷 부재 시 즉시 중단한다. |
+
+공통으로 요청 실패율·p95/p99는 **요청 표본**에서 계산하고, 5회 장애 반복의 RTO는 개별 값과 범위를 제시한다. 반복 5개의 p95를 성능 지표처럼 사용하지 않는다. 장애 주입 전 Patroni 멤버가 3/3 정상·etcd 멤버가 3/3 정상·두 프록시 관리 상태가 정상이라는 사전조건을 충족하지 못하면 주입하지 않는다. 실패 후에는 원래 토폴로지·설정·복제 상태로 돌아왔는지도 별도로 판정한다.
+
+## 다음 작업의 전제와 미검증 사항
+
+- OpenProxy 프로세스 종료·지속 stop·패킷 DROP은 서로 다른 장애다. 현재 실제 supervisor 상태를 기준으로 각각 따로 주입한다.
+- Patroni `failsafe_mode=true`이므로 etcd 정족수 상실 시 무조건 쓰기 정지를 기대하지 않는다. `primary_start_timeout`은 동적 설정에 없으므로 기본값·실제 failover 시간을 별도 검증한다.
+- 읽기 권한·역할 조회를 primary에 고정할 설계는 이 PR에 포함하지 않는다. standby 읽기 분산이 존재한다는 사실만으로 권한 회수의 즉시성을 주장할 수 없다.
+- 이번 실측은 라우팅·세션 계약의 기준선이다. 프록시 A/B 장애 전환, 리더 상실, Worker 멱등 복구, 실제 부하·p95/p99는 후속 시험이다.
+
+참조: [OpenProxy 설정·관리 콘솔](https://docs.tibero.com/tmaxopensql.en/installation/configuration/openproxy), [OpenProxy 읽기/쓰기 라우팅과 캐시](https://docs.tibero.com/tmaxopensql.en/administration/openproxy/load-balancing), [pgJDBC server-side prepare](https://jdbc.postgresql.org/documentation/server-prepare/), [Patroni 동적 설정](https://patroni.readthedocs.io/en/latest/dynamic_configuration.html).
diff --git a/docs/test-results/opensql-contract-evidence/contract-manifest.json b/docs/test-results/opensql-contract-evidence/contract-manifest.json
new file mode 100644
index 0000000..d227d3b
--- /dev/null
+++ b/docs/test-results/opensql-contract-evidence/contract-manifest.json
@@ -0,0 +1,391 @@
+{
+ "evidence_sha256": "0a0b86eff71c4c24740a99ea0f334fca70e5c11e1a813c3a3bc646a825549794",
+ "snapshot": {
+ "admins": [
+ {
+ "effective_config": {
+ "connect_timeout": 10000,
+ "pools.docgrid.default_role": "primary",
+ "pools.docgrid.load_balancing_mode": "Random",
+ "pools.docgrid.pool_mode": "Transaction",
+ "pools.docgrid.prepared_statements_cache_size": 0,
+ "pools.docgrid.primary_reads_enabled": false,
+ "pools.docgrid.query_parser_enabled": true,
+ "pools.docgrid.query_parser_read_write_splitting": true,
+ "shutdown_timeout": 60000
+ },
+ "node": "node2",
+ "proxy": "proxy-a",
+ "servers": [],
+ "stats": [
+ {
+ "errors": 0,
+ "queries": 1068,
+ "role": "primary",
+ "transactions": 333
+ },
+ {
+ "errors": 0,
+ "queries": 44,
+ "role": "replica_0",
+ "transactions": 32
+ },
+ {
+ "errors": 0,
+ "queries": 60,
+ "role": "replica_1",
+ "transactions": 40
+ }
+ ]
+ },
+ {
+ "effective_config": {
+ "connect_timeout": 10000,
+ "pools.docgrid.default_role": "primary",
+ "pools.docgrid.load_balancing_mode": "Random",
+ "pools.docgrid.pool_mode": "Transaction",
+ "pools.docgrid.prepared_statements_cache_size": 0,
+ "pools.docgrid.primary_reads_enabled": false,
+ "pools.docgrid.query_parser_enabled": true,
+ "pools.docgrid.query_parser_read_write_splitting": true,
+ "shutdown_timeout": 60000
+ },
+ "node": "node3",
+ "proxy": "proxy-b",
+ "servers": [],
+ "stats": [
+ {
+ "errors": 0,
+ "queries": 1387,
+ "role": "primary",
+ "transactions": 469
+ },
+ {
+ "errors": 0,
+ "queries": 127,
+ "role": "replica_0",
+ "transactions": 87
+ },
+ {
+ "errors": 0,
+ "queries": 112,
+ "role": "replica_1",
+ "transactions": 94
+ }
+ ]
+ }
+ ],
+ "nodes": [
+ {
+ "container_time_zone": "UTC+0000",
+ "etcd": {
+ "configured_member_names": [
+ "node1",
+ "node2",
+ "node3"
+ ],
+ "explicit_timing": {
+ "election_timeout_ms": null,
+ "heartbeat_interval_ms": null
+ },
+ "initial_cluster_state": "new",
+ "live_member_names": [
+ "node1",
+ "node2",
+ "node3"
+ ],
+ "quorum": 2,
+ "running_timing": {
+ "election_timeout_ms": {
+ "source": "installed binary default",
+ "value": 1000
+ },
+ "heartbeat_interval_ms": {
+ "source": "installed binary default",
+ "value": 100
+ }
+ }
+ },
+ "node": "node1",
+ "os": {
+ "architecture": "x86_64",
+ "id": "rocky",
+ "version_id": "9.7"
+ },
+ "patroni_dynamic": {
+ "check_timeline": null,
+ "failsafe_mode": true,
+ "loop_wait": 10,
+ "maximum_lag_on_failover": 1048576,
+ "primary_start_timeout": null,
+ "primary_stop_timeout": null,
+ "retry_timeout": 10,
+ "synchronous_mode": null,
+ "synchronous_mode_strict": null,
+ "ttl": 30
+ },
+ "patroni_members": [
+ {
+ "node": "node1",
+ "role": "Leader",
+ "state": "running"
+ },
+ {
+ "node": "node2",
+ "role": "Replica",
+ "state": "streaming"
+ },
+ {
+ "node": "node3",
+ "role": "Replica",
+ "state": "streaming"
+ }
+ ],
+ "runtime": {
+ "container_restart_policy": "unless-stopped",
+ "host_bootstrap_unit": {
+ "ActiveState": "inactive",
+ "Restart": "on-failure",
+ "RestartUSec": "15s"
+ },
+ "host_time_zone": "UTC+0000",
+ "openproxy_live_process_count": 0,
+ "openproxy_parent_is_container_pid1": null
+ },
+ "schema_version": 1,
+ "versions": {
+ "etcd": "etcd Version: 3.6.5",
+ "openproxy": null,
+ "openproxy_revision": null,
+ "opensql": "v3.17.8.7",
+ "patroni": "patroni 4.0.5",
+ "postgres_server_binary": "postgres (PostgreSQL) 17.8",
+ "psql_client": "psql (PostgreSQL) 17.8"
+ }
+ },
+ {
+ "container_time_zone": "UTC+0000",
+ "etcd": {
+ "configured_member_names": [
+ "node1",
+ "node2",
+ "node3"
+ ],
+ "explicit_timing": {
+ "election_timeout_ms": null,
+ "heartbeat_interval_ms": null
+ },
+ "initial_cluster_state": "new",
+ "live_member_names": [
+ "node1",
+ "node2",
+ "node3"
+ ],
+ "quorum": 2,
+ "running_timing": {
+ "election_timeout_ms": {
+ "source": "installed binary default",
+ "value": 1000
+ },
+ "heartbeat_interval_ms": {
+ "source": "installed binary default",
+ "value": 100
+ }
+ }
+ },
+ "node": "node2",
+ "openproxy": {
+ "general": {
+ "connect_timeout": 10000,
+ "prepared_statements_cache_size": 1000
+ },
+ "pools.docgrid": {
+ "default_role": "primary",
+ "pool_mode": "transaction",
+ "query_parser_enabled": true,
+ "query_parser_read_write_splitting": true
+ },
+ "pools.docgrid.shards.0": {
+ "patroni_port": "8008",
+ "use_patroni": true
+ },
+ "pools.docgrid.users.0": {
+ "pool_size": 5
+ }
+ },
+ "openproxy_service_template": {
+ "Restart": "always",
+ "RestartSec": "1"
+ },
+ "os": {
+ "architecture": "x86_64",
+ "id": "rocky",
+ "version_id": "9.7"
+ },
+ "patroni_dynamic": {
+ "check_timeline": null,
+ "failsafe_mode": true,
+ "loop_wait": 10,
+ "maximum_lag_on_failover": 1048576,
+ "primary_start_timeout": null,
+ "primary_stop_timeout": null,
+ "retry_timeout": 10,
+ "synchronous_mode": null,
+ "synchronous_mode_strict": null,
+ "ttl": 30
+ },
+ "patroni_members": [
+ {
+ "node": "node1",
+ "role": "Leader",
+ "state": "running"
+ },
+ {
+ "node": "node2",
+ "role": "Replica",
+ "state": "streaming"
+ },
+ {
+ "node": "node3",
+ "role": "Replica",
+ "state": "streaming"
+ }
+ ],
+ "runtime": {
+ "container_restart_policy": "unless-stopped",
+ "host_bootstrap_unit": {
+ "ActiveState": "inactive",
+ "Restart": "on-failure",
+ "RestartUSec": "15s"
+ },
+ "host_time_zone": "UTC+0000",
+ "openproxy_live_process_count": 1,
+ "openproxy_parent_is_container_pid1": true
+ },
+ "schema_version": 1,
+ "versions": {
+ "etcd": "etcd Version: 3.6.5",
+ "openproxy": "openproxy 1.1.3",
+ "openproxy_revision": "revision number: 723",
+ "opensql": "v3.17.8.7",
+ "patroni": "patroni 4.0.5",
+ "postgres_server_binary": "postgres (PostgreSQL) 17.8",
+ "psql_client": "psql (PostgreSQL) 17.8"
+ }
+ },
+ {
+ "container_time_zone": "UTC+0000",
+ "etcd": {
+ "configured_member_names": [
+ "node1",
+ "node2",
+ "node3"
+ ],
+ "explicit_timing": {
+ "election_timeout_ms": null,
+ "heartbeat_interval_ms": null
+ },
+ "initial_cluster_state": "new",
+ "live_member_names": [
+ "node1",
+ "node2",
+ "node3"
+ ],
+ "quorum": 2,
+ "running_timing": {
+ "election_timeout_ms": {
+ "source": "installed binary default",
+ "value": 1000
+ },
+ "heartbeat_interval_ms": {
+ "source": "installed binary default",
+ "value": 100
+ }
+ }
+ },
+ "node": "node3",
+ "openproxy": {
+ "general": {
+ "connect_timeout": 10000,
+ "prepared_statements_cache_size": 1000
+ },
+ "pools.docgrid": {
+ "default_role": "primary",
+ "pool_mode": "transaction",
+ "query_parser_enabled": true,
+ "query_parser_read_write_splitting": true
+ },
+ "pools.docgrid.shards.0": {
+ "patroni_port": "8008",
+ "use_patroni": true
+ },
+ "pools.docgrid.users.0": {
+ "pool_size": 5
+ }
+ },
+ "openproxy_service_template": {
+ "Restart": "always",
+ "RestartSec": "1"
+ },
+ "os": {
+ "architecture": "x86_64",
+ "id": "rocky",
+ "version_id": "9.7"
+ },
+ "patroni_dynamic": {
+ "check_timeline": null,
+ "failsafe_mode": true,
+ "loop_wait": 10,
+ "maximum_lag_on_failover": 1048576,
+ "primary_start_timeout": null,
+ "primary_stop_timeout": null,
+ "retry_timeout": 10,
+ "synchronous_mode": null,
+ "synchronous_mode_strict": null,
+ "ttl": 30
+ },
+ "patroni_members": [
+ {
+ "node": "node1",
+ "role": "Leader",
+ "state": "running"
+ },
+ {
+ "node": "node2",
+ "role": "Replica",
+ "state": "streaming"
+ },
+ {
+ "node": "node3",
+ "role": "Replica",
+ "state": "streaming"
+ }
+ ],
+ "runtime": {
+ "container_restart_policy": "unless-stopped",
+ "host_bootstrap_unit": {
+ "ActiveState": "inactive",
+ "Restart": "on-failure",
+ "RestartUSec": "15s"
+ },
+ "host_time_zone": "UTC+0000",
+ "openproxy_live_process_count": 1,
+ "openproxy_parent_is_container_pid1": true
+ },
+ "schema_version": 1,
+ "versions": {
+ "etcd": "etcd Version: 3.6.5",
+ "openproxy": "openproxy 1.1.3",
+ "openproxy_revision": "revision number: 723",
+ "opensql": "v3.17.8.7",
+ "patroni": "patroni 4.0.5",
+ "postgres_server_binary": "postgres (PostgreSQL) 17.8",
+ "psql_client": "psql (PostgreSQL) 17.8"
+ }
+ }
+ ],
+ "schema_version": 1
+ },
+ "capture_started_at_utc": "2026-09-26T12:57:23Z",
+ "capture_finished_at_utc": "2026-09-26T12:58:14Z"
+}
diff --git a/docs/test-results/opensql-contract-evidence/junit-summary.json b/docs/test-results/opensql-contract-evidence/junit-summary.json
new file mode 100644
index 0000000..d18babb
--- /dev/null
+++ b/docs/test-results/opensql-contract-evidence/junit-summary.json
@@ -0,0 +1,56 @@
+{
+ "cases": [
+ {
+ "name": "timestamp와 timestamptz의 한국 시간 자정 경계를 보존한다",
+ "status": "passed"
+ },
+ {
+ "name": "SQL-level PREPARE/EXECUTE를 pgJDBC 프로토콜 준비문과 구별한다",
+ "status": "passed"
+ },
+ {
+ "name": "기본·강제 server-side prepared statement를 여러 트랜잭션에서 반복한다",
+ "status": "passed"
+ },
+ {
+ "name": "자동 커밋 조회와 명시적 읽기·쓰기 트랜잭션의 DB 역할을 구분한다",
+ "status": "passed"
+ },
+ {
+ "name": "Worker의 FOR UPDATE SKIP LOCKED 조회는 standby에서 거부되지 않는다",
+ "status": "passed"
+ }
+ ],
+ "duration_seconds": 74.608,
+ "errors": 0,
+ "executed_at_utc": "2026-09-26T12:46:25.416Z",
+ "failures": 0,
+ "observations": [
+ "CONTRACT_TIMEZONE proxy=proxy-a jvm=Asia/Seoul db=Asia/Seoul boundary=pass",
+ "CONTRACT_SERVER proxy=proxy-a postgresql=17.8",
+ "CONTRACT_DRIVER proxy=proxy-a pgjdbc=42.7.11",
+ "CONTRACT_TIMEZONE proxy=proxy-b jvm=Asia/Seoul db=Asia/Seoul boundary=pass",
+ "CONTRACT_SERVER proxy=proxy-b postgresql=17.8",
+ "CONTRACT_DRIVER proxy=proxy-b pgjdbc=42.7.11",
+ "CONTRACT_SQL_PREPARE proxy=proxy-a result=same_transaction_pass",
+ "CONTRACT_SQL_PREPARE proxy=proxy-b result=same_transaction_pass",
+ "CONTRACT_PREPARED proxy=proxy-a threshold=5 executions=15 distinct_backends=1 prepared_on_last_backend=22",
+ "CONTRACT_PREPARED proxy=proxy-a threshold=1 executions=15 distinct_backends=1 prepared_on_last_backend=40",
+ "CONTRACT_PREPARED proxy=proxy-b threshold=5 executions=15 distinct_backends=1 prepared_on_last_backend=22",
+ "CONTRACT_PREPARED proxy=proxy-b threshold=1 executions=15 distinct_backends=1 prepared_on_last_backend=40",
+ "CONTRACT_ROUTE proxy=proxy-a operation=autocommit-select role=standby",
+ "CONTRACT_ROUTE proxy=proxy-a operation=read-write-transaction role=primary",
+ "CONTRACT_ROUTE proxy=proxy-a operation=read-only-transaction role=standby",
+ "CONTRACT_ROUTE proxy=proxy-b operation=autocommit-select role=standby",
+ "CONTRACT_ROUTE proxy=proxy-b operation=read-write-transaction role=primary",
+ "CONTRACT_ROUTE proxy=proxy-b operation=read-only-transaction role=standby",
+ "CONTRACT_LOCK proxy=proxy-a table=embedding_jobs route=primary",
+ "CONTRACT_LOCK proxy=proxy-a table=sync_outbox_events route=primary",
+ "CONTRACT_LOCK proxy=proxy-a table=rag_responses route=primary",
+ "CONTRACT_LOCK proxy=proxy-b table=embedding_jobs route=primary",
+ "CONTRACT_LOCK proxy=proxy-b table=sync_outbox_events route=primary",
+ "CONTRACT_LOCK proxy=proxy-b table=rag_responses route=primary"
+ ],
+ "skipped": 0,
+ "tests": 5
+}
diff --git a/docs/test-results/opensql-contract-evidence/node1.json b/docs/test-results/opensql-contract-evidence/node1.json
new file mode 100644
index 0000000..5f8beca
--- /dev/null
+++ b/docs/test-results/opensql-contract-evidence/node1.json
@@ -0,0 +1,87 @@
+{
+ "container_time_zone": "UTC+0000",
+ "etcd": {
+ "configured_member_names": [
+ "node1",
+ "node2",
+ "node3"
+ ],
+ "explicit_timing": {
+ "election_timeout_ms": null,
+ "heartbeat_interval_ms": null
+ },
+ "initial_cluster_state": "new",
+ "live_member_names": [
+ "node1",
+ "node2",
+ "node3"
+ ],
+ "quorum": 2,
+ "running_timing": {
+ "election_timeout_ms": {
+ "source": "installed binary default",
+ "value": 1000
+ },
+ "heartbeat_interval_ms": {
+ "source": "installed binary default",
+ "value": 100
+ }
+ }
+ },
+ "node": "node1",
+ "os": {
+ "architecture": "x86_64",
+ "id": "rocky",
+ "version_id": "9.7"
+ },
+ "patroni_dynamic": {
+ "check_timeline": null,
+ "failsafe_mode": true,
+ "loop_wait": 10,
+ "maximum_lag_on_failover": 1048576,
+ "primary_start_timeout": null,
+ "primary_stop_timeout": null,
+ "retry_timeout": 10,
+ "synchronous_mode": null,
+ "synchronous_mode_strict": null,
+ "ttl": 30
+ },
+ "patroni_members": [
+ {
+ "node": "node1",
+ "role": "Leader",
+ "state": "running"
+ },
+ {
+ "node": "node2",
+ "role": "Replica",
+ "state": "streaming"
+ },
+ {
+ "node": "node3",
+ "role": "Replica",
+ "state": "streaming"
+ }
+ ],
+ "runtime": {
+ "container_restart_policy": "unless-stopped",
+ "host_bootstrap_unit": {
+ "ActiveState": "inactive",
+ "Restart": "on-failure",
+ "RestartUSec": "15s"
+ },
+ "host_time_zone": "UTC+0000",
+ "openproxy_live_process_count": 0,
+ "openproxy_parent_is_container_pid1": null
+ },
+ "schema_version": 1,
+ "versions": {
+ "etcd": "etcd Version: 3.6.5",
+ "openproxy": null,
+ "openproxy_revision": null,
+ "opensql": "v3.17.8.7",
+ "patroni": "patroni 4.0.5",
+ "postgres_server_binary": "postgres (PostgreSQL) 17.8",
+ "psql_client": "psql (PostgreSQL) 17.8"
+ }
+}
diff --git a/docs/test-results/opensql-contract-evidence/node2.json b/docs/test-results/opensql-contract-evidence/node2.json
new file mode 100644
index 0000000..508a956
--- /dev/null
+++ b/docs/test-results/opensql-contract-evidence/node2.json
@@ -0,0 +1,110 @@
+{
+ "container_time_zone": "UTC+0000",
+ "etcd": {
+ "configured_member_names": [
+ "node1",
+ "node2",
+ "node3"
+ ],
+ "explicit_timing": {
+ "election_timeout_ms": null,
+ "heartbeat_interval_ms": null
+ },
+ "initial_cluster_state": "new",
+ "live_member_names": [
+ "node1",
+ "node2",
+ "node3"
+ ],
+ "quorum": 2,
+ "running_timing": {
+ "election_timeout_ms": {
+ "source": "installed binary default",
+ "value": 1000
+ },
+ "heartbeat_interval_ms": {
+ "source": "installed binary default",
+ "value": 100
+ }
+ }
+ },
+ "node": "node2",
+ "openproxy": {
+ "general": {
+ "connect_timeout": 10000,
+ "prepared_statements_cache_size": 1000
+ },
+ "pools.docgrid": {
+ "default_role": "primary",
+ "pool_mode": "transaction",
+ "query_parser_enabled": true,
+ "query_parser_read_write_splitting": true
+ },
+ "pools.docgrid.shards.0": {
+ "patroni_port": "8008",
+ "use_patroni": true
+ },
+ "pools.docgrid.users.0": {
+ "pool_size": 5
+ }
+ },
+ "openproxy_service_template": {
+ "Restart": "always",
+ "RestartSec": "1"
+ },
+ "os": {
+ "architecture": "x86_64",
+ "id": "rocky",
+ "version_id": "9.7"
+ },
+ "patroni_dynamic": {
+ "check_timeline": null,
+ "failsafe_mode": true,
+ "loop_wait": 10,
+ "maximum_lag_on_failover": 1048576,
+ "primary_start_timeout": null,
+ "primary_stop_timeout": null,
+ "retry_timeout": 10,
+ "synchronous_mode": null,
+ "synchronous_mode_strict": null,
+ "ttl": 30
+ },
+ "patroni_members": [
+ {
+ "node": "node1",
+ "role": "Leader",
+ "state": "running"
+ },
+ {
+ "node": "node2",
+ "role": "Replica",
+ "state": "streaming"
+ },
+ {
+ "node": "node3",
+ "role": "Replica",
+ "state": "streaming"
+ }
+ ],
+ "runtime": {
+ "container_restart_policy": "unless-stopped",
+ "host_bootstrap_unit": {
+ "ActiveState": "inactive",
+ "Restart": "on-failure",
+ "RestartUSec": "15s"
+ },
+ "host_time_zone": "UTC+0000",
+ "openproxy_live_process_count": 1,
+ "openproxy_parent_is_container_pid1": true
+ },
+ "schema_version": 1,
+ "versions": {
+ "etcd": "etcd Version: 3.6.5",
+ "openproxy": "openproxy 1.1.3",
+ "openproxy_revision": "revision number: 723",
+ "opensql": "v3.17.8.7",
+ "patroni": "patroni 4.0.5",
+ "postgres_server_binary": "postgres (PostgreSQL) 17.8",
+ "psql_client": "psql (PostgreSQL) 17.8"
+ }
+}
diff --git a/docs/test-results/opensql-contract-evidence/node3.json b/docs/test-results/opensql-contract-evidence/node3.json
new file mode 100644
index 0000000..44699f8
--- /dev/null
+++ b/docs/test-results/opensql-contract-evidence/node3.json
@@ -0,0 +1,110 @@
+{
+ "container_time_zone": "UTC+0000",
+ "etcd": {
+ "configured_member_names": [
+ "node1",
+ "node2",
+ "node3"
+ ],
+ "explicit_timing": {
+ "election_timeout_ms": null,
+ "heartbeat_interval_ms": null
+ },
+ "initial_cluster_state": "new",
+ "live_member_names": [
+ "node1",
+ "node2",
+ "node3"
+ ],
+ "quorum": 2,
+ "running_timing": {
+ "election_timeout_ms": {
+ "source": "installed binary default",
+ "value": 1000
+ },
+ "heartbeat_interval_ms": {
+ "source": "installed binary default",
+ "value": 100
+ }
+ }
+ },
+ "node": "node3",
+ "openproxy": {
+ "general": {
+ "connect_timeout": 10000,
+ "prepared_statements_cache_size": 1000
+ },
+ "pools.docgrid": {
+ "default_role": "primary",
+ "pool_mode": "transaction",
+ "query_parser_enabled": true,
+ "query_parser_read_write_splitting": true
+ },
+ "pools.docgrid.shards.0": {
+ "patroni_port": "8008",
+ "use_patroni": true
+ },
+ "pools.docgrid.users.0": {
+ "pool_size": 5
+ }
+ },
+ "openproxy_service_template": {
+ "Restart": "always",
+ "RestartSec": "1"
+ },
+ "os": {
+ "architecture": "x86_64",
+ "id": "rocky",
+ "version_id": "9.7"
+ },
+ "patroni_dynamic": {
+ "check_timeline": null,
+ "failsafe_mode": true,
+ "loop_wait": 10,
+ "maximum_lag_on_failover": 1048576,
+ "primary_start_timeout": null,
+ "primary_stop_timeout": null,
+ "retry_timeout": 10,
+ "synchronous_mode": null,
+ "synchronous_mode_strict": null,
+ "ttl": 30
+ },
+ "patroni_members": [
+ {
+ "node": "node1",
+ "role": "Leader",
+ "state": "running"
+ },
+ {
+ "node": "node2",
+ "role": "Replica",
+ "state": "streaming"
+ },
+ {
+ "node": "node3",
+ "role": "Replica",
+ "state": "streaming"
+ }
+ ],
+ "runtime": {
+ "container_restart_policy": "unless-stopped",
+ "host_bootstrap_unit": {
+ "ActiveState": "inactive",
+ "Restart": "on-failure",
+ "RestartUSec": "15s"
+ },
+ "host_time_zone": "UTC+0000",
+ "openproxy_live_process_count": 1,
+ "openproxy_parent_is_container_pid1": true
+ },
+ "schema_version": 1,
+ "versions": {
+ "etcd": "etcd Version: 3.6.5",
+ "openproxy": "openproxy 1.1.3",
+ "openproxy_revision": "revision number: 723",
+ "opensql": "v3.17.8.7",
+ "patroni": "patroni 4.0.5",
+ "postgres_server_binary": "postgres (PostgreSQL) 17.8",
+ "psql_client": "psql (PostgreSQL) 17.8"
+ }
+}
diff --git a/docs/test-results/opensql-contract-evidence/prepared-cache-delta.json b/docs/test-results/opensql-contract-evidence/prepared-cache-delta.json
new file mode 100644
index 0000000..37a25ab
--- /dev/null
+++ b/docs/test-results/opensql-contract-evidence/prepared-cache-delta.json
@@ -0,0 +1,42 @@
+{
+ "schema_version": 1,
+ "experiment": "prepared-statement-repeat",
+ "executions_per_proxy": 30,
+ "notes": "Counters are cumulative snapshots, not isolated per-query traces.",
+ "proxies": {
+ "proxy-a": {
+ "before": {
+ "hit": 526,
+ "miss": 212,
+ "eviction": 0
+ },
+ "after": {
+ "hit": 750,
+ "miss": 292,
+ "eviction": 0
+ },
+ "delta": {
+ "hit": 224,
+ "miss": 80,
+ "eviction": 0
+ }
+ },
+ "proxy-b": {
+ "before": {
+ "hit": 514,
+ "miss": 204,
+ "eviction": 0
+ },
+ "after": {
+ "hit": 738,
+ "miss": 284,
+ "eviction": 0
+ },
+ "delta": {
+ "hit": 224,
+ "miss": 80,
+ "eviction": 0
+ }
+ }
+ }
+}
diff --git a/docs/test-results/opensql-contract-evidence/proxy-a-admin.json b/docs/test-results/opensql-contract-evidence/proxy-a-admin.json
new file mode 100644
index 0000000..6b9b985
--- /dev/null
+++ b/docs/test-results/opensql-contract-evidence/proxy-a-admin.json
@@ -0,0 +1,36 @@
+{
+ "effective_config": {
+ "connect_timeout": 10000,
+ "pools.docgrid.default_role": "primary",
+ "pools.docgrid.load_balancing_mode": "Random",
+ "pools.docgrid.pool_mode": "Transaction",
+ "pools.docgrid.prepared_statements_cache_size": 0,
+ "pools.docgrid.primary_reads_enabled": false,
+ "pools.docgrid.query_parser_enabled": true,
+ "pools.docgrid.query_parser_read_write_splitting": true,
+ "shutdown_timeout": 60000
+ },
+ "node": "node2",
+ "proxy": "proxy-a",
+ "servers": [],
+ "stats": [
+ {
+ "errors": 0,
+ "queries": 1068,
+ "role": "primary",
+ "transactions": 333
+ },
+ {
+ "errors": 0,
+ "queries": 44,
+ "role": "replica_0",
+ "transactions": 32
+ },
+ {
+ "errors": 0,
+ "queries": 60,
+ "role": "replica_1",
+ "transactions": 40
+ }
+ ]
+}
diff --git a/docs/test-results/opensql-contract-evidence/proxy-b-admin.json b/docs/test-results/opensql-contract-evidence/proxy-b-admin.json
new file mode 100644
index 0000000..6f1f4bb
--- /dev/null
+++ b/docs/test-results/opensql-contract-evidence/proxy-b-admin.json
@@ -0,0 +1,36 @@
+{
+ "effective_config": {
+ "connect_timeout": 10000,
+ "pools.docgrid.default_role": "primary",
+ "pools.docgrid.load_balancing_mode": "Random",
+ "pools.docgrid.pool_mode": "Transaction",
+ "pools.docgrid.prepared_statements_cache_size": 0,
+ "pools.docgrid.primary_reads_enabled": false,
+ "pools.docgrid.query_parser_enabled": true,
+ "pools.docgrid.query_parser_read_write_splitting": true,
+ "shutdown_timeout": 60000
+ },
+ "node": "node3",
+ "proxy": "proxy-b",
+ "servers": [],
+ "stats": [
+ {
+ "errors": 0,
+ "queries": 1387,
+ "role": "primary",
+ "transactions": 469
+ },
+ {
+ "errors": 0,
+ "queries": 127,
+ "role": "replica_0",
+ "transactions": 87
+ },
+ {
+ "errors": 0,
+ "queries": 112,
+ "role": "replica_1",
+ "transactions": 94
+ }
+ ]
+}
diff --git a/docs/test-results/opensql-contract-verification/autocommit-readonly-readwrite-routing.md b/docs/test-results/opensql-contract-verification/autocommit-readonly-readwrite-routing.md
new file mode 100644
index 0000000..34fa126
--- /dev/null
+++ b/docs/test-results/opensql-contract-verification/autocommit-readonly-readwrite-routing.md
@@ -0,0 +1,36 @@
+# 자동 커밋·read-only·read-write JDBC 라우팅 확인
+
+## 검증 질문
+
+OpenProxy가 모든 SELECT를 primary로 보내는지, 자동 커밋 SELECT와 명시적 read-only/read-write 트랜잭션을 다르게 처리하는지 **실제 SQL 실행 DB의 역할**로 확인했다. 이 구분은 권한 회수 뒤 최신 역할을 읽어야 하는 경로와, 조금 늦어도 되는 조회 경로를 설계할 때 중요하다.
+
+## 실행 명령·위치
+
+개발자 컴퓨터의 저장소 루트에서 `./backend/gradlew -p backend openSqlOpenProxyContractTest --rerun-tasks --offline`를 실행했다. A/B JDBC URL은 임시 SSH 터널을 통해 GCP node2/node3 OpenProxy로 갔다. Java 코드는 **개발자 컴퓨터 JVM**에서 실행됐지만 `SELECT pg_is_in_recovery()`는 **프록시를 지난 DB 세션**에서 실행됐다. 이 구별 때문에 “로컬에서 SQL을 실행했다”라고만 표현하면 오해가 생긴다.
+
+[`routesQueriesByTransactionContract()`](../../../backend/src/test/java/com/opensource/docgrid/opensql/OpenSqlOpenProxyContractTest.java)의 실제 세 경로는 아래와 같다.
+
+| 경로 | JDBC 코드/원격 SQL | 왜 이렇게 했나 | 결과 요약 |
+| --- | --- | --- | --- |
+| 계약 JUnit 실행 | 개발자 컴퓨터에서 `./backend/gradlew -p backend openSqlOpenProxyContractTest --rerun-tasks --offline` | A/B의 세 JDBC 경로를 같은 시험 실행에서 비교한다. | 전체 계약 JUnit 5개 통과·실패 0건; 아래 여섯 역할 출력이 기록됐다. |
+| 자동 커밋 SELECT | 기본 auto-commit 연결에서 `SELECT pg_is_in_recovery()` | 단일 조회가 실제 어떤 역할의 DB에 도착하는지 본다. | A/B 모두 `true`, 즉 standby 도착. 물리 standby 번호는 미확인. |
+| 명시적 읽기·쓰기 | `setAutoCommit(false)` → `SELECT pg_is_in_recovery()` → `UPDATE documents SET id=id WHERE id=-1` → `rollback()` | 쓰기 가능 트랜잭션이 primary에 선제적으로 고정되는지 확인하고 데이터 변경은 남기지 않는다. | A/B 모두 `false`, 즉 primary 도착; UPDATE 영향 행 `0`, 오류 없음. |
+| JDBC read-only | `setReadOnly(true)` → `setAutoCommit(false)` → `SELECT pg_is_in_recovery()` → `rollback()` | read-only 힌트가 primary 일관성을 보장하는지 **가정하지 않고** 측정한다. | A/B 모두 `true`, 즉 standby 도착. Spring `@Transactional` 경로는 별도 미검증. |
+
+SQL 함수 `pg_is_in_recovery()`가 `true`면 그 SQL이 standby에서 실행된 것이고, `false`면 primary에서 실행된 것이다. 이 결과는 관리 콘솔의 누적 통계보다 해당 요청의 역할을 직접 보여준다.
+
+## 실제 결과
+
+| JDBC 경로 | proxy-a | proxy-b | 해석 |
+| --- | --- | --- | --- |
+| 자동 커밋 SELECT | standby | standby | 단순 SELECT가 두 프록시 모두에서 읽기 복제본으로 갔다. |
+| 명시적 읽기·쓰기 | primary | primary | SELECT 단계부터 primary였고 뒤의 0행 UPDATE가 성공했다. |
+| JDBC read-only 트랜잭션 | standby | standby | read-only라는 이유만으로 primary 최신성을 얻지 못했다. |
+
+위 6개 `CONTRACT_ROUTE` 출력은 [JUnit 요약](../opensql-contract-evidence/junit-summary.json)에 그대로 남아 있다. 같은 실행의 `FOR UPDATE SKIP LOCKED` 세 테이블 × 두 프록시도 성공했지만, 그 시험은 잠금 SQL이 허용됐다는 **간접 primary 증거**이고 이 표의 `pg_is_in_recovery()` 직접 측정과는 증거 성격이 다르다.
+
+## 어떤 결론을 내릴 수 있나
+
+이번 설치·설정에서는 자동 커밋 조회와 JDBC read-only 트랜잭션이 standby로 갈 수 있다. 따라서 권한 변경 직후 최신 역할을 반드시 확인해야 하는 로직에 단순히 `@Transactional(readOnly=true)`를 붙이면 안전해진다는 주장은 성립하지 않는다. 다만 **Spring 어노테이션을 붙인 실제 서비스 메서드**를 이 시험에서 호출한 것은 아니다. Spring/Hikari가 트랜잭션 시작 시 언제 연결을 빌리고 어떤 SQL을 앞세우는지는 후속 앱 통합 시험에서 확인해야 한다.
+
+또한 `standby`는 DB 역할만 뜻한다. 각 SELECT가 node2와 node3 중 어느 물리 standby로 갔는지, 두 standby로 균등 분산됐는지, 복제 지연이 얼마였는지는 이 JUnit으로 판정하지 않았다. `SHOW STATS`의 역할별 카운터는 누적값이고 각 테스트 요청과 1:1로 연결되지 않는다. 따라서 이 기준의 **라우팅 역할 확인은 완료**지만 **물리 노드별 로드밸런싱 효과는 미검증**이다.
diff --git a/docs/test-results/opensql-contract-verification/explicit-write-transaction-primary.md b/docs/test-results/opensql-contract-verification/explicit-write-transaction-primary.md
new file mode 100644
index 0000000..8de8081
--- /dev/null
+++ b/docs/test-results/opensql-contract-verification/explicit-write-transaction-primary.md
@@ -0,0 +1,53 @@
+# 명시적 쓰기 트랜잭션의 primary 도착 확인
+
+## 검증하려는 계약
+
+DocGrid가 두 OpenProxy 중 어느 쪽에 접속해도 **명시적 트랜잭션의 `SELECT → UPDATE`가 쓰기 가능한 primary에서 실행되는지** 확인했다. 이 테스트는 라우팅의 기능 기준선이며, 실제 문서를 수정하거나 쓰기 처리량을 측정하지 않는다.
+
+## 실행 위치와 명령
+
+JUnit은 개발자 컴퓨터의 저장소 루트에서 실행했다. 두 개의 SSH 터널은 각각 기존 GCP node2의 OpenProxy A, node3의 OpenProxy B 서비스 포트로 연결했다. 터널은 원격 접근 수단일 뿐 HA·성능 측정 경로가 아니다. 실제 실행 명령의 테스트 부분은 다음과 같다.
+
+```bash
+./backend/gradlew -p backend openSqlOpenProxyContractTest --rerun-tasks --offline
+```
+
+실행 시 `OPENSQL_PROXY_A_JDBC_URL`, `OPENSQL_PROXY_B_JDBC_URL`, `OPENSQL_APP_USER`, `OPENSQL_APP_PASSWORD`를 **셸 환경 변수**로 제공했다. A/B URL에는 이 수동 시험을 위한 `connectTimeout=5&socketTimeout=15`를 붙였다. 이 값으로 운영 `.env`를 변경했다는 뜻은 아니다. 암호는 공개 로그·Git에 넣지 않았다. 같은 한 번의 JUnit 실행에서 아래 SQL 테스트와 준비문·시간대 테스트 등 총 5개가 함께 돌았다.
+
+| 장소 | 수행 주체 | 수행 내용 | 이유 | 결과 요약 |
+| --- | --- | --- | --- | --- |
+| 개발자 컴퓨터의 저장소 루트 | Gradle | `./backend/gradlew -p backend openSqlOpenProxyContractTest --rerun-tasks --offline` | A/B에 대한 계약 JUnit 5개를 실행한다. | 2026-09-26 12:46 UTC 실행에서 5개 통과·실패 0건. 이 문서의 쓰기 시험은 그중 하나다. |
+| 개발자 컴퓨터 JVM | [`routesQueriesByTransactionContract()`](../../../backend/src/test/java/com/opensource/docgrid/opensql/OpenSqlOpenProxyContractTest.java) | A/B마다 JDBC 연결을 만들고 `setAutoCommit(false)` | 명시적 트랜잭션의 라우팅을 분리 측정한다. | 두 프록시의 명시적 읽기·쓰기 경로가 모두 통과했다. |
+| A/B OpenProxy를 지난 DB 세션 | JDBC `Statement` | `SELECT pg_is_in_recovery()` | `false`면 현재 SQL이 primary에서 실행됐음을 직접 확인한다. | A/B 모두 `false`를 반환해 `role=primary`로 기록됐다. |
+| 같은 트랜잭션의 DB 세션 | JDBC `PreparedStatement` | `UPDATE documents SET id = id WHERE id = ?`에 `-1` 바인딩 | 쓰기 SQL도 같은 트랜잭션에서 허용되는지 확인한다. 존재하지 않는 ID라 0행이어야 한다. | A/B 모두 오류 없이 실행됐고 영향 행은 `0`이었다. 실제 행 변경의 내구성은 검증하지 않는다. |
+| 개발자 컴퓨터 JVM | JDBC `Connection` | `connection.rollback()` | 테스트가 운영 데이터를 남기지 않도록 한다. | 두 경로 모두 rollback 호출이 오류 없이 끝났다. 변경 행이 0개였으므로 rollback의 데이터 복구 효과를 별도로 측정한 것은 아니다. |
+
+핵심 코드의 실제 순서는 아래와 같다. 의도를 보여주기 위해 핵심 호출만 발췌했으며 전체 구현은 위 파일을 참조한다.
+
+```java
+connection.setAutoCommit(false);
+boolean standby = isStandby(connection); // SELECT pg_is_in_recovery()
+assertThat(standby).isFalse();
+update.setLong(1, -1L);
+assertThat(update.executeUpdate()).isZero();
+connection.rollback();
+```
+
+추가로 같은 JUnit의 [`workerLockingSelectUsesPrimary()`](../../../backend/src/test/java/com/opensource/docgrid/opensql/OpenSqlOpenProxyContractTest.java)는 `embedding_jobs`, `sync_outbox_events`, `rag_responses`에서 `WHERE 1=0 ... FOR UPDATE SKIP LOCKED`를 A/B로 조회했다. 빈 결과이므로 행을 잠그거나 바꾸지는 않는다. standby에서는 `FOR UPDATE`가 허용되지 않으므로 **성공은 primary 라우팅의 간접 증거**다. 이 보조 시험에서는 `pg_is_in_recovery()`를 각 잠금 SQL 안에 넣어 직접 물리 역할을 출력한 것은 아니다.
+
+## 관측값과 해석
+
+[JUnit 요약](../opensql-contract-evidence/junit-summary.json)에 다음 결과가 남았다.
+
+```text
+CONTRACT_ROUTE proxy=proxy-a operation=read-write-transaction role=primary
+CONTRACT_ROUTE proxy=proxy-b operation=read-write-transaction role=primary
+CONTRACT_LOCK proxy=proxy-a table=embedding_jobs route=primary
+CONTRACT_LOCK proxy=proxy-b table=embedding_jobs route=primary
+```
+
+잠금 조회는 세 테이블 × 두 프록시 모두 성공했다. 명시적 쓰기 트랜잭션 테스트는 두 프록시 모두 `pg_is_in_recovery()=false`, UPDATE 영향 행 `0`, rollback으로 통과했다. JUnit은 2026-09-26 12:46 UTC에 총 5개/실패 0건이었다. `SELECT`를 먼저 보냈을 때부터 트랜잭션이 primary에 도착했으므로, 뒤의 UPDATE가 standby에서 실패할 위험을 이 단순 경로에서는 관찰하지 않았다.
+
+## 판정 경계
+
+**완료:** 양쪽 프록시에서 명시적 JDBC 쓰기 트랜잭션이 primary로 도착하고 무해한 UPDATE가 성공했다. **미검증:** DocGrid의 실제 Hikari·JPA 서비스 메서드 전체, 장애 직후 기존 커넥션 재연결, 대량 쓰기 처리량, 실제 Worker claim부터 commit까지의 흐름. UPDATE가 0행이라 실제 행 변경의 내구성을 증명하지 않는다. 장애나 재시도 없이 성공한 것을 HA 합격으로 부르지 않는다.
diff --git a/docs/test-results/opensql-contract-verification/ha-fault-test-acceptance-gates.md b/docs/test-results/opensql-contract-verification/ha-fault-test-acceptance-gates.md
new file mode 100644
index 0000000..5bc8525
--- /dev/null
+++ b/docs/test-results/opensql-contract-verification/ha-fault-test-acceptance-gates.md
@@ -0,0 +1,46 @@
+# 후속 HA 장애 시험의 사전 판정 기준
+
+## 이 문서의 성격
+
+이 항목의 “완료”는 **장애 주입 전에 통과·경고·실패·중단 기준을 정의했다**는 뜻이다. 이번 계약 확인에서는 OpenProxy 프로세스 종료, DB 프로세스 종료, 리더 VM 상실, etcd 정족수 상실을 **실행하지 않았다**. 따라서 여기에 RTO/RPO 실측값은 없다. 아래 시간은 DocGrid의 시험 목표이며 OpenSQL 제품 보장 수치가 아니다.
+
+## 실제로 실행한 확인 명령·위치
+
+기준을 정하기 전에 개발자 컴퓨터의 저장소 루트에서 `bash scripts/opensql/capture_live_ha_contract.sh`를 실행해 Patroni/etcd·OpenProxy 설정과 실행 감독을 수집했다. 이 문서를 작성할 때는 같은 worktree에서 다음 읽기 전용 명령으로 공개 [manifest](../opensql-contract-evidence/contract-manifest.json)의 판정 입력을 다시 확인했다.
+
+```bash
+jq '{patroni: [.snapshot.nodes[] | {node, failsafe_mode: .patroni_dynamic.failsafe_mode, ttl: .patroni_dynamic.ttl, primary_start_timeout: .patroni_dynamic.primary_start_timeout}], proxies: [.snapshot.admins[] | {proxy, pool_mode: .effective_config["pools.docgrid.pool_mode"], connect_timeout: .effective_config.connect_timeout, shutdown_timeout: .effective_config.shutdown_timeout}]}' docs/test-results/opensql-contract-evidence/contract-manifest.json
+```
+
+| 실행 위치 | 명령 | 목적 | 결과 요약 |
+| --- | --- | --- | --- |
+| 개발자 컴퓨터의 저장소 루트 | `bash scripts/opensql/capture_live_ha_contract.sh` | 장애 기준을 정하기 전에 현재 설정·실행 감독을 수집한다. | node1/2/3과 proxy-a/b의 비식별 스냅샷이 생성됐다. 장애 주입은 포함하지 않았다. |
+| 같은 worktree | 위의 `jq '{patroni: ..., proxies: ...}'` | 후속 시험 판정에 영향을 주는 설정값만 다시 확인한다. | 세 노드 `failsafe_mode=true`, `ttl=30`, `primary_start_timeout=null`; A/B `pool_mode=Transaction`, `connect_timeout=10000`, `shutdown_timeout=60000`. |
+| 실행하지 않음 | `kill -9`, `systemctl stop`, 방화벽 `DROP`, VM stop/delete, `etcdctl member remove`, `pg_wal_replay_pause()` | 이번 문서가 장애 결과가 아닌 사전 기준임을 분명히 한다. | **미실행**. RTO·RPO·장애 전환 결과는 없다. |
+
+결과는 세 노드 모두 `failsafe_mode=true`, `ttl=30`, `primary_start_timeout=null`; 프록시 A/B 모두 `pool_mode=Transaction`, `connect_timeout=10000`, `shutdown_timeout=60000`이었다. `primary_start_timeout=null`은 동적 설정에 명시되지 않았다는 뜻이지 값 `0`이 아니다. 설치 예시 systemd unit의 재시작 옵션과 실제 실행 감독은 [별도 설정 결과](ha-settings-and-runtime-supervision.md)에 구분했다.
+
+이 단계에서 **실행하지 않은 명령**도 명확히 적는다: `kill -9`, `systemctl stop`, 방화벽 `DROP`, VM stop/delete, `etcdctl member remove`, `pg_wal_replay_pause()` 등. 아래 표는 그 명령들을 실행한 결과가 아니라 안전하게 시험하기 위한 사전 약속이다.
+
+## 측정 규칙
+
+- 부하 발생기와 앱은 DB 3노드 밖의 **GCP 내부**에서 돌린다. SSH 터널을 통해 장애 시간이나 p95/p99를 재지 않는다.
+- 부하 강도는 정상 상태에서 찾은 최대 **안정** 처리량의 60~70%로 고정하고, 장애 전후에 같은 요청 유형·데이터셋을 쓴다. 아직 그 최대 안정 처리량은 측정하지 않았다.
+- 요청마다 `request_id`를 DB 외부 원장과 최종 DB 양쪽에 남긴다. 성공 HTTP 응답을 받은 ID 중 최종 DB에 없는 수를 관측 RPO로 보고한다. 앱 HTTP→Hikari→OpenProxy를 거치게 할 probe 경로·테이블은 **후속 구현 대상**이고, 이번 PR에 없다.
+- RTO는 장애 주입 시각부터가 아니라 **마지막 정상 성공 이후 30초 연속 안정 회복 구간의 시작까지**로 정의한다. 실패·결과 불명·재시도·중복 건수를 별도 계수한다.
+- 요청 표본에서 p95/p99를 계산한다. 장애 반복 5개의 RTO에서 p95를 만들어 성능 지표처럼 쓰지 않고 개별 값·범위를 공개한다.
+- 장애 직전 Patroni/etcd 각 3/3 정상, A/B 프록시 정상, 원복 절차·원장 보존을 확인하지 못하면 주입하지 않는다. 종료 후 원래 토폴로지·복제 건강·설정으로 돌아왔는지도 판정한다.
+
+## 장애별 통과·중단 기준
+
+| 후속 시험 | 장애 유형을 구분할 이유 | DocGrid 통과 목표 | 경고·실패·즉시 중단 | 현재 결과 요약 |
+| --- | --- | --- | --- | --- |
+| OpenProxy A/B | 프로세스 kill은 재시작할 수 있고, 지속 stop은 다른 시험이며, 패킷 DROP은 응답 없이 매달릴 수 있다. | A 중단과 B 중단을 각각 반복. 새 연결이 살아 있는 프록시로 도달하고 RTO ≤30초, 성공 응답 누락·중복 0건. | 30~60초 경고, >60초 실패. 상대 프록시에도 못 붙거나 성공 응답이 사라지면 즉시 중단. | 미실행·RTO 미측정. |
+| PostgreSQL 프로세스 종료 | Patroni가 **같은 노드 재시작**을 선택할 수 있으므로 “새 리더 선출”과 구분한다. | 실제 복구 유형을 기록하고 RTO ≤60초, 성공 응답 누락·중복 0건. | 이중 writable primary 또는 원장 대조 불가 시 즉시 중단. | 미실행·재시작/선출 여부 미확인. |
+| 리더 VM 상실 | PostgreSQL 프로세스 종료와 달리 노드 자체가 빠져 TTL 이후 failover가 필요할 수 있다. | 새 단일 리더와 라우팅 회복 RTO ≤120초, 성공 응답 후 누락 ID 수 공개. | RTO 초과 실패. 비동기 복제로 RPO>0이면 DocGrid의 무손실 목표 실패; 이중 리더 즉시 중단. | 미실행·RTO/RPO 미측정. |
+| Worker 인덱싱 중 리더 상실 | lease·Outbox·임베딩 저장의 중간 상태를 최종 결과와 구분해야 한다. | 최종 완료 문서·청크·임베딩·Outbox 누락/중복 0건. | 최종 상태 불일치·재처리 불가 실패. 시험 전용 pause 지점 구현 전에는 ‘임베딩 저장 중’ 주입을 주장하지 않는다. | 미실행·최종 중복/누락 미측정. |
+| etcd 멤버 장애 | 1대 상실(2/3)과 2대 상실(1/3)은 다르며 현재 `failsafe_mode=true`다. | 단일 writable primary 유지 여부와 복구 후 정상 복제를 기록. | 2대 상실 시 무조건 쓰기 정지를 기대하지 않는다. 이중 리더·복구 불능·사전 스냅샷 부재는 즉시 중단. | 미실행·정족수 상실 때의 실제 동작 미확인. |
+
+## 해석과 남은 일
+
+이 기준은 [설치 설정](../opensql-contract-evidence/contract-manifest.json)과 [라우팅 JUnit](../opensql-contract-evidence/junit-summary.json)을 바탕으로 썼다. 하지만 “30초 안에 전환된다”, “성공 응답 손실이 0이다”는 아직 **가설이자 목표**다. HA 실험을 시작하려면 GCP 내부 부하 발생기, HTTP probe API/DB 원장, 장애 주입·복구 자동화, 사전 스냅샷을 구현하고, 각 장애 유형별 원본 요청/DB 대조값을 남겨야 한다. 특히 비동기 복제에서는 성공 응답한 쓰기가 리더 상실 후 사라질 가능성을 시험 전부터 인정한다.
diff --git a/docs/test-results/opensql-contract-verification/ha-settings-and-runtime-supervision.md b/docs/test-results/opensql-contract-verification/ha-settings-and-runtime-supervision.md
new file mode 100644
index 0000000..8565455
--- /dev/null
+++ b/docs/test-results/opensql-contract-verification/ha-settings-and-runtime-supervision.md
@@ -0,0 +1,54 @@
+# Patroni·etcd·OpenProxy·실행 감독 설정 확인
+
+## 검증 질문
+
+리더 장애·프록시 장애 시험의 결과를 해석하기 전에, 실제 설치 설정과 실행 감독자가 무엇인지 알아야 한다. 특히 `PostgreSQL 프로세스 종료 = 반드시 새 리더 선출`, `OpenProxy kill = 계속 꺼짐`, `etcd 2대 장애 = 무조건 쓰기 중단`처럼 단정하면 안 된다. 이 문서는 **장애 주입 전 설정 기준선**을 기록한다. 장애를 직접 발생시킨 결과는 아니다.
+
+## 실행 위치·명령·이유
+
+개발자 컴퓨터의 저장소 루트에서 실제 수집 진입점 `bash scripts/opensql/capture_live_ha_contract.sh`를 실행했다. 승인 계정·프로젝트·존·SSH 키를 환경 변수로 주었고, 값은 공개 JSON이나 문서에 넣지 않았다. 이 스크립트가 `node1/2/3`마다 다음 두 호출을 분리해서 수행했다.
+
+```bash
+# Rocky 컨테이너: 설치 설정, Patroni/etcd 상태, 바이너리 기본값
+sudo docker exec --user opensql -i docgrid-nodeN python3 - collect --node nodeN
+
+# 동일 VM 호스트: Docker 정책, host systemd unit, 실행 중 프로세스
+python3 - runtime --node nodeN
+```
+
+위 두 줄은 개발자 컴퓨터에서 SSH를 통해 **원격 실행된 명령의 본문**이다. `python3 -`의 표준 입력으로 [`capture_ha_contract.py`](../../../scripts/opensql/capture_ha_contract.py)를 보냈다. 원격 호스트에 수집 스크립트를 영구 설치하지 않았다. 수집기 내부의 읽기 전용 호출은 아래와 같다.
+
+| 실행 장소 | 수집기 내부 호출·조회 | 왜 필요한가 | 결과 요약 |
+| --- | --- | --- | --- |
+| 개발자 컴퓨터의 저장소 루트 | `bash scripts/opensql/capture_live_ha_contract.sh` | 승인 계정·프로젝트를 검사하고 컨테이너/호스트의 읽기 전용 수집을 시작한다. | 노드 3개·프록시 관리자 2개의 비식별 스냅샷을 생성했고 통합 검증을 통과했다. |
+| 각 Rocky 컨테이너 | `patronictl -c <설치된 patroni.yml> show-config` 및 `list --format json` | 동적 HA 정책과 당시 3멤버 역할을 분리해서 확인한다. 전체 설정 파일은 공개하지 않는다. | 세 노드의 동적 설정이 일치했다. node1 `Leader/running`, node2/3 `Replica/streaming`; `ttl=30`, `failsafe_mode=true`였다. |
+| 각 Rocky 컨테이너 | `etcdctl --endpoints=http://127.0.0.1:2379 member list --write-out=json` | 부트스트랩 파일에 적힌 3멤버가 아니라 **실행 중인** 멤버가 3개인지 확인한다. 주소는 결과에서 제거한다. | 실행 멤버 `node1/2/3`이 확인됐고 계산된 정족수는 `2`다. |
+| 각 Rocky 컨테이너 | 설치된 `etcd --help` + 실행 etcd 프로세스의 시간 관련 인자·환경 변수 검사 | `heartbeat-interval`, `election-timeout`이 명시 값인지 설치 바이너리 기본값인지 판별한다. 프로세스 인자 전체는 공개하지 않는다. | override가 없어 설치 바이너리 기본값 heartbeat `100ms`, election timeout `1000ms`를 기록했다. 실제 장애 선출 시간은 측정하지 않았다. |
+| OpenProxy 설치 컨테이너 | `openproxy.toml`의 허용 키와 동봉 `openproxy.service`의 `Restart/RestartSec`만 읽기 | 풀·라우팅·캐시 값과 **예시 systemd 파일**의 내용을 기록한다. 암호·주소는 읽기 출력에 포함하지 않는다. | A/B의 허용 설정이 같았다. 동봉 service 예시는 `Restart=always`, `RestartSec=1`이지만 실제 실행 supervisor로 확인된 것은 아니다. |
+| 각 GCP VM 호스트 | `sudo docker inspect --format '{{.HostConfig.RestartPolicy.Name}}' docgrid-nodeN` | 컨테이너 재시작 정책을 확인한다. | 세 컨테이너 모두 `unless-stopped`였다. |
+| 각 GCP VM 호스트 | `systemctl show docgrid-opensql@docgrid-nodeN.service -p ActiveState -p Restart -p RestartUSec` | 호스트의 실제 bootstrap unit 상태를 확인한다. 설치 예시 파일과 혼동하지 않는다. | 세 호스트 모두 `ActiveState=inactive`, `Restart=on-failure`, `RestartUSec=15s`였다. |
+| 각 GCP VM 호스트에서 컨테이너 내부 조회 | `sudo docker exec docgrid-nodeN ps -eo ppid=,stat=,comm=` | 살아 있는 OpenProxy 개수와 부모가 컨테이너 PID 1인지 확인한다. | node1 `0개`, node2/3 각 `1개`; node2/3 프록시의 부모는 컨테이너 PID 1이었다. |
+
+## 코드의 판별 방식
+
+[`etcd_timing_inputs()`](../../../scripts/opensql/capture_ha_contract.py)은 실행 etcd 프로세스가 정확히 하나인지 확인하고, 시간 관련 CLI 인자와 환경 변수를 먼저 검사한다. 별도 `--config-file`이 있으면 우선순위가 불명확해 수집을 실패시킨다. 이번에는 override가 없어 해당 설치 바이너리 `--help` 기본값을 `source=installed binary default`로 저장했다. 이는 **실행 입력의 추론**이며 선출 시간을 재서 검증한 값은 아니다.
+
+[`runtime()`](../../../scripts/opensql/capture_ha_contract.py)은 Docker 정책과 host unit, 살아 있는 프록시 프로세스를 별도로 수집한다. 따라서 패키지에 동봉된 `Restart=always`를 실제 supervisor 정책으로 잘못 보고하지 않는다.
+
+## 관측 결과
+
+| 항목 | 세 노드 또는 A/B의 관측값 | 결과 해석 |
+| --- | --- | --- |
+| Patroni | `ttl=30`, `loop_wait=10`, `retry_timeout=10`, `maximum_lag_on_failover=1048576`, `failsafe_mode=true` | 실제 동적 설정 허용 항목이 세 노드에서 같았다. `primary_start_timeout`, `primary_stop_timeout`, `synchronous_mode`, `check_timeline`은 동적 설정에 **명시되지 않아 `null`**이며 `0`으로 해석하지 않는다. |
+| etcd 멤버 | 구성 이름·실행 멤버 모두 `node1/2/3`, 정족수 `2` | `initial_cluster_state=new`는 최초 부트스트랩 입력값이지 현재 새 클러스터를 생성한다는 뜻이 아니다. |
+| etcd 시간 입력 | heartbeat `100ms`, election timeout `1000ms`; 둘 다 `installed binary default` | 명시적 override가 없음을 확인했다. 실제 장애 때 선출 지연은 별도 측정해야 한다. |
+| OpenProxy 풀 | `pool_mode=transaction`, `default_role=primary`, parser·읽기/쓰기 분리 활성, `primary_reads_enabled=false`, `Random` | 연결이 프록시를 통과할 때의 기대 라우팅 조건이다. `Random`만으로 물리 standby 분산 비율을 입증하지 않는다. |
+| 캐시 표기 | 일반 TOML `prepared_statements_cache_size=1000`, 관리자 풀 조회값 `0` | 두 범위의 값이 달라 **유효 우선순위 미확정**. `0=완전 비활성`이라고 단정하지 않는다. |
+| Docker / host unit | Docker `unless-stopped`; host bootstrap unit `inactive`, `Restart=on-failure`, `RestartUSec=15s` | 동봉 OpenProxy service 예시의 `Restart=always`, `RestartSec=1`이 현재 프록시를 감독한다는 증거가 없다. |
+| 살아 있는 OpenProxy | node1 `0`, node2 `1`, node3 `1`; node2/3의 부모는 컨테이너 PID 1 | 두 프록시가 실행 중이라는 증거다. 프로세스 kill 뒤 누가 얼마 만에 재시작할지는 아직 모른다. |
+
+원본: [node1](../opensql-contract-evidence/node1.json), [node2](../opensql-contract-evidence/node2.json), [node3](../opensql-contract-evidence/node3.json), [A 관리자 값](../opensql-contract-evidence/proxy-a-admin.json), [B 관리자 값](../opensql-contract-evidence/proxy-b-admin.json). 관리자 조회는 `SHOW CONFIG/STATS/SERVERS`를 서비스 포트에서 읽었으며 설정 변경 명령은 실행하지 않았다. `SHOW STATS`는 누적값이라 이번 수집만의 요청량으로 해석하지 않는다.
+
+## 판정·미검증
+
+설정 **기록** 기준은 완료됐다. 그러나 OpenProxy 프로세스 `kill`, `systemctl stop`, 네트워크 DROP, PostgreSQL 프로세스 종료, VM 상실, etcd 정족수 상실은 이 단계에서 실행하지 않았다. 각 장애의 재시작/선출/쓰기 가능 시간은 후속 실험으로만 판정한다. `failsafe_mode=true`에서 etcd 정족수 상실을 단순히 “즉시 쓰기 정지”로 예측하지 않는다.
diff --git a/docs/test-results/opensql-contract-verification/openproxy-a-b-config-parity.md b/docs/test-results/opensql-contract-verification/openproxy-a-b-config-parity.md
new file mode 100644
index 0000000..85e7c34
--- /dev/null
+++ b/docs/test-results/opensql-contract-verification/openproxy-a-b-config-parity.md
@@ -0,0 +1,51 @@
+# OpenProxy A/B 설정 일치 검증
+
+## 목적과 범위
+
+이후 한쪽 OpenProxy를 중단하고 다른 쪽으로 전환할 때, 실패 원인이 장애 자체인지 A/B 설정 차이인지 구분하려고 **설치 파일의 공개 가능 항목**과 **관리 콘솔의 유효 항목**을 각각 비교했다. `proxy-a=node2`, `proxy-b=node3`이다. 암호·서버 주소·원본 TOML 전체의 byte-for-byte 동일성은 공개하거나 비교 결과로 주장하지 않는다.
+
+## 실제 명령은 어디에서 실행됐나
+
+개발자 컴퓨터의 저장소 루트에서 `bash scripts/opensql/capture_live_ha_contract.sh`를 실행했다. 스크립트는 승인된 활성 GCP 계정·프로젝트를 확인한 후, node2/3의 Rocky 컨테이너에서 아래 두 유형을 호출했다.
+
+```bash
+# node2/3 컨테이너에서 수집기 실행: 설치 TOML의 허용 키만 JSON화
+sudo docker exec --user opensql -i docgrid-nodeN python3 - collect --node nodeN
+
+# node2/3 컨테이너에서 수집기 실행: 관리자 계정은 컨테이너 메모리에서만 사용
+sudo docker exec -i docgrid-nodeN python3 - admin --node nodeN
+```
+
+| 실행 위치 | 명령 또는 코드 내부 호출 | 목적 | 결과 요약 |
+| --- | --- | --- | --- |
+| 개발자 컴퓨터의 저장소 루트 | `bash scripts/opensql/capture_live_ha_contract.sh` | A/B 설치·관리 설정을 같은 수집 절차로 확보한다. | node2/3 설치 스냅샷과 proxy-a/b 관리자 스냅샷이 생성되고 `assemble` 검증을 통과했다. |
+| node2/3 Rocky 컨테이너 | `python3 - collect --node nodeN` | TOML의 공개 허용 키만 수집한다. | 두 노드에서 `pool_mode=transaction`, parser·읽기/쓰기 분리 활성 등 허용 설치값이 일치했다. |
+| node2/3 Rocky 컨테이너 | `python3 - admin --node nodeN` 안의 `SHOW CONFIG` | 런타임 관리 뷰의 유효 설정을 설치값과 별개로 읽는다. | A/B의 허용 관리 설정이 같았다. 풀 캐시 값은 `0`으로, TOML 일반 섹션 `1000`과 달랐다. |
+| 같은 관리 세션 | `SHOW STATS` | primary·replica 역할별 통계가 있는지 확인한다. | A/B 모두 primary·replica 누적 카운터가 있었다. 숫자는 서로 달랐으며 이번 시험만의 요청량은 아니다. |
+| 같은 관리 세션 | `SHOW SERVERS` | 서버별 상태·캐시 통계의 공개 가능한 결과를 확인한다. | 공개 스냅샷의 `servers=[]`; 이 결과로 물리 DB별 분산을 판정하지 못했다. |
+| 개발자 컴퓨터의 저장소 루트 | 수집 스크립트가 호출한 `capture_ha_contract.py assemble` | 두 설치값·두 관리값의 동등성을 검사한다. | 공개 허용 항목 비교를 통과했고, 다섯 스냅샷의 SHA-256은 `0a0b86ef…549794`였다. |
+
+`admin` 모드의 [`admin_rows()`](../../../scripts/opensql/capture_ha_contract.py)는 컨테이너 localhost의 OpenProxy 서비스 포트에 `psql -X -w --csv -P footer=off -h 127.0.0.1 -d openproxy -c 'SHOW CONFIG'` 형태로 접속한다. 같은 방식으로 `SHOW STATS`, `SHOW SERVERS`도 호출했다. 실제 port·관리 사용자·암호는 설치 파일에서 메모리로 읽어 프로세스 환경에만 넣었으며, 공개 명령 예시에는 적지 않는다. `SHOW`는 조회 명령이고 설정 변경은 아니다. 설치 파일의 `admin_port=6433` 경로는 컨테이너 localhost에서 열려 있지 않았고, 서비스 포트 `6432` 경로가 성공했다. 관리 포트의 제품상 의미는 별도 확인 사항이다.
+
+로컬에서 [`assemble`](../../../scripts/opensql/capture_ha_contract.py)을 실행해 다섯 JSON 입력의 허용 필드를 검증·비교했다. 재현 시에는 위 스크립트가 이 단계를 자동으로 실행한다. `assemble`은 `node2.openproxy`와 `node3.openproxy`, `proxy-a.effective_config`와 `proxy-b.effective_config`를 별도 비교하고 다르면 실패한다.
+
+## 비교한 코드·설정과 결과
+
+| 비교 층위 | 실제 확인한 항목 | A/B 결과 | 해석 |
+| --- | --- | --- | --- |
+| 설치 TOML 일반 섹션 | `connect_timeout=10000`, `prepared_statements_cache_size=1000` | 동일 | 같은 설치 설정의 허용 항목이다. 두 번째 값의 관리자 풀 값과의 우선순위는 별개다. |
+| 설치 TOML `pools.docgrid` | `pool_mode=transaction`, `default_role=primary`, `query_parser_enabled=true`, `query_parser_read_write_splitting=true` | 동일 | 라우팅 규칙의 기본 조건이 두 프록시에서 같았다. |
+| 설치 TOML shard/user | `use_patroni=true`, `patroni_port=8008`, `pool_size=5` | 동일 | 주소와 인증 정보는 제외한 공통 계약이다. |
+| `SHOW CONFIG` 유효 풀 값 | `pool_mode=Transaction`, `default_role=primary`, 읽기/쓰기 분리·parser 활성, `primary_reads_enabled=false`, `load_balancing_mode=Random`, 풀의 `prepared_statements_cache_size=0` | 동일 | 런타임 관리 뷰에서도 허용 항목이 일치했다. 대소문자 차이는 출력 표기다. |
+| `SHOW CONFIG` 일반 값 | `connect_timeout=10000`, `shutdown_timeout=60000` | 동일 | 연결·종료 관련 설정을 후속 장애 시험 기준선으로 쓸 수 있다. |
+| `SHOW STATS` | 양쪽에 primary·replica 누적 카운터가 존재 | 각각 숫자는 다름 | **설정 비교 대상이 아니다.** 누적 통계가 다르다고 설정 불일치로 판정하면 안 된다. |
+
+증거: [node2 설치 스냅샷](../opensql-contract-evidence/node2.json), [node3 설치 스냅샷](../opensql-contract-evidence/node3.json), [A 관리 스냅샷](../opensql-contract-evidence/proxy-a-admin.json), [B 관리 스냅샷](../opensql-contract-evidence/proxy-b-admin.json), [통합 해시](../opensql-contract-evidence/contract-manifest.json).
+
+## 왜 이것이 필요한가
+
+두 프록시의 공개 가능한 설치·유효 설정이 같아야 A→B 또는 B→A 접속 전환 시험 결과를 해석할 기준선이 생긴다. 예를 들어 한쪽만 read/write splitting이 꺼져 있으면 장애 전후 SQL 역할 차이가 장애의 영향인지 설정의 영향인지 구분되지 않는다. 이번 비교는 그 혼동을 줄인다.
+
+## 결론과 한계
+
+A/B **허용 항목의 설정 일치**는 완료됐다. 원본 TOML 전체·비밀값·서버 주소가 동일하다는 주장은 하지 않는다. `SHOW SERVERS`의 공개 스냅샷은 `servers=[]`여서 물리 DB별 라우팅 증거가 아니다. 풀 캐시 값 `0`과 일반 TOML `1000`의 실제 우선순위도 확정하지 못했다. 그리고 양쪽의 설정이 같아도 한쪽 장애 뒤 실제 새 연결이 다른 쪽으로 넘어가는지는 아직 시험하지 않았다.
diff --git a/docs/test-results/opensql-contract-verification/pgjdbc-prepared-threshold-repeat.md b/docs/test-results/opensql-contract-verification/pgjdbc-prepared-threshold-repeat.md
new file mode 100644
index 0000000..f38b673
--- /dev/null
+++ b/docs/test-results/opensql-contract-verification/pgjdbc-prepared-threshold-repeat.md
@@ -0,0 +1,53 @@
+# pgJDBC server-side prepared statement 반복 실행
+
+## 왜 검사했나
+
+pgJDBC의 `PreparedStatement`는 같은 SQL을 반복하면 `prepareThreshold`에 따라 server-side prepare 단계로 전환될 수 있다. OpenProxy의 transaction pooling에서 이것이 실패하면 뒤의 장애·부하 시험에 **HA와 무관한 SQL 오류**가 섞인다. 기본에 해당하는 임계값 `5`와 강제 전환용 `1`을 각각 두 프록시에서 검사했다.
+
+## 어디에서 어떤 명령·코드를 실행했나
+
+개발자 컴퓨터의 저장소 루트에서 A/B JDBC URL 및 앱 계정을 셸 환경으로 주고 아래 명령을 실행했다. A/B URL은 각각 기존 GCP node2/node3의 OpenProxy로 향하는 임시 SSH 터널이었다. 비밀번호 원문은 명령 기록·문서에 쓰지 않는다.
+
+```bash
+./backend/gradlew -p backend openSqlOpenProxyContractTest --rerun-tasks --offline
+```
+
+실제 반복은 [`preparedStatementsSurviveTransactionPooling()`](../../../backend/src/test/java/com/opensource/docgrid/opensql/OpenSqlOpenProxyContractTest.java) 코드 안에서 일어났다. `connect()`가 각 JDBC URL 뒤에 `prepareThreshold=5` 또는 `prepareThreshold=1`을 추가했다. **셸에서 15번 명령을 수동으로 친 것이 아니라**, 같은 물리 JDBC 연결·같은 `PreparedStatement` 객체로 루프를 15번 돌렸다.
+
+```java
+connection.setAutoCommit(false);
+PreparedStatement statement = connection.prepareStatement("SELECT ?::integer");
+for (int value = 1; value <= 15; value++) {
+ statement.setInt(1, value);
+ ResultSet result = statement.executeQuery();
+ // 반환값이 입력값과 같아야 한다.
+ connection.commit();
+}
+```
+
+실제 코드에서는 매번 `SELECT pg_postmaster_start_time()::text || ':' || pg_backend_pid()`로 backend 식별자를 기록하고, 마지막 반복과 같은 트랜잭션 안에서 `SELECT count(*) FROM pg_prepared_statements`를 읽었다. `PGStatement.getPrepareThreshold()`로 요청한 임계값을 확인하고, 루프가 끝난 뒤 `PGStatement.isUseServerPrepare()`가 `true`인지 assert했다. 이 때문에 단순히 SQL 15회 성공했다는 것보다 **드라이버가 준비문 단계에 들어갔다는 증거**가 하나 더 있다.
+
+| 실행 위치 | 명령·코드 내부 SQL | 목적 | 결과 요약 |
+| --- | --- | --- | --- |
+| 개발자 컴퓨터의 저장소 루트 | `./backend/gradlew -p backend openSqlOpenProxyContractTest --rerun-tasks --offline` | A/B와 두 임계값 조합의 계약 JUnit을 실행한다. | 전체 JUnit 5개 통과·실패 0건; 준비문 테스트도 통과했다. |
+| A/B OpenProxy를 통한 DB 세션 | `SELECT ?::integer`를 각 조합에서 15회 실행 | 입력값과 반환값의 정확성 및 여러 commit 사이의 반복 동작을 확인한다. | A/B × `prepareThreshold=5/1` 네 조합 모두 15회 정확한 정수를 반환했다. |
+| 같은 DB 세션 | `SELECT pg_postmaster_start_time()::text \|\| ':' \|\| pg_backend_pid()` | 반복 중 사용한 backend가 바뀌었는지 센다. | 네 조합 모두 서로 다른 backend가 `1개`여서 backend 교체 시험은 되지 않았다. |
+| 마지막 반복과 같은 DB 트랜잭션 | `SELECT count(*) FROM pg_prepared_statements` | 마지막 backend에 보이는 준비문을 확인한다. | threshold `5`는 A/B 각 `22`, threshold `1`은 각 `40`이었다. 해당 SQL만의 독립 개수는 아니다. |
+| 개발자 컴퓨터 JVM | `PGStatement.getPrepareThreshold()` 및 `isUseServerPrepare()` | 요청한 임계값과 드라이버의 server-prepare 상태를 확인한다. | 네 조합 모두 요청값 `5` 또는 `1`과 일치했고 `isUseServerPrepare()=true`였다. |
+
+## 관측 결과
+
+| 프록시 | `prepareThreshold` | 반복·정확성 | 관측 backend 수 | 마지막 backend의 전체 준비문 수 |
+| --- | ---: | --- | ---: | ---: |
+| A | 5 | 15회, 매번 입력 정수 그대로 반환 | 1 | 22 |
+| A | 1 | 15회, 매번 입력 정수 그대로 반환 | 1 | 40 |
+| B | 5 | 15회, 매번 입력 정수 그대로 반환 | 1 | 22 |
+| B | 1 | 15회, 매번 입력 정수 그대로 반환 | 1 | 40 |
+
+네 조합 모두 `isUseServerPrepare()=true`, `prepared_on_last_backend > 0`을 통과했다. 결과는 [JUnit 요약](../opensql-contract-evidence/junit-summary.json)의 `CONTRACT_PREPARED` 네 줄에 있다. 마지막 준비문 수는 **해당 backend에 보이는 전체 개수**이며 이 테스트 SQL만의 독립 개수가 아니다.
+
+[이전 캐시 카운터 실험](../opensql-contract-evidence/prepared-cache-delta.json)은 A/B 각각 hit `+224`, miss `+80`, eviction `0`을 남겼다. 다만 **이번 최종 JUnit 실행이나 12:57 UTC 설정 스냅샷과 같은 시점의 실행이 아니므로** 위 네 행에 합산하거나 15회 루프의 캐시 적중률로 환산하지 않는다. 설치 TOML의 일반 설정 `prepared_statements_cache_size=1000`과 `SHOW CONFIG`의 `pools.docgrid` 값 `0`도 서로 달라 우선순위를 확정하지 못했다.
+
+## 결론과 제한
+
+**완료:** 두 프록시 × 두 임계값에서 여러 트랜잭션에 걸친 반복 실행·결과 정확성·드라이버 server prepare 진입을 확인했다. **미검증:** transaction pooling이 실제로 *다른 backend*로 전환될 때 named statement를 재준비하는지, OpenProxy 캐시 설정의 적용 우선순위와 캐시 효율, 고동시성에서의 오류율. 이번에는 네 조합 모두 `distinct_backends=1`이므로 “backend 교체를 견뎠다”라고 말할 수 없다. 별도 backend 교체 실험 전까지 이 부분은 후속 위험으로 남긴다.
diff --git a/docs/test-results/opensql-contract-verification/product-and-driver-versions.md b/docs/test-results/opensql-contract-verification/product-and-driver-versions.md
new file mode 100644
index 0000000..ad3d348
--- /dev/null
+++ b/docs/test-results/opensql-contract-verification/product-and-driver-versions.md
@@ -0,0 +1,48 @@
+# OpenSQL 3노드 제품·드라이버 버전 확인
+
+## 무엇을 확인했나
+
+Rocky Linux 9.7 `x86_64` 컨테이너 세 개에 설치된 OpenSQL·PostgreSQL·Patroni·etcd 버전, OpenProxy 두 개의 버전, 테스트 애플리케이션이 해석한 pgJDBC·HikariCP 버전을 기록했다. 버전이 같다는 사실은 이후 실험의 재현 조건이지, 제품 간 모든 호환성을 입증하는 결과는 아니다.
+
+## 어디에서 어떤 명령을 실행했나
+
+| 실행 위치 | 명령 또는 호출 | 목적 | 결과 요약 |
+| --- | --- | --- | --- |
+| 개발자 컴퓨터의 저장소 루트 | `bash scripts/opensql/capture_live_ha_contract.sh` | 승인된 GCP 계정·프로젝트인지 먼저 검사하고 기존 세 VM의 컨테이너에서 버전만 수집한다. 실제 실행에는 `OPENSQL_GCP_ZONE`, `OPENSQL_SSH_KEY`, `OPENSQL_EXPECTED_ACCOUNT`, `OPENSQL_EXPECTED_PROJECT`를 환경 변수로 제공했다. 값 자체는 공개하지 않는다. | 3개 노드·2개 관리자 스냅샷을 만들었다. 수집 구간은 2026-09-26 12:57:23~12:58:14 UTC다. |
+| 스크립트가 SSH로 접근한 `node1/2/3`의 Rocky 컨테이너 | `python3 - collect --node nodeN` | [`capture_ha_contract.py`](../../../scripts/opensql/capture_ha_contract.py)의 `collect`가 설치된 실행 파일의 버전 출력을 허용 목록으로 가공한다. `node1`에는 OpenProxy가 없으므로 그 값은 `null`이다. | 모두 Rocky Linux 9.7 `x86_64`, OpenSQL `v3.17.8.7`, PostgreSQL `17.8`, Patroni `4.0.5`, etcd `3.6.5`; OpenProxy는 node2/3에서 `1.1.3` revision `723`이었다. |
+| 개발자 컴퓨터의 저장소 루트 | `./backend/gradlew -p backend dependencyInsight --dependency org.postgresql:postgresql --configuration testRuntimeClasspath --offline` | Gradle 테스트 런타임이 실제로 선택한 pgJDBC 버전을 확인한다. 선언 버전만 읽는 것과 다르다. | `testRuntimeClasspath`에서 pgJDBC `42.7.11`이 선택됐다. |
+| 같은 위치 | `./backend/gradlew -p backend dependencyInsight --dependency com.zaxxer:HikariCP --configuration testRuntimeClasspath --offline` | HikariCP의 실제 선택 버전을 확인한다. | HikariCP `6.3.3`이 선택됐다. **버전 조회이지 Hikari 실행 시험은 아니다.** |
+| 개발자 컴퓨터에서 A/B OpenProxy로 연결한 JUnit | `./backend/gradlew -p backend openSqlOpenProxyContractTest --rerun-tasks --offline` | JDBC 메타데이터의 PostgreSQL 서버·드라이버 버전도 원격 연결을 통해 교차 확인한다. 연결 변수와 비밀번호는 셸 환경에만 주입했다. | 5개 테스트/실패 0건. A/B 모두 JDBC 서버 메타데이터 PostgreSQL `17.8`, 드라이버 `42.7.11`을 반환했다. |
+
+수집 스크립트 안의 SSH 명령 형태는 다음과 같다. `nodeN`은 `node1`부터 `node3`까지 반복되고 SSH 키 경로는 공개하지 않는다.
+
+```bash
+gcloud compute ssh "docgrid-nodeN" --zone="$OPENSQL_GCP_ZONE" \
+ --ssh-key-file="$OPENSQL_SSH_KEY" \
+ --command="sudo docker exec --user opensql -i docgrid-nodeN python3 - collect --node nodeN" --quiet \
+ < scripts/opensql/capture_ha_contract.py
+```
+
+이는 수집 코드의 실행 구조를 보여주는 **비식별화한 표기**다. 실제 스크립트는 셸 변수로 노드명을 조합하고 결과를 임시 JSON에 받는다. 원본 라이선스나 설정 파일 전체를 출력하지 않는다.
+
+## 어떤 코드가 결과를 만들었나
+
+수집기는 설치된 바이너리의 버전 출력 중 제품명·버전만 파싱해 [node1](../opensql-contract-evidence/node1.json), [node2](../opensql-contract-evidence/node2.json), [node3](../opensql-contract-evidence/node3.json)의 `versions`에 넣는다. JDBC 테스트의 [`timeZoneBoundaryIsStableAcrossProxies()`](../../../backend/src/test/java/com/opensource/docgrid/opensql/OpenSqlOpenProxyContractTest.java)는 `connection.getMetaData().getDatabaseProductVersion()`과 `getDriverVersion()`을 각각 A/B에서 읽어 `CONTRACT_SERVER`, `CONTRACT_DRIVER` 관측값을 남긴다. 이 테스트는 `DriverManager`로 연결하므로 HikariCP를 실제로 구동하는 시험은 아니다.
+
+## 실제 결과와 해석
+
+| 대상 | 관측값 | 의미 |
+| --- | --- | --- |
+| 세 컨테이너 | Rocky Linux 9.7, `x86_64` | 세 노드의 OS 식별자·아키텍처가 동일했다. |
+| OpenSQL | 세 노드 모두 `v3.17.8.7` | 제품 버전 차이로 인한 노드별 동작 차이를 이번 기준선에서는 배제할 수 있다. |
+| OpenProxy | `node2/3` 모두 `1.1.3`, revision `723`; `node1`은 미설치 | 프록시 두 대의 설치 빌드가 같았다. |
+| Patroni / etcd | 각각 세 노드 모두 `4.0.5` / `3.6.5` | 이후 리더 선출 시험에서 같은 버전의 3멤버를 대상으로 한다. |
+| PostgreSQL 실행 파일 / psql | 세 노드 모두 `17.8` / `17.8` | 설치 바이너리와 클라이언트 버전이다. |
+| JDBC 서버 메타데이터 | A/B 모두 PostgreSQL `17.8` | 실제 프록시 경유 연결이 반환한 서버 버전이다. |
+| pgJDBC / HikariCP | `42.7.11` / `6.3.3` | Gradle `testRuntimeClasspath`의 선택 버전이다. pgJDBC `42.7.11`은 JDBC 메타데이터와도 일치했다. |
+
+원본은 [통합 스냅샷](../opensql-contract-evidence/contract-manifest.json)과 [JUnit 요약](../opensql-contract-evidence/junit-summary.json)이다. JUnit 전체 5개 시험은 2026-09-26 12:46 UTC에 실패 0건이었고, 이 문서의 버전 항목은 그 시험과 별도 수집 스냅샷을 함께 해석한 것이다. 수집은 12:57:23~12:58:14 UTC에 순차 실행됐으므로 두 파일을 **동일 시점의 원자적 상태**라고 표현하지 않는다.
+
+## 결론과 한계
+
+버전 기록 기준은 완료됐다. 하지만 HikariCP `6.3.3`은 의존성 확인 결과이지 이 계약 JUnit에서 Hikari 풀을 실행했다는 뜻은 아니다. 또한 `node1`의 OpenProxy 값이 `null`인 것은 수집 누락이 아니라 해당 노드에 프록시를 설치하지 않은 토폴로지다. 전체 Gradle 테스트 `1,124개 중 104개 실패`라는 별도 실행 결과도 있으므로 “저장소 전체 테스트 성공”으로 확대하지 않는다.
diff --git a/docs/test-results/opensql-contract-verification/sanitized-snapshot-and-sha256.md b/docs/test-results/opensql-contract-verification/sanitized-snapshot-and-sha256.md
new file mode 100644
index 0000000..f4aeb17
--- /dev/null
+++ b/docs/test-results/opensql-contract-verification/sanitized-snapshot-and-sha256.md
@@ -0,0 +1,54 @@
+# 비식별 수집 JSON과 SHA-256 재계산
+
+## 검증 대상
+
+노드 3개와 OpenProxy 관리자 스냅샷 2개를 **사람이 수작업으로 옮겨 적지 않고**, 공개 가능한 항목만 모아 하나의 canonical JSON 스냅샷으로 만들었는지 확인했다. SHA-256은 그 공개 스냅샷의 무결성 확인값이다. GCP·티맥스티베로의 서명이나 원본 서버 전체 상태에 대한 증명서는 아니다.
+
+## 실제 실행 명령과 장소
+
+개발자 컴퓨터의 저장소 루트에서 `bash scripts/opensql/capture_live_ha_contract.sh`를 실행했다. 승인 계정/프로젝트를 확인한 뒤, 스크립트가 각 GCP VM에 SSH로 들어가 Rocky 컨테이너의 `collect`, VM 호스트의 `runtime`, node2/3 컨테이너의 `admin`을 호출했다. 로컬의 `merge`가 같은 노드 별칭인 두 결과만 합치고, `assemble`이 최종 5개 입력을 검증·해시했다. 명령 구조는 다음과 같다.
+
+```bash
+python3 scripts/opensql/capture_ha_contract.py merge \
+
+python3 scripts/opensql/capture_ha_contract.py assemble \
+ \
+ --admin
+```
+
+꺾쇠 안은 스크립트가 만든 **임시 파일 경로의 설명용 자리표시자**이며, 실제 스크립트는 `mktemp -d`로 임시 디렉터리를 만들고 종료 시 삭제한다. 공개 증거는 [`opensql-contract-evidence`](../opensql-contract-evidence/contract-manifest.json)에 복사한다. 원격 관리 암호는 컨테이너 메모리에서만 읽고, 공개 JSON에는 설정 허용 키·역할별 누적 숫자만 들어간다.
+
+이번 문서를 작성하면서 개발자 컴퓨터의 같은 worktree에서 실제로 아래 독립 재계산 명령도 실행했다. 이는 **수집 당시 명령이 아니라 공개 결과를 나중에 검산한 명령**이다.
+
+```bash
+python3 -c 'import json,hashlib,pathlib; p=pathlib.Path("docs/test-results/opensql-contract-evidence/contract-manifest.json"); d=json.loads(p.read_text()); b=json.dumps(d["snapshot"],ensure_ascii=False,sort_keys=True,separators=(",",":")).encode(); print(hashlib.sha256(b).hexdigest()); print(d["evidence_sha256"]); print(hashlib.sha256(b).hexdigest()==d["evidence_sha256"])'
+```
+
+| 실행 위치 | 명령·수집기 모드 | 목적 | 결과 요약 |
+| --- | --- | --- | --- |
+| 개발자 컴퓨터의 저장소 루트 | `bash scripts/opensql/capture_live_ha_contract.sh` | 승인 GCP 환경에서 다섯 스냅샷을 자동 수집한다. | 2026-09-26 12:57:23~12:58:14 UTC에 노드 3개·관리자 2개를 수집했다. |
+| 각 GCP VM/컨테이너 | 수집기의 `collect`, `runtime`, `admin` | 설치값·호스트 실행 상태·프록시 관리값을 분리해서 읽는다. | 노드 3개, proxy-a/b 관리자 2개의 비식별 JSON이 생성됐다. |
+| 개발자 컴퓨터 | `capture_ha_contract.py merge` | 같은 별칭의 컨테이너/호스트 결과만 결합한다. | node1/2/3 각각의 병합이 성공했고 중복·별칭 불일치가 관측되지 않았다. |
+| 개발자 컴퓨터 | `capture_ha_contract.py assemble` | 다섯 결과의 허용 항목·A/B 일치 여부를 검사하고 canonical JSON을 해시한다. | 검증을 통과했고 SHA-256 `0a0b86ef…549794`를 manifest에 기록했다. |
+| 개발자 컴퓨터, 문서 작성 시 재검산 | 위의 `python3 -c 'import json,hashlib,pathlib; ...'` | 공개 manifest의 `snapshot`에서 해시를 독립 재계산한다. | 재계산값과 기록값이 같았고 비교 결과가 `True`였다. 원격 상태를 새로 수집한 것은 아니다. |
+| 개발자 컴퓨터 | `python3 -m unittest scripts.opensql.test_capture_ha_contract -v` | 비식별화·병합·해시의 코드 방어 동작을 확인한다. | 단위시험 `11개`, 실패 `0건`. 원격 서버 상태의 진실성 증명은 아니다. |
+
+## 코드가 하는 검증
+
+[`merge_node()`](../../../scripts/opensql/capture_ha_contract.py)은 컨테이너 결과와 호스트 결과의 노드 별칭이 다르면 실패한다. [`assemble()`](../../../scripts/opensql/capture_ha_contract.py)은 node1/2/3이 정확히 하나씩 있는지, OS가 모두 Rocky 9.7 `x86_64`인지, A/B 설치·관리 계약 값이 같은지, OpenSQL 버전과 Patroni 동적 설정이 같은지 검사한다. 민감 문자열 탐지도 통과해야 한다. 그 뒤 `snapshot`을 `ensure_ascii=False`, `sort_keys=True`, `separators=(",",":")`로 직렬화한 **UTF-8 바이트**에 SHA-256을 적용한다. 수집 시작/종료 시각은 해시 대상 `snapshot`의 바깥 필드다.
+
+`python3 -m unittest scripts.opensql.test_capture_ha_contract -v`로 수집기 단위시험 11개가 실패 없이 통과했다. 여기에는 비밀값 차단, 중복 감지, 버전 추출, etcd 시간 입력 우선순위, 자동 병합, 관리 설정 포함 해시, JUnit 비식별화 검증이 있다. 이 단위시험은 원격 상태의 진실성을 보장하는 시험이 아니라 **수집 코드의 방어 동작**을 확인한 것이다.
+
+## 관측 결과와 해석
+
+| 항목 | 결과 | 해석 |
+| --- | --- | --- |
+| 순차 수집 구간 | 2026-09-26 `12:57:23Z`~`12:58:14Z` | 원자적 단일 시점 스냅샷이 아니다. |
+| 입력 | 노드 3개, 관리자 결과 2개 | 모두 [manifest](../opensql-contract-evidence/contract-manifest.json)의 `snapshot`에 포함됐다. |
+| 공개 SHA-256 | `0a0b86eff71c4c24740a99ea0f334fca70e5c11e1a813c3a3bc646a825549794` | 공개 snapshot 바이트의 동일성 검사용이다. |
+| 독립 재계산 | 출력 해시 두 줄 동일, 비교 `True` | 게시된 manifest의 `evidence_sha256`이 현재 `snapshot`에서 재현됐다. |
+| 민감 값 | 계정·프로젝트 ID·내부 IP·암호·라이선스 파일 미포함 | 공개 문서로 올릴 수 있는 범위를 유지했다. 자동 필터를 통과해도 게시 전 리뷰가 필요하다. |
+
+## 한계
+
+원본 설정 전체가 아닌 **허용 목록**만 해시한다. 다른 시점에 다시 수집하면 누적 `SHOW STATS` 숫자가 변해 해시도 달라질 수 있다. 해시가 같아도 당시 서버가 정직했음을 증명하는 것은 아니며, 해시가 달라도 곧바로 설정 오류라는 뜻은 아니다. JUnit 결과와 이전 prepared-cache delta는 이 해시 입력에 포함되지 않고 실행 시점도 다르다. 따라서 세 증거를 한 번의 원자적 실험으로 묶지 않는다.
diff --git a/docs/test-results/opensql-contract-verification/sql-prepare-vs-protocol-prepare.md b/docs/test-results/opensql-contract-verification/sql-prepare-vs-protocol-prepare.md
new file mode 100644
index 0000000..d8813d7
--- /dev/null
+++ b/docs/test-results/opensql-contract-verification/sql-prepare-vs-protocol-prepare.md
@@ -0,0 +1,31 @@
+# SQL-level PREPARE와 pgJDBC 프로토콜 준비문 구분
+
+## 왜 별도로 검증했나
+
+둘 다 “prepared statement”라고 부르지만, 애플리케이션이 `PREPARE/EXECUTE` SQL을 직접 보내는 방식과 pgJDBC `PreparedStatement`가 확장 쿼리 프로토콜로 server-side prepare 하는 방식은 같은 시험이 아니다. OpenProxy transaction pooling에서는 수명·backend 전환 문제를 다르게 해석해야 하므로 별도로 검사했다.
+
+## 명령과 실행 위치
+
+개발자 컴퓨터의 저장소 루트에서 A/B OpenProxy 터널과 앱 계정 환경 변수로 다음 **한 번의 JUnit 명령**을 실행했다. SQL은 Java 코드 안에서 프록시를 지나 GCP의 DB 세션에서 실행됐다.
+
+```bash
+./backend/gradlew -p backend openSqlOpenProxyContractTest --rerun-tasks --offline
+```
+
+| 방식 | Java 실행 위치·API | DB에 보낸 핵심 명령 | 목적 | 결과 요약 |
+| --- | --- | --- | --- | --- |
+| 계약 JUnit 실행 | 개발자 컴퓨터의 저장소 루트, Gradle | `./backend/gradlew -p backend openSqlOpenProxyContractTest --rerun-tasks --offline` | 아래 두 방식의 테스트를 포함한 전체 계약 시험을 실행한다. | JUnit 5개 통과·실패 0건. 두 준비문 테스트도 모두 통과했다. |
+| SQL-level | 개발자 컴퓨터 JVM의 `Statement`, [`sqlLevelPrepareIsObservedSeparately()`](../../../backend/src/test/java/com/opensource/docgrid/opensql/OpenSqlOpenProxyContractTest.java) | `PREPARE contract_<무작위식별자>(integer) AS SELECT $1::integer` → `EXECUTE contract_<식별자>(41)` → `DEALLOCATE contract_<식별자>` | 앱이 명시적으로 보내는 서버 SQL 명령의 **같은 트랜잭션 내** 동작을 확인한다. 이름은 충돌 방지를 위해 실행 때마다 생성했다. | A/B 모두 `41` 반환, `same_transaction_pass`. 다른 트랜잭션·backend로의 재사용은 미검증. |
+| pgJDBC 프로토콜 | 같은 JVM의 `PreparedStatement`와 `PGStatement`, [`preparedStatementsSurviveTransactionPooling()`](../../../backend/src/test/java/com/opensource/docgrid/opensql/OpenSqlOpenProxyContractTest.java) | `SELECT ?::integer` 15회; URL에 `prepareThreshold=5` 또는 `1` | 드라이버가 server-side prepare 단계로 들어가고 반복 결과가 맞는지 확인한다. SQL-level `PREPARE` 문자열을 직접 보내는 시험이 아니다. | A/B × 임계값 2개, 네 조합 모두 15회 정확한 결과와 `isUseServerPrepare()=true`; 각 조합의 관측 backend는 `1개`. |
+
+SQL-level 테스트는 `setAutoCommit(false)` 이후 `PREPARE`, `EXECUTE`, `DEALLOCATE`를 **같은 트랜잭션 안에서** 실행해 `41`을 확인하고 `rollback()`했다. 프록시 A 결과와 B 결과가 같은지도 assert했다. 프로토콜 테스트는 같은 `PreparedStatement`를 유지한 채 **매 반복 후 commit**해 15개 트랜잭션을 통과했다.
+
+## 결과·해석
+
+[JUnit 요약](../opensql-contract-evidence/junit-summary.json)에 `CONTRACT_SQL_PREPARE proxy=proxy-a result=same_transaction_pass`와 B의 동일한 결과가 있다. SQL-level 방식은 양쪽 모두 **동일 트랜잭션 안에서** 41을 반환하고 명시적으로 정리됐다. 프로토콜 방식은 A/B × threshold `5/1` 네 조합에서 모두 15회 정확한 정수를 반환하고 `PGStatement.isUseServerPrepare()`가 `true`였다.
+
+따라서 “SQL-level 명령도 동작했다”와 “pgJDBC의 server-side prepare도 해당 반복 조건에서 동작했다”는 두 개의 별도 결론을 낼 수 있다. 전자를 근거로 후자의 캐시 정책을 입증하거나, 후자를 근거로 SQL-level 준비문의 세션 간 보존을 주장하면 안 된다.
+
+## 남은 검증
+
+SQL-level 시험은 **트랜잭션 간 재사용**이나 **다른 backend로 이동한 뒤 재사용**을 해보지 않았다. 프로토콜 반복은 15번의 commit을 거쳤지만 각 조합에서 관측한 backend가 **1개**여서 backend 전환 시 재준비를 검증하지 못했다. `prepared_statements_cache_size`의 일반 TOML `1000`과 관리 풀 값 `0`의 우선순위도 확정되지 않았다. 이 문서의 완료 판정은 두 방식을 구분해 **실제로 수행한 범위의 동작을 각각 기록했다**는 뜻이다.
diff --git a/docs/test-results/opensql-contract-verification/timezone-and-midnight-boundary.md b/docs/test-results/opensql-contract-verification/timezone-and-midnight-boundary.md
new file mode 100644
index 0000000..f352a16
--- /dev/null
+++ b/docs/test-results/opensql-contract-verification/timezone-and-midnight-boundary.md
@@ -0,0 +1,38 @@
+# JVM·DB·OS 시간대와 자정 경계 확인
+
+## 검증 목표
+
+개발자 컴퓨터의 JVM, GCP VM/컨테이너의 OS, 프록시를 거친 DB 세션의 시간대가 **같은지 다른지** 먼저 기록했다. 그런 다음 한국 시간 자정 직후의 `timestamp`와 `timestamptz`를 A/B OpenProxy에서 왕복시키고, 한 트랜잭션에서 바꾼 `SET LOCAL TIME ZONE`이 다음 요청에 남지 않는지 확인했다.
+
+## 실제 명령·실행 위치
+
+개발자 컴퓨터의 저장소 루트에서 A/B JDBC URL을 임시 SSH 터널로 주고 `./backend/gradlew -p backend openSqlOpenProxyContractTest --rerun-tasks --offline`를 실행했다. 이때 자정 경계 테스트는 같은 JUnit 실행의 5개 테스트 중 하나였다. 별도로 개발자 컴퓨터에서 `bash scripts/opensql/capture_live_ha_contract.sh`를 실행했고, 그 스크립트의 `collect`·`runtime` 모드가 각각 Rocky 컨테이너와 GCP VM 호스트에서 `date +%Z%z`를 호출해 OS 시간대를 비식별화된 문자열로 기록했다.
+
+| 위치 | 실행 코드·SQL | 목적 | 결과 요약 |
+| --- | --- | --- | --- |
+| 개발자 컴퓨터의 저장소 루트 | `./backend/gradlew -p backend openSqlOpenProxyContractTest --rerun-tasks --offline` | A/B 자정 경계·세션 시험을 포함한 계약 JUnit을 실행한다. | 전체 5개 통과·실패 0건; A/B 모두 `boundary=pass`를 기록했다. |
+| 개발자 컴퓨터의 저장소 루트 | `bash scripts/opensql/capture_live_ha_contract.sh` | 세 GCP VM 호스트와 Rocky 컨테이너의 시간대를 수집한다. | 노드 3개에서 호스트·컨테이너 모두 `UTC+0000`으로 기록됐다. |
+| 개발자 컴퓨터 JVM | `ZoneId.systemDefault()` | 테스트 실행 JVM의 기본 시간대를 확인한다. | `Asia/Seoul`이었다. |
+| A/B를 통한 원격 DB 세션 | `SHOW TimeZone` | 실제 세션의 날짜 변환 기준을 확인한다. | A/B 모두 `Asia/Seoul`이었다. |
+| 같은 DB 세션 | `SELECT ?::timestamp, ?::timestamptz` | 벽시계 날짜와 시간대가 포함된 순간이 각각 왕복 보존되는지 비교한다. | A/B 모두 지정한 `2026-09-27 00:00:01` 벽시계값과 `+09:00` 순간을 보존해 `boundary=pass`. |
+| 같은 DB 세션의 명시적 트랜잭션 | `SET LOCAL TIME ZONE 'Pacific/Honolulu'` → `SHOW TimeZone` → `rollback()` → 다시 `SHOW TimeZone` | 트랜잭션 국소 설정이 풀의 다음 사용으로 새지 않는지 확인한다. | 트랜잭션 안에서는 `Pacific/Honolulu`, rollback 후에는 A/B 모두 원래의 `Asia/Seoul`로 복귀했다. |
+| 세 GCP VM 호스트 및 세 Rocky 컨테이너 | `date +%Z%z` | OS 시간대 표기를 수집한다. DB 세션 시간대와 혼동하지 않는다. | 호스트 3대와 컨테이너 3개 모두 `UTC+0000`이었다. |
+
+핵심 Java 코드는 [`timeZoneBoundaryIsStableAcrossProxies()`](../../../backend/src/test/java/com/opensource/docgrid/opensql/OpenSqlOpenProxyContractTest.java)에 있다. 입력은 `LocalDateTime.of(2026, 9, 27, 0, 0, 1)`과 같은 벽시계값의 `OffsetDateTime` `+09:00`이다. `timestamp`는 `LocalDateTime`으로 같은 값인지 비교하고, `timestamptz`는 조회된 `OffsetDateTime`의 **`Instant`가 원본과 같은지** 비교했다. 표시 오프셋 문자열의 동일성이 아니라 순간의 동일성을 검사한 것이다. A/B의 `SHOW TimeZone` 및 서버 버전도 서로 같아야 테스트가 통과한다.
+
+## 관측 결과
+
+| 대상 | 관측 시간대 | 해석 |
+| --- | --- | --- |
+| JUnit을 실행한 JVM | `Asia/Seoul` | Java 기본 시간대다. GCP VM OS의 시간대와 다르다. |
+| proxy-a를 통한 DB 세션 | `Asia/Seoul` | 쿼리를 실행한 DB 세션 시간대다. |
+| proxy-b를 통한 DB 세션 | `Asia/Seoul` | A와 같다. |
+| GCP VM 3대와 Rocky 컨테이너 3개 | `UTC+0000` | OS 시간대이며 JDBC 세션의 시간대를 강제로 UTC로 만든다는 의미는 아니다. |
+| 자정 경계 왕복 | A/B 모두 `boundary=pass` | 지정한 `timestamp` 벽시계값과 `timestamptz` 순간이 보존됐다. |
+| `SET LOCAL` 후 rollback | A/B 모두 원래 DB 세션 시간대 복귀 | 이번 트랜잭션 국소 설정은 다음 조회에 남지 않았다. |
+
+출처는 [JUnit 요약](../opensql-contract-evidence/junit-summary.json)의 `CONTRACT_TIMEZONE` 출력, [node1](../opensql-contract-evidence/node1.json)·[node2](../opensql-contract-evidence/node2.json)·[node3](../opensql-contract-evidence/node3.json)의 `container_time_zone` 및 `runtime.host_time_zone`이다. JUnit의 출력은 A/B 각각 `jvm=Asia/Seoul db=Asia/Seoul boundary=pass`였다.
+
+## 결론과 범위
+
+**완료:** 서로 다른 JVM/DB/OS 시간대 조건에서 지정한 자정 경계의 두 PostgreSQL 날짜 유형이 A/B 모두에서 일관되게 왕복했고, `SET LOCAL`이 rollback 뒤 누수되지 않았다. **미검증:** 모든 날짜 값·DST 경계·JSON 직렬화·Spring/JPA 변환·다른 JVM 기본 시간대·네트워크 장애 뒤 재연결 시 세션 시간대. 이번 한 날짜의 통과를 “시간대 문제 전부 해결”로 확대하지 않는다.
diff --git a/scripts/opensql/capture_ha_contract.py b/scripts/opensql/capture_ha_contract.py
new file mode 100644
index 0000000..e5a7c99
--- /dev/null
+++ b/scripts/opensql/capture_ha_contract.py
@@ -0,0 +1,556 @@
+#!/usr/bin/env python3
+"""Collect only allowlisted OpenSQL HA settings and assemble a public-safe fingerprint."""
+
+from __future__ import annotations
+
+import argparse
+import ast
+import csv
+import hashlib
+import io
+import json
+import os
+import platform
+import re
+import subprocess
+import sys
+import xml.etree.ElementTree as ET
+from pathlib import Path
+
+
+NODE = re.compile(r"node[123]\Z")
+PRIVATE_VALUE = re.compile(r"(?:\b\d{1,3}(?:\.\d{1,3}){3}\b|@|password|secret|token|license)", re.I)
+VERSION = re.compile(r"[A-Za-z0-9][A-Za-z0-9 ._+():-]{0,127}\Z")
+PROXY_FIELDS = {
+ "general": {"prepared_statements_cache_size", "worker_threads", "connect_timeout",
+ "healthcheck_timeout", "healthcheck_delay", "shutdown_timeout", "ban_time",
+ "renew_interval"},
+ "pools.docgrid": {"pool_mode", "default_role", "query_parser_enabled",
+ "query_parser_read_write_splitting"},
+ "pools.docgrid.users.0": {"pool_size", "statement_timeout"},
+ "pools.docgrid.shards.0": {"use_patroni", "patroni_port"},
+}
+PATRONI_FIELDS = {"ttl", "loop_wait", "retry_timeout", "primary_start_timeout",
+ "primary_stop_timeout", "maximum_lag_on_failover", "check_timeline",
+ "synchronous_mode", "synchronous_mode_strict", "failsafe_mode"}
+SERVICE_FIELDS = {"Restart", "RestartSec", "KillSignal", "TimeoutStopSec"}
+ADMIN_CONFIG_FIELDS = {"prepared_statements_cache_size", "connect_timeout",
+ "shutdown_timeout", "pools.docgrid.pool_mode",
+ "pools.docgrid.default_role", "pools.docgrid.query_parser_enabled",
+ "pools.docgrid.query_parser_read_write_splitting",
+ "pools.docgrid.primary_reads_enabled",
+ "pools.docgrid.load_balancing_mode",
+ "pools.docgrid.prepared_statements_cache_size"}
+
+
+class ContractError(ValueError):
+ """Reject ambiguous or potentially identifying evidence before publication."""
+
+
+def scalar(raw: str):
+ """Parse only the small TOML/YAML scalar subset used by contract settings."""
+ value = raw.split("#", 1)[0].strip()
+ if value in ("true", "false"):
+ return value == "true"
+ if re.fullmatch(r"-?\d+", value):
+ return int(value)
+ if value.startswith(('"', "'")):
+ try:
+ parsed = ast.literal_eval(value)
+ except (SyntaxError, ValueError) as error:
+ raise ContractError("설정 문자열을 해석할 수 없습니다") from error
+ if isinstance(parsed, str):
+ return parsed
+ raise ContractError("허용된 단일 설정값 형식이 아닙니다")
+
+
+def safe_value(value):
+ """Keep internal addresses, credentials, and paths out of every generated result."""
+ if isinstance(value, str) and PRIVATE_VALUE.search(value):
+ raise ContractError("공개할 수 없는 설정값이 포함되었습니다")
+ return value
+
+
+def proxy_settings(path: Path):
+ """Read only named scalar keys; never copy a credential-bearing TOML section."""
+ settings = {section: {} for section in PROXY_FIELDS}
+ section = None
+ for line in path.read_text(encoding="utf-8").splitlines():
+ heading = re.fullmatch(r"\s*\[([A-Za-z0-9_.]+)\]\s*(?:#.*)?", line)
+ if heading:
+ section = heading.group(1)
+ continue
+ if section not in PROXY_FIELDS:
+ continue
+ assignment = re.match(r"\s*([A-Za-z_][A-Za-z_0-9]*)\s*=\s*(.*)", line)
+ if not assignment or assignment.group(1) not in PROXY_FIELDS[section]:
+ continue
+ key = assignment.group(1)
+ if key in settings[section]:
+ raise ContractError(f"OpenProxy 설정 중복: {section}.{key}")
+ settings[section][key] = safe_value(scalar(assignment.group(2)))
+ if not settings["pools.docgrid"] or not settings["pools.docgrid.shards.0"]:
+ raise ContractError("DocGrid OpenProxy 풀을 찾지 못했습니다")
+ return settings
+
+
+def patroni_settings(text: str):
+ """Extract top-level dynamic keys from patronictl without serializing its full output."""
+ settings = {}
+ for line in text.splitlines():
+ match = re.fullmatch(r"([a-z_]+):\s*(.*?)\s*", line)
+ if not match or match.group(1) not in PATRONI_FIELDS:
+ continue
+ key = match.group(1)
+ if key in settings:
+ raise ContractError(f"Patroni 동적 설정 중복: {key}")
+ settings[key] = safe_value(scalar(match.group(2)))
+ if not settings:
+ raise ContractError("Patroni 동적 설정을 읽지 못했습니다")
+ return {key: settings.get(key) for key in sorted(PATRONI_FIELDS)}
+
+
+def service_template(path: Path):
+ """Label packaged systemd values as a template, not as the active supervisor."""
+ if not path.exists():
+ return None
+ values = {}
+ section = None
+ for line in path.read_text(encoding="utf-8").splitlines():
+ heading = re.fullmatch(r"\s*\[([^]]+)\]\s*", line)
+ if heading:
+ section = heading.group(1)
+ elif section == "Service":
+ match = re.fullmatch(r"\s*([A-Za-z]+)\s*=\s*([^#\s]+)\s*", line)
+ if match and match.group(1) in SERVICE_FIELDS:
+ values[match.group(1)] = safe_value(match.group(2))
+ return values
+
+
+def version(binary: Path, *options):
+ """Report only a short version line; suppress arbitrary command output on failure."""
+ if not binary.is_file():
+ return None
+ try:
+ result = subprocess.run([str(binary), *options], capture_output=True, text=True,
+ timeout=5, check=True)
+ except (OSError, subprocess.SubprocessError):
+ return None
+ first = result.stdout.splitlines()[0].strip() if result.stdout.splitlines() else ""
+ return first if VERSION.fullmatch(first) else None
+
+
+def opensql_version(binary: Path):
+ """Extract the product release from a multiline, banner-style version response."""
+ result = subprocess.run([str(binary), "--version"], capture_output=True, text=True,
+ timeout=5, check=True)
+ match = re.search(r"^OpenSQL version (v\d+(?:\.\d+)+)$", result.stdout, re.MULTILINE)
+ if not match:
+ raise ContractError("OpenSQL 제품 버전을 판별할 수 없습니다")
+ return match.group(1)
+
+
+def os_release(path: Path):
+ """Expose only distro identity and release, excluding host-specific metadata."""
+ values = {}
+ for line in path.read_text(encoding="utf-8").splitlines():
+ match = re.fullmatch(r"(ID|VERSION_ID)=(.*)", line)
+ if match:
+ values[match.group(1).lower()] = safe_value(scalar(match.group(2)))
+ return values
+
+
+def time_zone():
+ """Record the OS clock label and offset without publishing host metadata."""
+ result = subprocess.run(["date", "+%Z%z"], capture_output=True, text=True,
+ timeout=5, check=True)
+ value = result.stdout.strip()
+ if not re.fullmatch(r"[A-Za-z_+/:-]{2,40}[+-]\d{4}", value):
+ raise ContractError("OS 시간대 표기를 판별할 수 없습니다")
+ return value
+
+
+def etcd_timing_inputs(root: Path, proc_root: Path = Path("/proc")):
+ """Resolve installed defaults against the one running etcd process's safe timing inputs."""
+ processes = []
+ for entry in proc_root.iterdir():
+ if not entry.name.isdecimal():
+ continue
+ try:
+ if (entry / "comm").read_text(encoding="utf-8").strip() == "etcd":
+ processes.append(entry)
+ except (OSError, UnicodeError):
+ continue
+ if len(processes) != 1:
+ raise ContractError("실행 중인 etcd 프로세스 하나를 확인하지 못했습니다")
+ command = processes[0].joinpath("cmdline").read_bytes().decode("utf-8").split("\0")
+ environment = processes[0].joinpath("environ").read_bytes().decode("utf-8").split("\0")
+ if any(argument.startswith("--config-file") for argument in command):
+ raise ContractError("별도 etcd 설정 파일의 우선순위를 확인해야 합니다")
+ help_result = subprocess.run([str(root / "bin/etcd"), "--help"],
+ capture_output=True, text=True, timeout=5, check=True)
+ help_text = help_result.stdout + help_result.stderr
+ timing = {}
+ for flag, variable, output in (("heartbeat-interval", "ETCD_HEARTBEAT_INTERVAL",
+ "heartbeat_interval_ms"),
+ ("election-timeout", "ETCD_ELECTION_TIMEOUT",
+ "election_timeout_ms")):
+ default = re.search(r"--" + flag + r"\s+'(\d+)'", help_text)
+ if not default:
+ raise ContractError(f"설치된 etcd의 기본값을 확인하지 못했습니다: {flag}")
+ explicit = [value.split("=", 1)[1] for value in environment
+ if value.startswith(variable + "=")]
+ for index, argument in enumerate(command):
+ if argument.startswith("--" + flag + "="):
+ explicit.append(argument.split("=", 1)[1])
+ elif argument == "--" + flag and index + 1 < len(command):
+ explicit.append(command[index + 1])
+ if len(explicit) > 1 or (explicit and not explicit[0].isdecimal()):
+ raise ContractError(f"실행 중인 etcd 시간 설정을 판별할 수 없습니다: {flag}")
+ timing[output] = {"value": int(explicit[0] if explicit else default.group(1)),
+ "source": "process override" if explicit else "installed binary default"}
+ return timing
+
+
+def etcd_settings(root: Path):
+ """Keep only HA timing overrides and normalized live member names."""
+ environment = root / "etc/etcd/etcd.env"
+ configured = {}
+ for line in environment.read_text(encoding="utf-8").splitlines():
+ if "=" not in line or line.lstrip().startswith("#"):
+ continue
+ key, raw = line.split("=", 1)
+ if key not in {"ETCD_INITIAL_CLUSTER", "ETCD_INITIAL_CLUSTER_STATE",
+ "ETCD_HEARTBEAT_INTERVAL", "ETCD_ELECTION_TIMEOUT"}:
+ continue
+ if key in configured:
+ raise ContractError(f"etcd 설정 중복: {key}")
+ configured[key] = raw.strip().strip('"\'')
+ names = []
+ for member in configured.get("ETCD_INITIAL_CLUSTER", "").split(","):
+ name = member.split("=", 1)[0]
+ if not re.fullmatch(r"etcd[123]", name):
+ raise ContractError("etcd 초기 멤버 이름을 안전하게 정규화할 수 없습니다")
+ names.append(name.replace("etcd", "node"))
+ if sorted(names) != ["node1", "node2", "node3"]:
+ raise ContractError("etcd 초기 멤버 세 개를 확인하지 못했습니다")
+ result = subprocess.run([str(root / "bin/etcdctl"),
+ "--endpoints=http://127.0.0.1:2379", "member", "list",
+ "--write-out=json"], capture_output=True, text=True,
+ timeout=10, check=True)
+ live = json.loads(result.stdout).get("members", [])
+ live_names = []
+ for member in live:
+ name = member.get("name", "")
+ if not re.fullmatch(r"etcd[123]", name) or member.get("isLearner", False):
+ raise ContractError("etcd 실행 멤버 구성을 판별할 수 없습니다")
+ live_names.append(name.replace("etcd", "node"))
+ if sorted(live_names) != sorted(names):
+ raise ContractError("etcd 초기 설정과 실행 멤버가 일치하지 않습니다")
+ timing = {}
+ for key, output in (("ETCD_HEARTBEAT_INTERVAL", "heartbeat_interval_ms"),
+ ("ETCD_ELECTION_TIMEOUT", "election_timeout_ms")):
+ raw = configured.get(key)
+ if raw is not None and (not raw.isdecimal() or int(raw) <= 0):
+ raise ContractError(f"etcd 시간 설정을 판별할 수 없습니다: {key}")
+ timing[output] = int(raw) if raw is not None else None
+ return {"initial_cluster_state": safe_value(configured.get("ETCD_INITIAL_CLUSTER_STATE")),
+ "configured_member_names": sorted(names), "live_member_names": sorted(live_names),
+ "quorum": len(live_names) // 2 + 1, "explicit_timing": timing,
+ "running_timing": etcd_timing_inputs(root)}
+
+
+def patroni_members(text: str):
+ """Normalize member names and roles without exposing REST addresses."""
+ members = json.loads(text)
+ if not isinstance(members, list) or len(members) != 3:
+ raise ContractError("Patroni 멤버 세 개를 확인하지 못했습니다")
+ normalized = []
+ for member in members:
+ name = member.get("Member")
+ match = re.fullmatch(r"(?:docgrid-)?node([123])|postgresql([123])", name or "")
+ if not match:
+ raise ContractError("Patroni 멤버 이름을 안전한 별칭으로 바꿀 수 없습니다")
+ role = member.get("Role")
+ state = member.get("State")
+ if role not in {"Leader", "Replica", "Sync Standby", "Standby Leader"} or not isinstance(state, str):
+ raise ContractError("Patroni 역할 또는 상태를 판별할 수 없습니다")
+ normalized.append({"node": "node" + (match.group(1) or match.group(2)),
+ "role": role, "state": safe_value(state)})
+ if {member["node"] for member in normalized} != {"node1", "node2", "node3"}:
+ raise ContractError("Patroni 멤버 이름이 중복되거나 누락되었습니다")
+ return sorted(normalized, key=lambda member: member["node"])
+
+
+def collect(args):
+ """Build one read-only node snapshot from installed configuration and binaries."""
+ if not NODE.fullmatch(args.node):
+ raise ContractError("노드 별칭은 node1~node3이어야 합니다")
+ root = args.install_root
+ document = {
+ "schema_version": 1,
+ "node": args.node,
+ "os": {**os_release(args.os_release), "architecture": platform.machine()},
+ "container_time_zone": time_zone(),
+ "versions": {
+ "opensql": opensql_version(root / "bin/opensql"),
+ "openproxy": version(root / "bin/openproxy", "--version"),
+ "openproxy_revision": version(root / "bin/openproxy", "--revision"),
+ "patroni": version(root / "bin/patroni", "--version"),
+ "etcd": version(root / "bin/etcd", "--version"),
+ "psql_client": version(root / "bin/psql", "--version"),
+ "postgres_server_binary": version(root / "bin/postgres", "--version"),
+ },
+ "etcd": etcd_settings(root),
+ "patroni_dynamic": patroni_settings(subprocess.run(
+ [str(root / "bin/patronictl"), "-c", str(root / "etc/patroni/patroni.yml"),
+ "show-config"], capture_output=True, text=True, timeout=10,
+ check=True).stdout),
+ "patroni_members": patroni_members(subprocess.run(
+ [str(root / "bin/patronictl"), "-c", str(root / "etc/patroni/patroni.yml"),
+ "list", "--format", "json"], capture_output=True, text=True, timeout=10,
+ check=True).stdout),
+ }
+ proxy = root / "etc/openproxy/openproxy.toml"
+ if proxy.exists():
+ document["openproxy"] = proxy_settings(proxy)
+ document["openproxy_service_template"] = service_template(
+ root / "etc/openproxy/openproxy.service")
+ return document
+
+
+def runtime(args):
+ """Report the active container and host unit, distinct from a packaged unit template."""
+ if not NODE.fullmatch(args.node):
+ raise ContractError("노드 별칭은 node1~node3이어야 합니다")
+ container = f"docgrid-{args.node}"
+ inspect = subprocess.run(["sudo", "-n", "docker", "inspect", "--format",
+ "{{.HostConfig.RestartPolicy.Name}}", container],
+ capture_output=True, text=True, timeout=5, check=True)
+ unit = subprocess.run(["systemctl", "show", f"docgrid-opensql@{container}.service",
+ "-p", "ActiveState", "-p", "Restart", "-p", "RestartUSec"],
+ capture_output=True, text=True, timeout=5, check=True)
+ fields = dict(line.split("=", 1) for line in unit.stdout.splitlines() if "=" in line)
+ processes = subprocess.run(["sudo", "-n", "docker", "exec", container,
+ "ps", "-eo", "ppid=,stat=,comm="],
+ capture_output=True, text=True, timeout=5, check=True)
+ openproxy_parents = []
+ for line in processes.stdout.splitlines():
+ columns = line.split()
+ if len(columns) >= 3 and columns[2] == "openproxy" and not columns[1].startswith("Z"):
+ openproxy_parents.append(columns[0])
+ if inspect.stdout.strip() not in {"always", "unless-stopped", "no", "on-failure"}:
+ raise ContractError("컨테이너 재시작 정책을 판별할 수 없습니다")
+ return {"node": args.node, "container_restart_policy": inspect.stdout.strip(),
+ "host_time_zone": time_zone(),
+ "host_bootstrap_unit": {key: safe_value(fields.get(key)) for key in
+ ("ActiveState", "Restart", "RestartUSec")},
+ "openproxy_live_process_count": len(openproxy_parents),
+ "openproxy_parent_is_container_pid1": all(parent == "1" for parent in openproxy_parents)
+ if openproxy_parents else None}
+
+
+def admin_credentials(path: Path):
+ """Read administrator credentials only in container memory, never in collector output."""
+ fields = {}
+ section = None
+ for line in path.read_text(encoding="utf-8").splitlines():
+ heading = re.fullmatch(r"\s*\[([A-Za-z0-9_.]+)\]\s*(?:#.*)?", line)
+ if heading:
+ section = heading.group(1)
+ continue
+ if section != "general":
+ continue
+ match = re.match(r"\s*(port|admin_port|admin_username|admin_password)\s*=\s*(.*)", line)
+ if match:
+ fields[match.group(1)] = scalar(match.group(2))
+ if not isinstance(fields.get("port"), int) or not all(
+ isinstance(fields.get(key), str) for key in ("admin_username", "admin_password")):
+ raise ContractError("OpenProxy 관리자 접속 정보를 읽지 못했습니다")
+ return fields
+
+
+def admin_rows(root: Path, credentials: dict, command: str):
+ """Run a read-only SHOW command; keep psql stdout/stderr and password in memory."""
+ environment = os.environ.copy()
+ environment["PGPASSWORD"] = credentials["admin_password"]
+ try:
+ result = subprocess.run(
+ [str(root / "bin/psql"), "-X", "-w", "--csv", "-P", "footer=off",
+ "-h", "127.0.0.1", "-p", str(credentials["port"]),
+ "-d", "openproxy", "-U", credentials["admin_username"], "-c", command],
+ capture_output=True, text=True, timeout=10, check=True, env=environment)
+ except subprocess.CalledProcessError as error:
+ reason = ("authentication" if "authentication failed" in error.stderr.lower() else
+ "connection" if "connection refused" in error.stderr.lower() else
+ "psql-command")
+ raise ContractError(f"{command} 조회 실패 ({reason})") from error
+ return list(csv.DictReader(io.StringIO(result.stdout)))
+
+
+def admin_snapshot(args):
+ """Expose only effective contract settings and role-level counters from OpenProxy."""
+ if args.node not in {"node2", "node3"}:
+ raise ContractError("관리 콘솔은 node2 또는 node3에서만 수집합니다")
+ root = args.install_root
+ credentials = admin_credentials(root / "etc/openproxy/openproxy.toml")
+ config = {}
+ for row in admin_rows(root, credentials, "SHOW CONFIG"):
+ key = row.get("key", "").strip()
+ if key in ADMIN_CONFIG_FIELDS:
+ config[key] = safe_value(scalar(row.get("value", "").strip()))
+ if not config:
+ raise ContractError("OpenProxy 실제 설정을 확인하지 못했습니다")
+ stats = []
+ for row in admin_rows(root, credentials, "SHOW STATS"):
+ if row.get("database") != "docgrid":
+ continue
+ instance = row.get("instance", "")
+ match = re.fullmatch(r"docgrid_shard_\d+_(primary|replica_\d+)", instance)
+ if not match:
+ raise ContractError("OpenProxy 통계 역할을 안전하게 정규화할 수 없습니다")
+ stats.append({"role": match.group(1), "queries": int(row["total_query_count"]),
+ "transactions": int(row["total_xact_count"]),
+ "errors": int(row["total_errors"])})
+ servers = []
+ for row in admin_rows(root, credentials, "SHOW SERVERS"):
+ if row.get("database_name") != "docgrid":
+ continue
+ match = re.fullmatch(r"docgrid_shard_\d+_(primary|replica_\d+)",
+ row.get("address_id", ""))
+ if not match:
+ raise ContractError("OpenProxy 서버 역할을 안전하게 정규화할 수 없습니다")
+ servers.append({"role": match.group(1),
+ "prepare_cache_hit": int(row["prepare_cache_hit"]),
+ "prepare_cache_miss": int(row["prepare_cache_miss"]),
+ "prepare_cache_eviction": int(row["prepare_cache_eviction"]),
+ "prepare_cache_size": int(row["prepare_cache_size"])})
+ return {"node": args.node, "proxy": "proxy-a" if args.node == "node2" else "proxy-b",
+ "effective_config": config,
+ "stats": sorted(stats, key=lambda row: row["role"]),
+ "servers": sorted(servers, key=lambda row: row["role"])}
+
+
+def merge_node(collect_path: Path, runtime_path: Path):
+ """Join independently collected container and VM observations without hand editing."""
+ node = json.loads(collect_path.read_text(encoding="utf-8"))
+ runtime_data = json.loads(runtime_path.read_text(encoding="utf-8"))
+ if node.get("node") != runtime_data.get("node") or not NODE.fullmatch(node.get("node", "")):
+ raise ContractError("컨테이너와 VM 수집 결과의 노드가 다릅니다")
+ if "runtime" in node or PRIVATE_VALUE.search(json.dumps({"node": node, "runtime": runtime_data})):
+ raise ContractError("중복 또는 공개 불가능한 수집 결과입니다")
+ node["runtime"] = {key: value for key, value in runtime_data.items() if key != "node"}
+ return node
+
+
+def assemble(paths, admin_paths=None):
+ """Compare A/B contracts and hash all supplied, sanitized node/admin observations."""
+ nodes = [json.loads(path.read_text(encoding="utf-8")) for path in paths]
+ if {node.get("node") for node in nodes} != {"node1", "node2", "node3"}:
+ raise ContractError("node1~node3 결과가 각각 하나씩 필요합니다")
+ for node in nodes:
+ if node.get("schema_version") != 1 or PRIVATE_VALUE.search(json.dumps(node)):
+ raise ContractError("스냅샷 형식 또는 공개 가능성을 확인할 수 없습니다")
+ by_name = {node["node"]: node for node in nodes}
+ expected_os = {"id": "rocky", "version_id": "9.7", "architecture": "x86_64"}
+ if any(node.get("os") != expected_os for node in nodes):
+ raise ContractError("세 노드의 Rocky Linux 9.7 x86_64 구성이 일치하지 않습니다")
+ if "openproxy" in by_name["node1"]:
+ raise ContractError("node1에 예상하지 않은 OpenProxy 설정이 있습니다")
+ if by_name["node2"].get("openproxy") != by_name["node3"].get("openproxy"):
+ raise ContractError("두 OpenProxy의 계약 관련 설정이 다릅니다")
+ if by_name["node2"].get("versions", {}).get("openproxy") != by_name["node3"].get("versions", {}).get("openproxy"):
+ raise ContractError("두 OpenProxy의 설치 버전이 다릅니다")
+ product_versions = [node.get("versions", {}).get("opensql") for node in nodes]
+ if any(product_versions) and (None in product_versions or len(set(product_versions)) != 1):
+ raise ContractError("세 노드의 OpenSQL 제품 버전이 다릅니다")
+ if len({json.dumps(node.get("patroni_dynamic"), sort_keys=True) for node in nodes}) != 1:
+ raise ContractError("Patroni 동적 설정의 노드별 조회 결과가 다릅니다")
+ snapshot = {"schema_version": 1, "nodes": [by_name[name] for name in sorted(by_name)]}
+ if admin_paths:
+ admins = [json.loads(path.read_text(encoding="utf-8")) for path in admin_paths]
+ if {admin.get("proxy") for admin in admins} != {"proxy-a", "proxy-b"} or any(
+ PRIVATE_VALUE.search(json.dumps(admin)) for admin in admins):
+ raise ContractError("관리 결과 별칭 또는 공개 가능성을 확인할 수 없습니다")
+ by_proxy = {admin["proxy"]: admin for admin in admins}
+ if by_proxy["proxy-a"].get("node") != "node2" or by_proxy["proxy-b"].get("node") != "node3":
+ raise ContractError("관리 결과와 프록시 노드가 일치하지 않습니다")
+ if by_proxy["proxy-a"].get("effective_config") != by_proxy["proxy-b"].get("effective_config"):
+ raise ContractError("두 OpenProxy의 관리 콘솔 설정이 다릅니다")
+ snapshot["admins"] = [by_proxy[name] for name in sorted(by_proxy)]
+ canonical = json.dumps(snapshot, ensure_ascii=False, sort_keys=True,
+ separators=(",", ":")).encode("utf-8")
+ return {"snapshot": snapshot, "evidence_sha256": hashlib.sha256(canonical).hexdigest()}
+
+
+def junit_summary(path: Path):
+ """Preserve test cases and allowlisted observations without machine names or secrets."""
+ suite = ET.parse(path).getroot()
+ if suite.tag != "testsuite":
+ raise ContractError("JUnit 시험 결과 형식을 확인할 수 없습니다")
+ timestamp = suite.get("timestamp")
+ if timestamp is not None and not re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z", timestamp):
+ raise ContractError("JUnit 실행 시각을 판별할 수 없습니다")
+ cases = []
+ for case in suite.findall("testcase"):
+ cases.append({"name": safe_value(case.get("name", "")),
+ "status": "failed" if case.find("failure") is not None else
+ "error" if case.find("error") is not None else
+ "skipped" if case.find("skipped") is not None else "passed"})
+ observations = []
+ for line in (suite.findtext("system-out") or "").splitlines():
+ if line.startswith("CONTRACT_"):
+ if PRIVATE_VALUE.search(line) or not re.fullmatch(
+ r"CONTRACT_[A-Z_]+ [A-Za-z0-9_=./:+ -]{1,300}", line):
+ raise ContractError("공개할 수 없는 시험 관측값이 포함되었습니다")
+ observations.append(line)
+ return {"tests": int(suite.get("tests", "0")),
+ "executed_at_utc": timestamp,
+ "duration_seconds": float(suite.get("time", "0")),
+ "failures": int(suite.get("failures", "0")),
+ "errors": int(suite.get("errors", "0")),
+ "skipped": int(suite.get("skipped", "0")),
+ "cases": cases, "observations": observations}
+
+
+def main(argv=None):
+ """Keep collection and local assembly explicit so raw configs are never transferred."""
+ parser = argparse.ArgumentParser(description=__doc__)
+ actions = parser.add_subparsers(dest="action", required=True)
+ one = actions.add_parser("collect")
+ one.add_argument("--node", required=True)
+ one.add_argument("--install-root", type=Path, default=Path("/var/lib/docgrid/opensql"))
+ one.add_argument("--os-release", type=Path, default=Path("/etc/os-release"))
+ host = actions.add_parser("runtime")
+ host.add_argument("--node", required=True)
+ admin = actions.add_parser("admin")
+ admin.add_argument("--node", required=True)
+ admin.add_argument("--install-root", type=Path, default=Path("/var/lib/docgrid/opensql"))
+ merge = actions.add_parser("merge")
+ merge.add_argument("collect_json", type=Path)
+ merge.add_argument("runtime_json", type=Path)
+ many = actions.add_parser("assemble")
+ many.add_argument("node_json", nargs=3, type=Path)
+ many.add_argument("--admin", nargs=2, type=Path)
+ junit = actions.add_parser("junit")
+ junit.add_argument("xml", type=Path)
+ args = parser.parse_args(argv)
+ try:
+ data = (collect(args) if args.action == "collect" else
+ runtime(args) if args.action == "runtime" else
+ admin_snapshot(args) if args.action == "admin" else
+ merge_node(args.collect_json, args.runtime_json) if args.action == "merge" else
+ junit_summary(args.xml) if args.action == "junit" else
+ assemble(args.node_json, args.admin))
+ print(json.dumps(data, ensure_ascii=False, sort_keys=True, indent=2))
+ return 0
+ except ContractError as error:
+ print(f"HA 계약 수집 실패: {error}", file=sys.stderr)
+ return 1
+ except (OSError, subprocess.SubprocessError, json.JSONDecodeError, ET.ParseError) as error:
+ print(f"HA 계약 수집 실패: {type(error).__name__}", file=sys.stderr)
+ return 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/opensql/capture_live_ha_contract.sh b/scripts/opensql/capture_live_ha_contract.sh
new file mode 100755
index 0000000..edc4b9c
--- /dev/null
+++ b/scripts/opensql/capture_live_ha_contract.sh
@@ -0,0 +1,62 @@
+#!/usr/bin/env bash
+# Capture public-safe contract evidence from the three existing DocGrid VMs.
+set -euo pipefail
+
+: "${OPENSQL_GCP_ZONE:?Set the zone containing the existing three VMs}"
+: "${OPENSQL_SSH_KEY:?Set the SSH private-key path used for the existing VMs}"
+: "${OPENSQL_EXPECTED_ACCOUNT:?Set the approved GCP account}"
+: "${OPENSQL_EXPECTED_PROJECT:?Set the approved GCP project}"
+
+active_account="$(gcloud auth list --filter=status:ACTIVE --format='value(account)')"
+active_project="$(gcloud config get-value project 2>/dev/null)"
+if [[ "$active_account" != "$OPENSQL_EXPECTED_ACCOUNT" ||
+ "$active_project" != "$OPENSQL_EXPECTED_PROJECT" ]]; then
+ echo 'The active GCP account or project differs from the approved target.' >&2
+ exit 1
+fi
+
+repository_root="$(cd "$(dirname "$0")/../.." && pwd)"
+collector="$repository_root/scripts/opensql/capture_ha_contract.py"
+output="$repository_root/docs/test-results/opensql-contract-evidence"
+temporary="$(mktemp -d)"
+trap 'rm -r "$temporary"' EXIT
+started_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
+
+# 1. Read the container and VM separately; neither remote command prints raw config files.
+for node in node1 node2 node3; do
+ vm="docgrid-$node"
+ gcloud compute ssh "$vm" --zone="$OPENSQL_GCP_ZONE" \
+ --ssh-key-file="$OPENSQL_SSH_KEY" \
+ --command="sudo docker exec --user opensql -i $vm python3 - collect --node $node" --quiet \
+ < "$collector" > "$temporary/$node.collect.json"
+ gcloud compute ssh "$vm" --zone="$OPENSQL_GCP_ZONE" \
+ --ssh-key-file="$OPENSQL_SSH_KEY" \
+ --command="python3 - runtime --node $node" --quiet \
+ < "$collector" > "$temporary/$node.runtime.json"
+ python3 "$collector" merge "$temporary/$node.collect.json" \
+ "$temporary/$node.runtime.json" > "$temporary/$node.json"
+done
+
+# 2. Read OpenProxy's effective settings without exporting its administrator credentials.
+for pair in 'node2 proxy-a' 'node3 proxy-b'; do
+ read -r node proxy <<< "$pair"
+ vm="docgrid-$node"
+ gcloud compute ssh "$vm" --zone="$OPENSQL_GCP_ZONE" \
+ --ssh-key-file="$OPENSQL_SSH_KEY" \
+ --command="sudo docker exec -i $vm python3 - admin --node $node" --quiet \
+ < "$collector" > "$temporary/$proxy-admin.json"
+done
+
+# 3. Validate every sanitized input before replacing any published snapshot.
+python3 "$collector" assemble "$temporary/node1.json" "$temporary/node2.json" \
+ "$temporary/node3.json" --admin "$temporary/proxy-a-admin.json" \
+ "$temporary/proxy-b-admin.json" > "$temporary/contract-manifest.json"
+finished_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
+jq --arg started "$started_at" --arg finished "$finished_at" \
+ '. + {capture_started_at_utc: $started, capture_finished_at_utc: $finished}' \
+ "$temporary/contract-manifest.json" > "$temporary/timed-manifest.json"
+mv "$temporary/timed-manifest.json" "$temporary/contract-manifest.json"
+for name in node1 node2 node3 proxy-a-admin proxy-b-admin contract-manifest; do
+ cp "$temporary/$name.json" "$output/$name.json"
+done
+echo 'Public-safe OpenSQL contract snapshots refreshed.'
diff --git a/scripts/opensql/test_capture_ha_contract.py b/scripts/opensql/test_capture_ha_contract.py
new file mode 100644
index 0000000..a4150d6
--- /dev/null
+++ b/scripts/opensql/test_capture_ha_contract.py
@@ -0,0 +1,267 @@
+"""Check that OpenSQL contract snapshots are deterministic and credential-free."""
+
+from __future__ import annotations
+
+import importlib.util
+import json
+import tempfile
+import unittest
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import patch
+
+
+SCRIPT = Path(__file__).with_name("capture_ha_contract.py")
+SPEC = importlib.util.spec_from_file_location("capture_ha_contract", SCRIPT)
+if SPEC is None or SPEC.loader is None:
+ raise RuntimeError("HA 계약 수집기를 읽을 수 없습니다")
+CONTRACT = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(CONTRACT)
+
+
+class ContractSnapshotTest(unittest.TestCase):
+ """Guard allowlisted extraction and A/B contract comparisons before remote use."""
+
+ def setUp(self):
+ """Place realistic secrets next to public settings in an isolated config."""
+ self.temporary = tempfile.TemporaryDirectory()
+ self.addCleanup(self.temporary.cleanup)
+ self.root = Path(self.temporary.name)
+ self.config = self.root / "openproxy.toml"
+ self.config.write_text("""
+[general]
+prepared_statements_cache_size = 1000
+renew_interval = 5000
+admin_password = "do-not-publish-admin"
+
+[pools.docgrid]
+pool_mode = "transaction"
+query_parser_enabled = true
+query_parser_read_write_splitting = true
+
+[pools.docgrid.users.0]
+username = "docgrid_app"
+password = "do-not-publish-app"
+pool_size = 5
+
+[pools.docgrid.shards.0]
+servers = [
+ ["192.0.2.1", 5432, "auto"],
+ ["192.0.2.2", 5432, "auto"]
+]
+database = "docgrid"
+use_patroni = true
+patroni_port = "8008"
+""", encoding="utf-8")
+
+ def test_proxy_output_omits_passwords_and_addresses(self):
+ """Read only contract keys, even when secrets share the same section."""
+ settings = CONTRACT.proxy_settings(self.config)
+ serialized = json.dumps(settings)
+ self.assertEqual(1000, settings["general"]["prepared_statements_cache_size"])
+ self.assertEqual("transaction", settings["pools.docgrid"]["pool_mode"])
+ self.assertNotIn("do-not-publish", serialized)
+ self.assertNotIn("192.0.2", serialized)
+
+ def test_duplicate_or_sensitive_allowlisted_value_is_rejected(self):
+ """Do not silently choose one duplicate or print an identifying value."""
+ self.config.write_text(self.config.read_text() + "\n[pools.docgrid]\npool_mode = \"session\"\n")
+ with self.assertRaisesRegex(CONTRACT.ContractError, "중복"):
+ CONTRACT.proxy_settings(self.config)
+ self.config.write_text(self.config.read_text().replace(
+ 'pool_mode = "transaction"', 'pool_mode = "192.0.2.1"').replace(
+ '\n[pools.docgrid]\npool_mode = "session"\n', ''))
+ with self.assertRaisesRegex(CONTRACT.ContractError, "공개"):
+ CONTRACT.proxy_settings(self.config)
+
+ def test_patroni_top_level_only_and_missing_keys_are_explicit(self):
+ """A nested PostgreSQL option must not impersonate a dynamic HA setting."""
+ dynamic = CONTRACT.patroni_settings("""
+loop_wait: 10
+primary_start_timeout: 300
+failsafe_mode: false
+postgresql:
+ parameters:
+ password: do-not-publish
+""")
+ self.assertEqual(300, dynamic["primary_start_timeout"])
+ self.assertFalse(dynamic["failsafe_mode"])
+ self.assertIsNone(dynamic["ttl"])
+ self.assertNotIn("do-not-publish", json.dumps(dynamic))
+
+ def test_opensql_banner_version_is_extracted_without_copyright_text(self):
+ """The product version is not the first line of its --version banner."""
+ banner = "###\n\nOpenSQL version v3.17.8.7\n\nCopyright notice\n"
+ with patch.object(CONTRACT.subprocess, "run",
+ return_value=SimpleNamespace(stdout=banner)):
+ self.assertEqual("v3.17.8.7", CONTRACT.opensql_version(self.root / "opensql"))
+
+ def test_assembly_is_order_independent_and_rejects_proxy_drift(self):
+ """One canonical SHA-256 represents the same three sanitized nodes."""
+ paths = []
+ for node in ("node1", "node2", "node3"):
+ path = self.root / f"{node}.json"
+ data = {"schema_version": 1, "node": node,
+ "os": {"id": "rocky", "version_id": "9.7", "architecture": "x86_64"},
+ "patroni_dynamic": {"ttl": 30}}
+ if node != "node1":
+ data["openproxy"] = {"pool_mode": "transaction"}
+ data["versions"] = {"openproxy": "openproxy 1.1.3"}
+ path.write_text(json.dumps(data), encoding="utf-8")
+ paths.append(path)
+ first = CONTRACT.assemble(paths)
+ self.assertEqual(first, CONTRACT.assemble(list(reversed(paths))))
+ changed = json.loads(paths[2].read_text())
+ changed["openproxy"]["pool_mode"] = "session"
+ paths[2].write_text(json.dumps(changed), encoding="utf-8")
+ with self.assertRaisesRegex(CONTRACT.ContractError, "설정이 다릅니다"):
+ CONTRACT.assemble(paths)
+
+ def test_assembly_rejects_os_and_patroni_drift(self):
+ """A snapshot from a different OS or DCS state cannot prove one contract."""
+ paths = []
+ for node in ("node1", "node2", "node3"):
+ path = self.root / f"{node}.json"
+ path.write_text(json.dumps({"schema_version": 1, "node": node,
+ "os": {"id": "rocky", "version_id": "9.7", "architecture": "x86_64"},
+ "patroni_dynamic": {"ttl": 30},
+ **({"openproxy": {"pool_mode": "transaction"}} if node != "node1" else {})}),
+ encoding="utf-8")
+ paths.append(path)
+ changed = json.loads(paths[2].read_text())
+ changed["os"]["architecture"] = "aarch64"
+ paths[2].write_text(json.dumps(changed), encoding="utf-8")
+ with self.assertRaisesRegex(CONTRACT.ContractError, "Rocky Linux"):
+ CONTRACT.assemble(paths)
+ changed["os"]["architecture"] = "x86_64"
+ changed["patroni_dynamic"]["ttl"] = 60
+ paths[2].write_text(json.dumps(changed), encoding="utf-8")
+ with self.assertRaisesRegex(CONTRACT.ContractError, "Patroni"):
+ CONTRACT.assemble(paths)
+
+ def test_admin_snapshot_whitelists_effective_config_and_counters(self):
+ """Do not copy credentials or backend addresses from administrator tables."""
+ general = self.config.read_text().replace(
+ 'admin_password = "do-not-publish-admin"',
+ 'admin_port = 6433\nport = 6432\nadmin_username = "admin"\n'
+ 'admin_password = "do-not-publish-admin"')
+ self.config.write_text(general, encoding="utf-8")
+ credentials = CONTRACT.admin_credentials(self.config)
+ self.assertEqual(6432, credentials["port"])
+ rows = {
+ "SHOW CONFIG": [{"key": "pools.docgrid.prepared_statements_cache_size", "value": "0"},
+ {"key": "pools.docgrid.pool_mode", "value": '"Transaction"'},
+ {"key": "admin_password", "value": "do-not-publish-admin"}],
+ "SHOW STATS": [{"database": "docgrid", "instance": "docgrid_shard_0_replica_0",
+ "total_query_count": "7", "total_xact_count": "5", "total_errors": "0",
+ "user": "do-not-publish-app"}],
+ "SHOW SERVERS": [{"database_name": "docgrid", "address_id": "docgrid_shard_0_replica_0",
+ "prepare_cache_hit": "3", "prepare_cache_miss": "1",
+ "prepare_cache_eviction": "0", "prepare_cache_size": "1",
+ "user": "do-not-publish-app"}],
+ }
+ with patch.object(CONTRACT, "admin_rows", side_effect=lambda _root, _auth, sql: rows[sql]):
+ with patch.object(CONTRACT, "admin_credentials", return_value=credentials):
+ data = CONTRACT.admin_snapshot(SimpleNamespace(node="node2", install_root=self.root))
+ self.assertEqual(0, data["effective_config"]["pools.docgrid.prepared_statements_cache_size"])
+ self.assertEqual("Transaction", data["effective_config"]["pools.docgrid.pool_mode"])
+ self.assertEqual(7, data["stats"][0]["queries"])
+ self.assertNotIn("do-not-publish", json.dumps(data))
+
+ def test_etcd_snapshot_keeps_members_and_explicit_timing_without_urls(self):
+ """A live three-member result must not publish peer addresses or tokens."""
+ environment = self.root / "etc/etcd/etcd.env"
+ environment.parent.mkdir(parents=True)
+ environment.write_text(
+ "ETCD_INITIAL_CLUSTER=etcd1=http://192.0.2.1:2380,"
+ "etcd2=http://192.0.2.2:2380,etcd3=http://192.0.2.3:2380\n"
+ "ETCD_INITIAL_CLUSTER_STATE=existing\n"
+ "ETCD_INITIAL_CLUSTER_TOKEN=do-not-publish-token\n",
+ encoding="utf-8")
+ live = {"members": [{"name": f"etcd{number}",
+ "peerURLs": [f"http://192.0.2.{number}:2380"]}
+ for number in (1, 2, 3)]}
+ with patch.object(CONTRACT.subprocess, "run",
+ return_value=SimpleNamespace(stdout=json.dumps(live))):
+ with patch.object(CONTRACT, "etcd_timing_inputs", return_value={
+ "heartbeat_interval_ms": {"value": 100, "source": "installed binary default"},
+ "election_timeout_ms": {"value": 1000, "source": "installed binary default"}}):
+ settings = CONTRACT.etcd_settings(self.root)
+ self.assertEqual(2, settings["quorum"])
+ self.assertIsNone(settings["explicit_timing"]["election_timeout_ms"])
+ self.assertEqual(["node1", "node2", "node3"], settings["live_member_names"])
+ self.assertNotIn("192.0.2", json.dumps(settings))
+ self.assertNotIn("do-not-publish", json.dumps(settings))
+
+ def test_etcd_running_timing_uses_installed_defaults_or_process_override(self):
+ """Do not call an absent env override an effective default without checking /proc."""
+ process = self.root / "proc/1234"
+ process.mkdir(parents=True)
+ process.joinpath("comm").write_text("etcd\n", encoding="utf-8")
+ process.joinpath("cmdline").write_bytes(b"/bin/etcd\0")
+ process.joinpath("environ").write_bytes(b"ETCD_NAME=etcd1\0")
+ binary_help = "--heartbeat-interval '100'\n--election-timeout '1000'\n"
+ with patch.object(CONTRACT.subprocess, "run",
+ return_value=SimpleNamespace(stdout=binary_help, stderr="")):
+ timing = CONTRACT.etcd_timing_inputs(self.root, self.root / "proc")
+ self.assertEqual(1000, timing["election_timeout_ms"]["value"])
+ self.assertEqual("installed binary default", timing["election_timeout_ms"]["source"])
+ process.joinpath("environ").write_bytes(b"ETCD_ELECTION_TIMEOUT=1500\0")
+ overridden = CONTRACT.etcd_timing_inputs(self.root, self.root / "proc")
+ self.assertEqual(1500, overridden["election_timeout_ms"]["value"])
+ self.assertEqual("process override", overridden["election_timeout_ms"]["source"])
+
+ def test_merge_matches_node_and_assembly_hashes_admin_snapshots(self):
+ """Generated node/runtime joins and both admin snapshots enter one fingerprint."""
+ paths = []
+ for node in ("node1", "node2", "node3"):
+ collect = self.root / f"{node}-collect.json"
+ runtime = self.root / f"{node}-runtime.json"
+ collect.write_text(json.dumps({"schema_version": 1, "node": node,
+ "os": {"id": "rocky", "version_id": "9.7", "architecture": "x86_64"},
+ "patroni_dynamic": {"ttl": 30},
+ **({"openproxy": {"pool_mode": "transaction"}} if node != "node1" else {})}),
+ encoding="utf-8")
+ runtime.write_text(json.dumps({"node": node, "host_time_zone": "UTC+0000"}),
+ encoding="utf-8")
+ merged = CONTRACT.merge_node(collect, runtime)
+ path = self.root / f"{node}.json"
+ path.write_text(json.dumps(merged), encoding="utf-8")
+ paths.append(path)
+ admins = []
+ for proxy, node in (("proxy-a", "node2"), ("proxy-b", "node3")):
+ path = self.root / f"{proxy}.json"
+ path.write_text(json.dumps({"proxy": proxy, "node": node,
+ "effective_config": {"pool_mode": "Transaction"}}),
+ encoding="utf-8")
+ admins.append(path)
+ before = CONTRACT.assemble(paths, admins)["evidence_sha256"]
+ changed = json.loads(admins[1].read_text())
+ changed["effective_config"]["pool_mode"] = "Session"
+ admins[1].write_text(json.dumps(changed), encoding="utf-8")
+ with self.assertRaisesRegex(CONTRACT.ContractError, "관리 콘솔 설정"):
+ CONTRACT.assemble(paths, admins)
+ changed["effective_config"]["pool_mode"] = "Transaction"
+ changed["stats"] = [{"queries": 7}]
+ admins[1].write_text(json.dumps(changed), encoding="utf-8")
+ self.assertNotEqual(before, CONTRACT.assemble(paths, admins)["evidence_sha256"])
+
+ def test_junit_summary_drops_hostname_and_rejects_sensitive_output(self):
+ """Retain the case verdict while removing machine identity from Gradle XML."""
+ path = self.root / "TEST-contract.xml"
+ path.write_text(''
+ 'CONTRACT_PREPARED proxy=proxy-a threshold=5'
+ '', encoding="utf-8")
+ summary = CONTRACT.junit_summary(path)
+ self.assertEqual(1, summary["tests"])
+ self.assertEqual("passed", summary["cases"][0]["status"])
+ self.assertNotIn("private-host", json.dumps(summary))
+ path.write_text(path.read_text().replace("threshold=5", "password=do-not-publish"),
+ encoding="utf-8")
+ with self.assertRaisesRegex(CONTRACT.ContractError, "공개"):
+ CONTRACT.junit_summary(path)
+
+
+if __name__ == "__main__":
+ unittest.main()