From 3a7b6f7760bdb82351ffc71286c93fc8b24a77e9 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Sat, 29 Aug 2026 23:59:29 +0900 Subject: [PATCH] =?UTF-8?q?=EC=82=AC=EB=9E=8C=20=EC=8A=B9=EC=9D=B8=20?= =?UTF-8?q?=EA=B2=8C=EC=9D=B4=ED=8A=B8=EB=A5=BC=20=EB=AA=85=EC=8B=9C?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/test_continuous_improvement_store.py | 25 ++++++++++ infra/db/init/14_continuous_improvement.sql | 7 ++- scripts/run-outcome-os-release-agent.py | 13 +++++- .../smoke-continuous-improvement-ledger.sql | 45 ++++++++++++++++++ scripts/test_outcome_os_release_agent.py | 46 +++++++++++++++++++ 5 files changed, 133 insertions(+), 3 deletions(-) diff --git a/apps/api/app/test_continuous_improvement_store.py b/apps/api/app/test_continuous_improvement_store.py index b6d589f..f3278ed 100644 --- a/apps/api/app/test_continuous_improvement_store.py +++ b/apps/api/app/test_continuous_improvement_store.py @@ -590,6 +590,31 @@ class ContinuousImprovementBoundaryTests(unittest.TestCase): self.assertIn("executor_receipt_id", schema) self.assertIn("executor_evidence_refs", schema) + def test_schema_keeps_reject_and_quarantine_fail_closed_for_every_target(self) -> None: + schema_path = ( + Path(__file__).resolve().parents[3] + / "infra" + / "db" + / "init" + / "14_continuous_improvement.sql" + ) + schema = schema_path.read_text(encoding="utf-8") + self.assertIn( + "IF NEW.decision IN ('reject', 'keep_quarantine') THEN", + schema, + ) + self.assertIn("IF NEW.decision <> expected_decision", schema) + smoke = ( + Path(__file__).resolve().parents[3] + / "scripts" + / "smoke-continuous-improvement-ledger.sql" + ).read_text(encoding="utf-8") + self.assertIn("'content_qualification'", smoke) + self.assertIn("'keep_quarantine'", smoke) + self.assertIn("'release_gate'", smoke) + self.assertIn("'reject'", smoke) + self.assertIn("fail-closed human decision created a side effect", smoke) + def test_admin_projection_rejects_raw_transcript_claim(self) -> None: payload = { "content_qualifications": [], diff --git a/infra/db/init/14_continuous_improvement.sql b/infra/db/init/14_continuous_improvement.sql index a2a6581..832e4ff 100644 --- a/infra/db/init/14_continuous_improvement.sql +++ b/infra/db/init/14_continuous_improvement.sql @@ -491,7 +491,12 @@ BEGIN AND qualified THEN RETURN NEW; END IF; - IF NEW.decision NOT IN ('reject', expected_decision) + -- reject와 keep_quarantine는 부작용 없이 감사 이벤트만 남기는 fail-closed 선택이다. + -- 카탈로그·승격·롤백 효과를 만드는 긍정 결정만 계산된 게이트와 일치해야 한다. + IF NEW.decision IN ('reject', 'keep_quarantine') THEN + RETURN NEW; + END IF; + IF NEW.decision <> expected_decision OR (NEW.decision = expected_decision AND NOT qualified) THEN RAISE EXCEPTION 'human decision cannot bypass computed fail-closed gate' USING ERRCODE = '23514'; END IF; diff --git a/scripts/run-outcome-os-release-agent.py b/scripts/run-outcome-os-release-agent.py index ee53716..07e08fa 100644 --- a/scripts/run-outcome-os-release-agent.py +++ b/scripts/run-outcome-os-release-agent.py @@ -115,14 +115,17 @@ RELEASE_E2E_TIMEOUT_SECONDS = 15 * 60 # Existing preview volumes do not replay docker-entrypoint-initdb.d. These # forward-compatible, idempotent Outcome OS migrations must therefore land -# before the new API image is restarted. Every file is applied atomically. +# before the new API image is restarted. Transactional files are atomic; +# online indexes run outside a transaction so production writes stay available. RELEASE_DB_MIGRATIONS = ( "14_continuous_improvement.sql", "15_self_directed_practice_runtime.sql", "16_calibration_transfer_actual_execution.sql", "17_improvement_workbook_contracts.sql", + "18_admin_usage_ledger_index.sql", "19_auth_identity_alias.sql", ) +RELEASE_DB_ONLINE_MIGRATIONS = frozenset({"18_admin_usage_ledger_index.sql"}) # These files are runtime-critical but were added after the first manifest # snapshot. A release must classify them as whole-file payloads; otherwise a @@ -133,6 +136,7 @@ REQUIRED_RELEASE_PAYLOAD_PATHS = ( "infra/db/init/15_self_directed_practice_runtime.sql", "infra/db/init/16_calibration_transfer_actual_execution.sql", "infra/db/init/17_improvement_workbook_contracts.sql", + "infra/db/init/18_admin_usage_ledger_index.sql", "infra/db/init/19_auth_identity_alias.sql", ) @@ -867,6 +871,11 @@ class NasPreviewDeploymentDriver: migration_commands: list[str] = [] for migration_name in RELEASE_DB_MIGRATIONS: migration_path = f"{release_root}/infra/db/init/{migration_name}" + transaction_flag = ( + "" + if migration_name in RELEASE_DB_ONLINE_MIGRATIONS + else "--single-transaction " + ) migration_commands.extend( [ f"test -f {shlex.quote(migration_path)}", @@ -876,7 +885,7 @@ class NasPreviewDeploymentDriver: f"-f {shlex.quote(compose)} -f {shlex.quote(remote_override)} " "exec -T db sh -lc " + shlex.quote( - 'psql -v ON_ERROR_STOP=1 --single-transaction ' + f"psql -v ON_ERROR_STOP=1 {transaction_flag}" '-U "$POSTGRES_USER" -d "$POSTGRES_DB"' ) + f" < {shlex.quote(migration_path)}" diff --git a/scripts/smoke-continuous-improvement-ledger.sql b/scripts/smoke-continuous-improvement-ledger.sql index 3f854f2..6fcf5ad 100644 --- a/scripts/smoke-continuous-improvement-ledger.sql +++ b/scripts/smoke-continuous-improvement-ledger.sql @@ -280,6 +280,51 @@ SELECT set_config('app.current_ai_view','',true); SELECT set_config('app.current_role','admin',true); SELECT set_config('app.current_uid','14000000-0000-0000-0000-000000000001',true); +-- 보류와 반려는 모든 대상에서 감사 기록만 남기고 효과를 만들지 않는다. +INSERT INTO app.ci_ingestion_submission ( + submission_id, content_hash, operation_kind, result_id +) VALUES + ('14000000-0000-0000-0000-000000000064',repeat('8',64),'human_approval','14000000-0000-0000-0000-000000000065'), + ('14000000-0000-0000-0000-000000000066',repeat('9',64),'human_approval','14000000-0000-0000-0000-000000000067'); +INSERT INTO audit.ci_human_approval_event ( + approval_event_id, submission_id, target_kind, target_id, decision, actor_uid, + reason_code, evidence_refs, content_hash +) VALUES + ( + '14000000-0000-0000-0000-000000000065','14000000-0000-0000-0000-000000000064', + 'content_qualification','14000000-0000-0000-0000-000000000019','keep_quarantine', + '14000000-0000-0000-0000-000000000001','more-evidence-required', + '{audit://synthetic/g8/content-hold}',repeat('8',64) + ), + ( + '14000000-0000-0000-0000-000000000067','14000000-0000-0000-0000-000000000066', + 'release_gate','14000000-0000-0000-0000-000000000021','reject', + '14000000-0000-0000-0000-000000000001','release-rejected', + '{audit://synthetic/g8/release-reject}',repeat('9',64) + ); +DO $$ +BEGIN + IF (SELECT count(*) FROM audit.ci_human_approval_event + WHERE approval_event_id IN ( + '14000000-0000-0000-0000-000000000065', + '14000000-0000-0000-0000-000000000067' + )) <> 2 + OR EXISTS ( + SELECT 1 FROM app.ci_catalog_entry + WHERE approval_event_id = '14000000-0000-0000-0000-000000000065' + ) + OR EXISTS ( + SELECT 1 FROM audit.ci_lifecycle_event + WHERE approval_event_id IN ( + '14000000-0000-0000-0000-000000000065', + '14000000-0000-0000-0000-000000000067' + ) + ) THEN + RAISE EXCEPTION 'fail-closed human decision created a side effect'; + END IF; +END; +$$; + INSERT INTO app.ci_ingestion_submission ( submission_id, content_hash, operation_kind, result_id ) VALUES ( diff --git a/scripts/test_outcome_os_release_agent.py b/scripts/test_outcome_os_release_agent.py index 55df46b..1effc2d 100644 --- a/scripts/test_outcome_os_release_agent.py +++ b/scripts/test_outcome_os_release_agent.py @@ -388,6 +388,37 @@ class OutcomeReleaseAgentTests(unittest.TestCase): ): self.agent_module.validate_required_release_payload(manifest) + def test_admin_usage_index_migration_is_idempotent_and_release_required(self) -> None: + migration_path = ( + SCRIPT_PATH.parent.parent + / "infra" + / "db" + / "init" + / "18_admin_usage_ledger_index.sql" + ) + migration = migration_path.read_text(encoding="utf-8") + + self.assertNotIn("BEGIN;", migration) + self.assertIn( + "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_turns_admin_usage_created_at", + migration, + ) + self.assertIn("ON app.turns (created_at DESC)", migration) + self.assertIn("WHERE speaker = 'client';", migration) + self.assertNotIn("COMMIT;", migration) + self.assertIn( + "18_admin_usage_ledger_index.sql", + self.agent_module.RELEASE_DB_MIGRATIONS, + ) + self.assertIn( + "18_admin_usage_ledger_index.sql", + self.agent_module.RELEASE_DB_ONLINE_MIGRATIONS, + ) + self.assertIn( + "infra/db/init/18_admin_usage_ledger_index.sql", + self.agent_module.REQUIRED_RELEASE_PAYLOAD_PATHS, + ) + def test_two_patch_runs_must_be_byte_identical_before_any_deploy(self) -> None: agent, runner, deployment, _, _ = self.make_agent( active_sha=None, @@ -740,6 +771,7 @@ class OutcomeReleaseAgentTests(unittest.TestCase): "15_self_directed_practice_runtime.sql", "16_calibration_transfer_actual_execution.sql", "17_improvement_workbook_contracts.sql", + "18_admin_usage_ledger_index.sql", "19_auth_identity_alias.sql", ), self.agent_module.RELEASE_DB_MIGRATIONS, @@ -924,6 +956,20 @@ class OutcomeReleaseAgentTests(unittest.TestCase): self.assertIn("required env file missing", prepare_command) self.assertIn(f"--env-file {root}/release/infra/.env", promote_command) self.assertNotIn(f"--env-file {root}/infra/.env", promote_command) + transactional_migration_command = next( + line + for line in promote_command.splitlines() + if "17_improvement_workbook_contracts.sql" in line + and "psql -v ON_ERROR_STOP=1" in line + ) + online_migration_command = next( + line + for line in promote_command.splitlines() + if "18_admin_usage_ledger_index.sql" in line + and "psql -v ON_ERROR_STOP=1" in line + ) + self.assertIn("--single-transaction", transactional_migration_command) + self.assertNotIn("--single-transaction", online_migration_command) self.assertEqual(f"{root}/release/infra/.env", state["env_file"]) def test_adopted_legacy_state_infers_compose_adjacent_env_file(self) -> None: