From c5339b820eaf2af3fe169bbb3d14d5364d0bea72 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 13:54:03 +0900 Subject: [PATCH 01/37] test(i18n): require owned Similar VOC translation draft lifecycle --- tests/test_similar_voc_translation_seed.py | 351 +++++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 tests/test_similar_voc_translation_seed.py diff --git a/tests/test_similar_voc_translation_seed.py b/tests/test_similar_voc_translation_seed.py new file mode 100644 index 000000000..83bd3765d --- /dev/null +++ b/tests/test_similar_voc_translation_seed.py @@ -0,0 +1,351 @@ +"""Similar VOC eight-locale translation seed and ownership lifecycle contract.""" + +from __future__ import annotations + +import asyncio +import os +import re +import uuid +from collections.abc import Awaitable, Callable +from pathlib import Path +from urllib.parse import urlsplit, urlunsplit + +import asyncpg +import pytest + +ROOT = Path(__file__).resolve().parents[1] +_ADMIN_DSN = os.environ.get( + "LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN", "postgresql://localhost/postgres" +) +_INITIAL_SCHEMA = ROOT / "migrations" / "0001_initial_schema.sql" +_MEMBER_LOCALE_MIGRATION = ROOT / "migrations" / "0044_member_locale_preference.sql" +_LEDGER_MIGRATION = ROOT / "migrations" / "0246_ui_translation_ledger.sql" +_TRUNCATE_GUARD_MIGRATION = ROOT / "migrations" / "0247_ui_translation_truncate_guard.sql" +_EXISTING_SEED_OWNERSHIP = ( + ROOT / "migrations" / "0247_z_customer_master_translation_seed_ownership.sql" +) +_GENERIC_SEED_OWNERSHIP = ( + ROOT / "migrations" / "0249_ui_translation_seed_ownership_generic.sql" +) +_SIMILAR_VOC_SEED = ROOT / "migrations" / "0249_z_similar_voc_translation_draft.sql" +_SIMILAR_VOC_ROLLBACK = ( + ROOT / "migrations" / "rollback" / "0249_z_similar_voc_translation_draft.sql" +) +_LOCALES = ("ko", "en", "ja", "zh", "vi", "es", "de", "fr") +_EXPECTED_KEYS = { + "Similar VOC · customer cohort", + "Review prior evidence and action history adjudicated as the same issue type.", + "The retained evidence remains visible. Retry the failed next page.", + "Retry the failed next page.", + "Retry the same query.", + "Retry loading earlier VOC", + "Retry Similar VOC", + "Similar VOC evidence is being adjudicated.", + "No prior VOC was adjudicated as the same issue type.", + "Event time {time}", + "Current post evidence", + "Prior post evidence", + "Customer cohort", + "No same-customer evidence", + "Prior actions", + "No recorded action", + "Open evidence post", + "Loading earlier VOC...", + "Show earlier VOC", + "Retry needed", + "This request failed. Retry the same action.", + "Could not load more prior VOC. Try again.", + "Similar VOC adjudication is unavailable. Try again later.", +} +_PLACEHOLDER = re.compile(r"\{[^{}]+\}") + + +async def _postgres_available_async() -> bool: + """Return whether the configured PostgreSQL admin endpoint is reachable.""" + try: + connection = await asyncpg.connect(_ADMIN_DSN, timeout=2) + except (asyncpg.PostgresError, OSError, TimeoutError): + return False + await connection.close() + return True + + +def _postgres_available() -> bool: + """Probe PostgreSQL once during collection without adding a sync DB driver.""" + return asyncio.run(_postgres_available_async()) + + +pytestmark = pytest.mark.skipif( + not _postgres_available(), + reason=( + "no reachable PostgreSQL server at " + f"{_ADMIN_DSN} (set LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN)" + ), +) + + +async def _apply_base(connection: asyncpg.Connection) -> None: + """Apply the translation-ledger prerequisites shared by every scenario.""" + for migration in ( + _INITIAL_SCHEMA, + _MEMBER_LOCALE_MIGRATION, + _LEDGER_MIGRATION, + _TRUNCATE_GUARD_MIGRATION, + _EXISTING_SEED_OWNERSHIP, + ): + await connection.execute(migration.read_text(encoding="utf-8")) + + +async def _run_in_database( + scenario: Callable[[asyncpg.Connection], Awaitable[None]], +) -> None: + """Run one seed scenario in an isolated PostgreSQL database.""" + database_name = f"lineageweave_similar_voc_copy_{uuid.uuid4().hex[:12]}" + admin_connection = await asyncpg.connect(_ADMIN_DSN) + await admin_connection.execute(f'create database "{database_name}"') + parsed_admin_dsn = urlsplit(_ADMIN_DSN) + database_dsn = urlunsplit(parsed_admin_dsn._replace(path=f"/{database_name}")) + try: + connection = await asyncpg.connect(database_dsn) + try: + await _apply_base(connection) + await scenario(connection) + finally: + await connection.close() + finally: + await admin_connection.execute(f'drop database "{database_name}"') + await admin_connection.close() + + +def test_similar_voc_seed_is_complete_and_replay_preserves_reviewed_copy() -> None: + """The draft is eight-locale complete and replay never overwrites review edits.""" + + async def scenario(connection: asyncpg.Connection) -> None: + await connection.execute(_GENERIC_SEED_OWNERSHIP.read_text(encoding="utf-8")) + await connection.execute(_SIMILAR_VOC_SEED.read_text(encoding="utf-8")) + + resource = await connection.fetchrow( + """ + select resource_id, publication_state, published_at + from ui_translation_resource + where product_key = 'lineageweave' + and screen_key = 'similar-voc' + and resource_version = 1 + """ + ) + assert resource is not None + resource_id = resource["resource_id"] + assert resource["publication_state"] == "draft" + assert resource["published_at"] is None + + key_rows = await connection.fetch( + "select translation_key from ui_translation_key where resource_id = $1", + resource_id, + ) + assert {row["translation_key"] for row in key_rows} == _EXPECTED_KEYS + + text_rows = await connection.fetch( + """ + select translation_key, locale, translated_text + from ui_translation_text + where resource_id = $1 + """, + resource_id, + ) + assert len(text_rows) == len(_EXPECTED_KEYS) * len(_LOCALES) + by_key: dict[str, dict[str, str]] = {} + for row in text_rows: + by_key.setdefault(row["translation_key"], {})[row["locale"]] = row[ + "translated_text" + ] + + for translation_key in _EXPECTED_KEYS: + translations = by_key[translation_key] + assert set(translations) == set(_LOCALES) + assert translations["en"] == translation_key + expected_placeholders = sorted(_PLACEHOLDER.findall(translation_key)) + for translated_text in translations.values(): + assert translated_text.strip() + assert sorted(_PLACEHOLDER.findall(translated_text)) == expected_placeholders + + reviewed_copy = "검토자가 수정한 유사 VOC 제목" + await connection.execute( + """ + update ui_translation_text + set translated_text = $1 + where resource_id = $2 + and translation_key = 'Similar VOC · customer cohort' + and locale = 'ko' + """, + reviewed_copy, + resource_id, + ) + await connection.execute(_SIMILAR_VOC_SEED.read_text(encoding="utf-8")) + assert ( + await connection.fetchval( + """ + select translated_text + from ui_translation_text + where resource_id = $1 + and translation_key = 'Similar VOC · customer cohort' + and locale = 'ko' + """, + resource_id, + ) + == reviewed_copy + ) + assert ( + await connection.fetchval( + """ + select count(*) + from ui_translation_resource + where product_key = 'lineageweave' + and screen_key = 'similar-voc' + and resource_version = 1 + """ + ) + == 1 + ) + + asyncio.run(_run_in_database(scenario)) + + +def test_similar_voc_seed_refuses_unowned_resource_and_never_resurrects_retired_copy() -> None: + """The one-time seed neither adopts operator data nor resurrects reviewed deletion.""" + + async def collision_scenario(connection: asyncpg.Connection) -> None: + foreign_resource_id = await connection.fetchval( + """ + insert into ui_translation_resource(product_key, screen_key, resource_version) + values ('lineageweave', 'similar-voc', 1) + returning resource_id + """ + ) + await connection.execute(_GENERIC_SEED_OWNERSHIP.read_text(encoding="utf-8")) + with pytest.raises(asyncpg.PostgresError, match="refuses to adopt"): + await connection.execute(_SIMILAR_VOC_SEED.read_text(encoding="utf-8")) + await connection.execute("rollback") + assert ( + await connection.fetchval( + "select resource_id from ui_translation_resource where resource_id = $1", + foreign_resource_id, + ) + == foreign_resource_id + ) + + asyncio.run(_run_in_database(collision_scenario)) + + async def retirement_scenario(connection: asyncpg.Connection) -> None: + await connection.execute(_GENERIC_SEED_OWNERSHIP.read_text(encoding="utf-8")) + await connection.execute(_SIMILAR_VOC_SEED.read_text(encoding="utf-8")) + resource_id = await connection.fetchval( + """ + select resource_id + from ui_translation_resource + where product_key = 'lineageweave' + and screen_key = 'similar-voc' + and resource_version = 1 + """ + ) + await connection.execute( + "delete from ui_translation_resource where resource_id = $1", resource_id + ) + ownership = await connection.fetchrow( + """ + select ownership_state, resource_id + from ui_translation_seed_ownership + where migration_key = '0249_z_similar_voc_translation_draft' + """ + ) + assert ownership["ownership_state"] == "retired" + assert ownership["resource_id"] is None + + await connection.execute(_GENERIC_SEED_OWNERSHIP.read_text(encoding="utf-8")) + await connection.execute(_SIMILAR_VOC_SEED.read_text(encoding="utf-8")) + assert ( + await connection.fetchval( + """ + select count(*) + from ui_translation_resource + where product_key = 'lineageweave' + and screen_key = 'similar-voc' + and resource_version = 1 + """ + ) + == 0 + ) + + asyncio.run(_run_in_database(retirement_scenario)) + + +def test_similar_voc_explicit_rollback_is_owned_draft_only_and_reseedable() -> None: + """Explicit rollback removes only the owned draft and intentionally clears its receipt.""" + + async def scenario(connection: asyncpg.Connection) -> None: + await connection.execute(_GENERIC_SEED_OWNERSHIP.read_text(encoding="utf-8")) + await connection.execute(_SIMILAR_VOC_SEED.read_text(encoding="utf-8")) + await connection.execute(_SIMILAR_VOC_ROLLBACK.read_text(encoding="utf-8")) + assert ( + await connection.fetchval( + """ + select count(*) + from ui_translation_resource + where product_key = 'lineageweave' + and screen_key = 'similar-voc' + and resource_version = 1 + """ + ) + == 0 + ) + assert ( + await connection.fetchval( + """ + select count(*) + from ui_translation_seed_ownership + where migration_key = '0249_z_similar_voc_translation_draft' + """ + ) + == 0 + ) + + await connection.execute(_GENERIC_SEED_OWNERSHIP.read_text(encoding="utf-8")) + await connection.execute(_SIMILAR_VOC_SEED.read_text(encoding="utf-8")) + assert ( + await connection.fetchval( + """ + select count(*) + from ui_translation_resource + where product_key = 'lineageweave' + and screen_key = 'similar-voc' + and resource_version = 1 + """ + ) + == 1 + ) + + await connection.execute( + """ + update ui_translation_resource + set publication_state = 'published' + where product_key = 'lineageweave' + and screen_key = 'similar-voc' + and resource_version = 1 + """ + ) + with pytest.raises(asyncpg.PostgresError, match="refuses to remove published"): + await connection.execute(_SIMILAR_VOC_ROLLBACK.read_text(encoding="utf-8")) + await connection.execute("rollback") + assert ( + await connection.fetchval( + """ + select publication_state + from ui_translation_resource + where product_key = 'lineageweave' + and screen_key = 'similar-voc' + and resource_version = 1 + """ + ) + == "published" + ) + + asyncio.run(_run_in_database(scenario)) From 5448d8d24caa07110152f8fa1e5e5c52044ae60f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 13:54:51 +0900 Subject: [PATCH 02/37] fix(i18n): generalize owned one-time translation seed lifecycle --- ..._ui_translation_seed_ownership_generic.sql | 315 ++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 migrations/0249_ui_translation_seed_ownership_generic.sql diff --git a/migrations/0249_ui_translation_seed_ownership_generic.sql b/migrations/0249_ui_translation_seed_ownership_generic.sql new file mode 100644 index 000000000..ea4610e17 --- /dev/null +++ b/migrations/0249_ui_translation_seed_ownership_generic.sql @@ -0,0 +1,315 @@ +-- Generalize one-time UI translation candidate ownership beyond Customer Master. +-- ADR 0362 keeps reviewed draft copy product-owned and prevents historical seed +-- bytes from becoming authoritative again after review or deliberate deletion. +begin; + +create or replace function guard_ui_translation_seed_resource_ownership() +returns trigger +language plpgsql +set search_path = pg_catalog, public, pg_temp +as $$ +declare + owner_migration_key text; + owner_state text; + owner_resource_id bigint; + migration_file text; +begin + migration_file := current_setting('lineageweave.migration_file', true); + + if tg_op = 'INSERT' then + select migration_key, ownership_state, resource_id + into owner_migration_key, owner_state, owner_resource_id + from public.ui_translation_seed_ownership + where product_key = new.product_key + and screen_key = new.screen_key + and resource_version = new.resource_version + for update; + + if owner_migration_key is null then + return new; + end if; + + if migration_file = owner_migration_key || '.sql' then + if owner_state = 'blocked' then + raise exception + 'UI translation seed % refuses to adopt an existing unowned resource', + owner_migration_key; + end if; + if owner_state not in ('pending', 'owned') then + raise exception + 'UI translation seed % has no active ownership reservation', + owner_migration_key; + end if; + return new; + end if; + + -- A pending row reserves this exact product/screen/version identity for + -- its one-time seed. Once owned, blocked, or retired, ordinary product + -- lifecycle remains outside migration provenance. + if owner_state = 'pending' then + raise exception + 'UI translation seed % reserves this resource identity until its migration runs', + owner_migration_key; + end if; + return new; + end if; + + select migration_key, ownership_state, resource_id + into owner_migration_key, owner_state, owner_resource_id + from public.ui_translation_seed_ownership + where resource_id = old.resource_id + for update; + + if owner_migration_key is null then + return old; + end if; + + if migration_file = 'rollback/' || owner_migration_key || '.sql' then + if owner_state <> 'owned' or owner_resource_id <> old.resource_id then + raise exception + 'UI translation rollback % refuses resource % outside exact seed ownership', + owner_migration_key, + old.resource_id; + end if; + return old; + end if; + + if owner_state = 'owned' and owner_resource_id = old.resource_id then + update public.ui_translation_seed_ownership + set ownership_state = 'retired', + resource_id = null + where migration_key = owner_migration_key + and ownership_state = 'owned' + and resource_id = old.resource_id; + end if; + return old; +end; +$$; + +create or replace function bind_ui_translation_seed_resource_ownership() +returns trigger +language plpgsql +set search_path = pg_catalog, public, pg_temp +as $$ +declare + owner_migration_key text; + owner_state text; + owner_resource_id bigint; + migration_file text; +begin + migration_file := current_setting('lineageweave.migration_file', true); + + select migration_key, ownership_state, resource_id + into owner_migration_key, owner_state, owner_resource_id + from public.ui_translation_seed_ownership + where product_key = new.product_key + and screen_key = new.screen_key + and resource_version = new.resource_version + for update; + + if owner_migration_key is null + or migration_file is distinct from owner_migration_key || '.sql' then + return new; + end if; + + if owner_state = 'owned' and owner_resource_id = new.resource_id then + return new; + end if; + + update public.ui_translation_seed_ownership + set ownership_state = 'owned', + resource_id = new.resource_id + where migration_key = owner_migration_key + and ownership_state = 'pending' + and resource_id is null; + + if not found then + raise exception + 'UI translation seed % could not bind ownership to resource %', + owner_migration_key, + new.resource_id; + end if; + return new; +end; +$$; + +create or replace function guard_ui_translation_seed_child_ownership() +returns trigger +language plpgsql +set search_path = pg_catalog, public, pg_temp +as $$ +declare + target_resource_id bigint; + owner_migration_key text; + owner_state text; + owner_resource_id bigint; + migration_file text; +begin + if tg_op = 'DELETE' then + target_resource_id := old.resource_id; + else + target_resource_id := new.resource_id; + end if; + + select ownership.migration_key, ownership.ownership_state, ownership.resource_id + into owner_migration_key, owner_state, owner_resource_id + from public.ui_translation_resource as resource + join public.ui_translation_seed_ownership as ownership + on ownership.product_key = resource.product_key + and ownership.screen_key = resource.screen_key + and ownership.resource_version = resource.resource_version + where resource.resource_id = target_resource_id + for update of ownership; + + if owner_migration_key is null then + if tg_op = 'DELETE' then + return old; + end if; + return new; + end if; + + migration_file := current_setting('lineageweave.migration_file', true); + if migration_file = owner_migration_key || '.sql' + and (owner_state <> 'owned' or owner_resource_id <> target_resource_id) then + raise exception + 'UI translation seed % refuses child mutation outside exact seed ownership', + owner_migration_key; + end if; + + if tg_op = 'DELETE' then + return old; + end if; + return new; +end; +$$; + +-- Replace the Customer-Master-specific trigger wiring with the same generic +-- lifecycle boundary. The legacy functions remain harmlessly defined so old +-- rollback evidence stays inspectable, but they are no longer active writers. +drop trigger if exists customer_master_seed_resource_ownership_guard + on public.ui_translation_resource; +drop trigger if exists customer_master_seed_resource_ownership_bind + on public.ui_translation_resource; +drop trigger if exists customer_master_seed_key_ownership_guard + on public.ui_translation_key; +drop trigger if exists customer_master_seed_text_ownership_guard + on public.ui_translation_text; + +drop trigger if exists ui_translation_seed_resource_ownership_guard + on public.ui_translation_resource; +create trigger ui_translation_seed_resource_ownership_guard +before insert or delete on public.ui_translation_resource +for each row execute function guard_ui_translation_seed_resource_ownership(); + +drop trigger if exists ui_translation_seed_resource_ownership_bind + on public.ui_translation_resource; +create trigger ui_translation_seed_resource_ownership_bind +after insert on public.ui_translation_resource +for each row execute function bind_ui_translation_seed_resource_ownership(); + +drop trigger if exists ui_translation_seed_key_ownership_guard + on public.ui_translation_key; +create trigger ui_translation_seed_key_ownership_guard +before insert or update or delete on public.ui_translation_key +for each row execute function guard_ui_translation_seed_child_ownership(); + +drop trigger if exists ui_translation_seed_text_ownership_guard + on public.ui_translation_text; +create trigger ui_translation_seed_text_ownership_guard +before insert or update or delete on public.ui_translation_text +for each row execute function guard_ui_translation_seed_child_ownership(); + +-- Reserve Similar VOC v1 before the seed can touch reviewable copy. A resource +-- that predates this reservation is operator-owned and remains fail-closed. +do $similar_voc_seed_ownership_init$ +declare + owner_state text; + owner_resource_id bigint; + owner_product_key text; + owner_screen_key text; + owner_resource_version bigint; + target_resource_id bigint; +begin + insert into public.ui_translation_seed_ownership( + migration_key, + product_key, + screen_key, + resource_version, + ownership_state + ) + select + '0249_z_similar_voc_translation_draft', + 'lineageweave', + 'similar-voc', + 1, + case + when exists ( + select 1 + from public.ui_translation_resource + where product_key = 'lineageweave' + and screen_key = 'similar-voc' + and resource_version = 1 + ) then 'blocked' + else 'pending' + end + on conflict (migration_key) do nothing; + + select ownership_state, resource_id, product_key, screen_key, resource_version + into owner_state, owner_resource_id, owner_product_key, owner_screen_key, + owner_resource_version + from public.ui_translation_seed_ownership + where migration_key = '0249_z_similar_voc_translation_draft' + for update; + + if owner_state is null then + raise exception 'Similar VOC translation seed ownership reservation is missing'; + end if; + + if owner_product_key <> 'lineageweave' + or owner_screen_key <> 'similar-voc' + or owner_resource_version <> 1 then + raise exception 'Similar VOC translation seed ownership identity drifted'; + end if; + + select resource_id + into target_resource_id + from public.ui_translation_resource + where product_key = 'lineageweave' + and screen_key = 'similar-voc' + and resource_version = 1 + for update; + + if owner_state = 'retired' then + if owner_resource_id is not null then + raise exception 'Retired Similar VOC seed unexpectedly retains resource %', + owner_resource_id; + end if; + return; + end if; + + if owner_state = 'owned' then + if target_resource_id is distinct from owner_resource_id then + raise exception + 'Similar VOC seed ownership points to resource %, current resource is %', + owner_resource_id, + target_resource_id; + end if; + return; + end if; + + if owner_state = 'blocked' and target_resource_id is null then + update public.ui_translation_seed_ownership + set ownership_state = 'pending' + where migration_key = '0249_z_similar_voc_translation_draft'; + return; + end if; + + if owner_state = 'pending' and target_resource_id is not null then + raise exception + 'Similar VOC seed pending ownership collided with existing resource %', + target_resource_id; + end if; +end; +$similar_voc_seed_ownership_init$; + +commit; From d5f574f6e9eb04afab86c9bb9aee8acf93fb4375 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 13:55:45 +0900 Subject: [PATCH 03/37] feat(i18n): seed owned Similar VOC eight-locale draft --- .../0249_z_similar_voc_translation_draft.sql | 178 ++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 migrations/0249_z_similar_voc_translation_draft.sql diff --git a/migrations/0249_z_similar_voc_translation_draft.sql b/migrations/0249_z_similar_voc_translation_draft.sql new file mode 100644 index 000000000..350e43388 --- /dev/null +++ b/migrations/0249_z_similar_voc_translation_draft.sql @@ -0,0 +1,178 @@ +-- Similar VOC v1 buyer-copy candidate for ADR 0362. +-- This is reviewable draft data only; publication remains a separate one-way +-- action after language/product review and unchanged-head consumer acceptance. +begin; + +select set_config( + 'lineageweave.migration_file', + '0249_z_similar_voc_translation_draft.sql', + true +); + +create temporary table similar_voc_translation_seed ( + translation_key text primary key, + ko text not null, + en text not null, + ja text not null, + zh text not null, + vi text not null, + es text not null, + de text not null, + fr text not null +) on commit drop; + +insert into similar_voc_translation_seed( + translation_key, ko, en, ja, zh, vi, es, de, fr +) +values + ('Similar VOC · customer cohort', '유사 VOC · 고객군 확인', 'Similar VOC · customer cohort', '類似VOC・顧客群', '相似VOC · 客户群', 'VOC tương tự · nhóm khách hàng', 'VOC similares · cohorte de clientes', 'Ähnliche VOC · Kundengruppe', 'VOC similaires · cohorte client'), + ('Review prior evidence and action history adjudicated as the same issue type.', '같은 문제 유형으로 판정된 과거 근거와 조치 이력을 확인하세요.', 'Review prior evidence and action history adjudicated as the same issue type.', '同じ問題タイプと判定された過去の根拠と対応履歴を確認します。', '查看被判定为同一问题类型的历史证据和处置记录。', 'Xem bằng chứng trước đây và lịch sử xử lý được xác định là cùng loại vấn đề.', 'Revise la evidencia anterior y el historial de acciones clasificados como el mismo tipo de problema.', 'Prüfen Sie frühere Nachweise und Maßnahmen, die demselben Problemtyp zugeordnet wurden.', 'Consultez les éléments de preuve antérieurs et l’historique des actions classés dans le même type de problème.'), + ('The retained evidence remains visible. Retry the failed next page.', '불러온 근거는 그대로 유지됩니다. 실패한 다음 페이지를 다시 요청하세요.', 'The retained evidence remains visible. Retry the failed next page.', '取得済みの根拠はそのまま表示されます。失敗した次のページを再取得してください。', '已加载的证据会继续显示。请重新请求失败的下一页。', 'Bằng chứng đã tải vẫn được giữ nguyên. Hãy yêu cầu lại trang tiếp theo bị lỗi.', 'La evidencia ya cargada seguirá visible. Vuelva a solicitar la página siguiente que falló.', 'Bereits geladene Nachweise bleiben sichtbar. Fordern Sie die fehlgeschlagene nächste Seite erneut an.', 'Les éléments de preuve déjà chargés restent visibles. Redemandez la page suivante qui a échoué.'), + ('Retry the failed next page.', '실패한 다음 페이지를 다시 요청하세요.', 'Retry the failed next page.', '失敗した次のページを再取得してください。', '请重新请求失败的下一页。', 'Hãy yêu cầu lại trang tiếp theo bị lỗi.', 'Vuelva a solicitar la página siguiente que falló.', 'Fordern Sie die fehlgeschlagene nächste Seite erneut an.', 'Redemandez la page suivante qui a échoué.'), + ('Retry the same query.', '같은 조회를 다시 시도하세요.', 'Retry the same query.', '同じ照会をもう一度実行してください。', '请重试同一查询。', 'Hãy thử lại cùng truy vấn.', 'Vuelva a intentar la misma consulta.', 'Führen Sie dieselbe Abfrage erneut aus.', 'Relancez la même requête.'), + ('Retry loading earlier VOC', '이전 VOC 더 불러오기 다시 시도', 'Retry loading earlier VOC', '以前のVOCの追加読み込みを再試行', '重试加载更早的VOC', 'Thử tải lại các VOC trước đó', 'Reintentar la carga de VOC anteriores', 'Frühere VOC erneut laden', 'Réessayer le chargement des VOC antérieures'), + ('Retry Similar VOC', '유사 VOC 다시 조회', 'Retry Similar VOC', '類似VOCを再照会', '重试相似VOC查询', 'Thử lại truy vấn VOC tương tự', 'Reintentar la consulta de VOC similares', 'Ähnliche VOC erneut abfragen', 'Relancer la recherche de VOC similaires'), + ('Similar VOC evidence is being adjudicated.', '유사 VOC 근거를 판정하고 있습니다.', 'Similar VOC evidence is being adjudicated.', '類似VOCの根拠を判定しています。', '正在判定相似VOC证据。', 'Đang thẩm định bằng chứng VOC tương tự.', 'Se está evaluando la evidencia de VOC similares.', 'Nachweise zu ähnlichen VOC werden bewertet.', 'Les éléments de preuve des VOC similaires sont en cours d’évaluation.'), + ('No prior VOC was adjudicated as the same issue type.', '같은 문제 유형으로 판정된 과거 VOC가 없습니다.', 'No prior VOC was adjudicated as the same issue type.', '同じ問題タイプと判定された過去のVOCはありません。', '没有被判定为同一问题类型的历史VOC。', 'Không có VOC trước đây nào được xác định là cùng loại vấn đề.', 'No hay VOC anteriores clasificadas como el mismo tipo de problema.', 'Es wurden keine früheren VOC demselben Problemtyp zugeordnet.', 'Aucune VOC antérieure n’a été classée dans le même type de problème.'), + ('Event time {time}', '사건 시각 {time}', 'Event time {time}', '事象時刻 {time}', '事件时间 {time}', 'Thời điểm sự kiện {time}', 'Hora del evento {time}', 'Ereigniszeit {time}', 'Heure de l’événement {time}'), + ('Current post evidence', '현재 글 근거', 'Current post evidence', '現在の投稿の根拠', '当前帖子的证据', 'Bằng chứng của bài đăng hiện tại', 'Evidencia de la publicación actual', 'Nachweis aus dem aktuellen Beitrag', 'Élément de preuve de la publication actuelle'), + ('Prior post evidence', '과거 글 근거', 'Prior post evidence', '過去の投稿の根拠', '历史帖子的证据', 'Bằng chứng của bài đăng trước', 'Evidencia de la publicación anterior', 'Nachweis aus dem früheren Beitrag', 'Élément de preuve de la publication antérieure'), + ('Customer cohort', '고객군', 'Customer cohort', '顧客群', '客户群', 'Nhóm khách hàng', 'Cohorte de clientes', 'Kundengruppe', 'Cohorte client'), + ('No same-customer evidence', '동일 고객 근거 없음', 'No same-customer evidence', '同一顧客を示す根拠なし', '无同一客户证据', 'Không có bằng chứng cùng khách hàng', 'No hay evidencia del mismo cliente', 'Kein Nachweis für denselben Kunden', 'Aucun élément de preuve du même client'), + ('Prior actions', '과거 조치', 'Prior actions', '過去の対応', '历史处置', 'Hành động trước đây', 'Acciones anteriores', 'Frühere Maßnahmen', 'Actions antérieures'), + ('No recorded action', '기록된 조치 없음', 'No recorded action', '記録された対応なし', '无已记录处置', 'Không có hành động được ghi nhận', 'No hay acciones registradas', 'Keine Maßnahme erfasst', 'Aucune action enregistrée'), + ('Open evidence post', '근거 글 열기', 'Open evidence post', '根拠となる投稿を開く', '打开证据帖子', 'Mở bài đăng làm bằng chứng', 'Abrir publicación de evidencia', 'Nachweisbeitrag öffnen', 'Ouvrir la publication de preuve'), + ('Loading earlier VOC...', '이전 VOC를 불러오는 중...', 'Loading earlier VOC...', '以前のVOCを読み込んでいます...', '正在加载更早的VOC...', 'Đang tải các VOC trước đó...', 'Cargando VOC anteriores...', 'Frühere VOC werden geladen...', 'Chargement des VOC antérieures...'), + ('Show earlier VOC', '이전 VOC 더 보기', 'Show earlier VOC', '以前のVOCをさらに表示', '显示更早的VOC', 'Hiển thị thêm VOC trước đó', 'Mostrar VOC anteriores', 'Weitere frühere VOC anzeigen', 'Afficher davantage de VOC antérieures'), + ('Retry needed', '다시 시도 필요', 'Retry needed', '再試行が必要です', '需要重试', 'Cần thử lại', 'Es necesario reintentar', 'Erneuter Versuch erforderlich', 'Nouvel essai requis'), + ('This request failed. Retry the same action.', '요청이 실패했습니다. 같은 작업을 다시 시도하세요.', 'This request failed. Retry the same action.', 'リクエストに失敗しました。同じ操作をもう一度お試しください。', '请求失败。请重试同一操作。', 'Yêu cầu không thành công. Hãy thử lại cùng thao tác.', 'La solicitud falló. Vuelva a intentar la misma acción.', 'Diese Anfrage ist fehlgeschlagen. Wiederholen Sie dieselbe Aktion.', 'La requête a échoué. Réessayez la même action.'), + ('Could not load more prior VOC. Try again.', '이전 VOC를 더 불러오지 못했습니다. 다시 시도하세요.', 'Could not load more prior VOC. Try again.', '以前のVOCを追加で読み込めませんでした。もう一度お試しください。', '无法加载更多历史VOC。请重试。', 'Không thể tải thêm VOC trước đó. Hãy thử lại.', 'No se pudieron cargar más VOC anteriores. Inténtelo de nuevo.', 'Weitere frühere VOC konnten nicht geladen werden. Versuchen Sie es erneut.', 'Impossible de charger davantage de VOC antérieures. Réessayez.'), + ('Similar VOC adjudication is unavailable. Try again later.', '유사 VOC 판정을 사용할 수 없습니다. 잠시 후 다시 확인하세요.', 'Similar VOC adjudication is unavailable. Try again later.', '類似VOCの判定を利用できません。しばらくしてからもう一度お試しください。', '相似VOC判定暂不可用。请稍后重试。', 'Tạm thời không thể thẩm định VOC tương tự. Hãy thử lại sau.', 'La evaluación de VOC similares no está disponible. Inténtelo de nuevo más tarde.', 'Die Bewertung ähnlicher VOC ist nicht verfügbar. Versuchen Sie es später erneut.', 'L’évaluation des VOC similaires est indisponible. Réessayez plus tard.'); + +do $similar_voc_seed$ +declare + owner_state text; + owner_resource_id bigint; + target_resource_id bigint; + expected_key_count integer; +begin + select ownership_state, resource_id + into owner_state, owner_resource_id + from public.ui_translation_seed_ownership + where migration_key = '0249_z_similar_voc_translation_draft' + and product_key = 'lineageweave' + and screen_key = 'similar-voc' + and resource_version = 1 + for update; + + if owner_state is null then + raise exception 'Similar VOC translation seed ownership prerequisite is missing'; + end if; + + if owner_state = 'retired' then + if owner_resource_id is not null then + raise exception 'Retired Similar VOC seed unexpectedly retains resource %', + owner_resource_id; + end if; + return; + end if; + + if owner_state = 'blocked' then + raise exception 'Similar VOC translation seed refuses to adopt an existing unowned resource'; + end if; + + select resource_id + into target_resource_id + from public.ui_translation_resource + where product_key = 'lineageweave' + and screen_key = 'similar-voc' + and resource_version = 1 + for update; + + if owner_state = 'owned' then + if target_resource_id is distinct from owner_resource_id then + raise exception + 'Similar VOC seed ownership points to resource %, current resource is %', + owner_resource_id, + target_resource_id; + end if; + -- Review data is mutable while draft. Historical seed bytes no longer + -- have authority after first materialization, so replay is a no-op. + return; + end if; + + if owner_state <> 'pending' then + raise exception 'Similar VOC translation seed has unexpected ownership state %', + owner_state; + end if; + + if target_resource_id is not null then + raise exception + 'Similar VOC translation seed refuses to adopt existing resource %', + target_resource_id; + end if; + + select count(*) into expected_key_count + from similar_voc_translation_seed; + if expected_key_count <> 23 then + raise exception 'Similar VOC translation seed expected 23 keys, found %', + expected_key_count; + end if; + + insert into public.ui_translation_resource(product_key, screen_key, resource_version) + values ('lineageweave', 'similar-voc', 1) + returning resource_id into target_resource_id; + + if not exists ( + select 1 + from public.ui_translation_seed_ownership + where migration_key = '0249_z_similar_voc_translation_draft' + and ownership_state = 'owned' + and resource_id = target_resource_id + ) then + raise exception + 'Similar VOC translation seed did not bind ownership to resource %', + target_resource_id; + end if; + + insert into public.ui_translation_key(resource_id, translation_key) + select target_resource_id, translation_key + from similar_voc_translation_seed; + + insert into public.ui_translation_text( + resource_id, translation_key, locale, translated_text + ) + select + target_resource_id, + seed.translation_key, + localized.locale, + localized.translated_text + from similar_voc_translation_seed as seed + cross join lateral ( + values + ('ko', seed.ko), + ('en', seed.en), + ('ja', seed.ja), + ('zh', seed.zh), + ('vi', seed.vi), + ('es', seed.es), + ('de', seed.de), + ('fr', seed.fr) + ) as localized(locale, translated_text); + + if ( + select count(*) + from public.ui_translation_key + where resource_id = target_resource_id + ) <> expected_key_count + or ( + select count(*) + from public.ui_translation_text + where resource_id = target_resource_id + ) <> expected_key_count * 8 then + raise exception 'Similar VOC translation draft failed eight-locale completeness'; + end if; +end; +$similar_voc_seed$; + +commit; From eaaf7397fcb6db32afea599b6c17fc981bc6c9bd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 13:55:56 +0900 Subject: [PATCH 04/37] fix(i18n): bound Similar VOC draft rollback to exact seed ownership --- .../0249_z_similar_voc_translation_draft.sql | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 migrations/rollback/0249_z_similar_voc_translation_draft.sql diff --git a/migrations/rollback/0249_z_similar_voc_translation_draft.sql b/migrations/rollback/0249_z_similar_voc_translation_draft.sql new file mode 100644 index 000000000..942ebedd7 --- /dev/null +++ b/migrations/rollback/0249_z_similar_voc_translation_draft.sql @@ -0,0 +1,76 @@ +-- Roll back only the unpublished Similar VOC v1 resource owned by its seed. +begin; + +select set_config( + 'lineageweave.migration_file', + 'rollback/0249_z_similar_voc_translation_draft.sql', + true +); + +do $similar_voc_seed_rollback$ +declare + owner_state text; + owner_resource_id bigint; + target_state text; + target_product_key text; + target_screen_key text; + target_resource_version bigint; +begin + select ownership_state, resource_id + into owner_state, owner_resource_id + from public.ui_translation_seed_ownership + where migration_key = '0249_z_similar_voc_translation_draft' + and product_key = 'lineageweave' + and screen_key = 'similar-voc' + and resource_version = 1 + for update; + + if owner_state is null or owner_state in ('blocked', 'retired') then + return; + end if; + + if owner_state = 'pending' then + delete from public.ui_translation_seed_ownership + where migration_key = '0249_z_similar_voc_translation_draft' + and ownership_state = 'pending' + and resource_id is null; + return; + end if; + + if owner_state <> 'owned' or owner_resource_id is null then + raise exception 'Similar VOC translation rollback has invalid ownership state %', + owner_state; + end if; + + select publication_state, product_key, screen_key, resource_version + into target_state, target_product_key, target_screen_key, target_resource_version + from public.ui_translation_resource + where resource_id = owner_resource_id + for update; + + if target_state is null then + raise exception + 'Similar VOC translation rollback ownership points to missing resource %', + owner_resource_id; + end if; + + if target_product_key <> 'lineageweave' + or target_screen_key <> 'similar-voc' + or target_resource_version <> 1 then + raise exception + 'Similar VOC translation rollback refuses resource % with drifted identity', + owner_resource_id; + end if; + + if target_state <> 'draft' then + raise exception + 'Similar VOC translation rollback refuses to remove published resource %', + owner_resource_id; + end if; + + delete from public.ui_translation_resource + where resource_id = owner_resource_id; +end; +$similar_voc_seed_rollback$; + +commit; From c0e6a5e7f74a8310b9d9a9d61b305ea49951bd6b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 13:57:27 +0900 Subject: [PATCH 05/37] docs(adr): govern Similar VOC screen-copy draft ownership --- .../0377-similar-voc-versioned-screen-copy.md | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 docs/adr/0377-similar-voc-versioned-screen-copy.md diff --git a/docs/adr/0377-similar-voc-versioned-screen-copy.md b/docs/adr/0377-similar-voc-versioned-screen-copy.md new file mode 100644 index 000000000..215c41fe7 --- /dev/null +++ b/docs/adr/0377-similar-voc-versioned-screen-copy.md @@ -0,0 +1,84 @@ +# ADR 0377: Govern Similar VOC screen copy as an owned versioned draft + +- Status: Proposed +- Date: 2026-09-24 +- Owners: LineageWeave product composition / presentation read model +- Extends: ADR 0362 +- Related: #929, #1126, migration 0249 + +## Problem + +The Similar VOC repair lane adds buyer-visible retry, loading, empty, evidence-label, and recovery copy. Keeping those strings in `SimilarVocPanel` would preserve a second static frontend translation authority and would leave the required `ko/en/ja/zh/vi/es/de/fr` contract unverifiable. ADR 0362 already makes PostgreSQL authoritative for LineageWeave-owned UI copy, but its first one-time seed lifecycle was implemented specifically for Customer Master v1. A second review candidate must not copy that lifecycle ad hoc, overwrite reviewed draft text on startup replay, adopt a pre-existing operator-owned resource, or resurrect historical seed bytes after deliberate product deletion. + +This ADR does not move ontology labels, VOC semantic truth, similarity adjudication, or authorization scope into the translation ledger. It governs product presentation copy only. + +## Constraints + +- The screen aggregate identity is `lineageweave/similar-voc/v1`. +- Candidate copy covers the full Similar VOC presentation surface needed by the current panel, including retry and recovery text introduced by #1126. +- Every required key has exactly the eight ADR-0362 locales: `ko`, `en`, `ja`, `zh`, `vi`, `es`, `de`, `fr`. +- The migration produces a `draft`. It must not publish or claim language/product approval. +- Seed replay after first materialization is a no-op. Review edits are product data and historical migration bytes must not overwrite them. +- A resource that existed before the seed reservation is operator-owned and cannot be adopted implicitly. +- Ordinary deletion of an exactly owned candidate retires the one-time seed receipt so startup cannot resurrect it. +- Explicit rollback may delete only the exact owned unpublished draft. Published versions remain immutable under ADR 0362. +- Seed ownership must be reusable for future LineageWeave screen-copy candidates rather than adding another Customer-Master-specific trigger family. +- Similar VOC consumer cutover remains in its existing source-owner lane; this owner PR must not become a second writer for `SimilarVocPanel`. + +## Alternatives considered + +### Keep the new Korean strings in the component and translate only later + +Rejected. The component would remain a source of product copy, exact eight-locale evidence could not be bound to a screen version, and the known #1126 delivery-gate failure would simply be deferred. + +### Add a static eight-locale dictionary beside `SimilarVocPanel` + +Rejected. That would duplicate ADR 0362's PostgreSQL authority and make deployment artifacts authoritative again. + +### Reuse the Customer Master resource + +Rejected. `customer-master` and `similar-voc` are separate screen aggregates with different required keys, review lifecycles, and buyer evidence. Sharing one resource would couple unrelated releases and violate aggregate boundaries. + +### Copy the Customer-Master-specific ownership triggers for Similar VOC + +Rejected. A second screen-specific trigger family would encode the same lifecycle twice and make later seed safety fixes diverge. The ownership table is already generic; trigger enforcement is generalized instead. + +### Re-run the seed on every startup and upsert its values + +Rejected. Once a draft exists, reviewers own its copy. An upsert replay would silently restore historical migration text over reviewed product data. After first exact-owned materialization, replay must not mutate keys or text. + +## Decision + +Migration `0249_ui_translation_seed_ownership_generic.sql` replaces the active Customer-Master-specific ownership trigger wiring with generic trigger functions over `ui_translation_seed_ownership`. The lifecycle is selected by the ownership row's `(migration_key, product_key, screen_key, resource_version)` identity and the trusted `lineageweave.migration_file` execution context. + +A `pending` ownership row reserves an otherwise-empty screen identity for its seed. The forward seed may create that root and an `AFTER INSERT` binder records the exact `resource_id` as `owned`. Child writes performed by the seed are accepted only after that binding. A pre-existing resource causes `blocked` state and the forward seed fails closed without touching it. + +Ordinary deletion of an exact `owned` candidate first changes the receipt to `retired` and clears `resource_id`; the root can then be deleted without `ON DELETE CASCADE` erasing the one-time completion evidence. Forward replay in `owned` or `retired` state does not restore seed text. Explicit rollback is distinguished by `rollback/.sql`; it may delete the exact owned draft without retiring it, allowing the ownership row to cascade away so a later intentional forward migration can seed a fresh review candidate. + +Migration `0249_z_similar_voc_translation_draft.sql` reserves no semantic authority. It creates 23 presentation keys × 8 locales only when ownership is `pending`, verifies the exact 184-row matrix, and then stops. Publication remains a separate one-way ADR-0362 transition after independent language/product review and unchanged-head consumer acceptance. + +## DDD mapping + +- Subdomain: product composition / presentation read model. +- Bounded context: LineageWeave product read model. +- Aggregate: `lineageweave/similar-voc/v1` translation resource. +- Value identities: translation key and locale-tagged presentation copy. +- Repository: PostgreSQL translation ledger; Valkey remains an optional read cache under ADR 0362. +- Domain service: one-time candidate-seed ownership lifecycle. +- Invariants: no implicit adoption, no reviewed-copy replay overwrite, no post-retirement resurrection, rollback limited to exact owned draft, eight-locale completeness before any publication, ontology labels excluded. +- ACL: #1126 consumes only a released/published screen-copy contract; similarity adjudication and authorization continue to come from their existing owners. + +## Recovery and rollout + +If the Similar VOC draft is wrong before publication, reviewers may edit it in place or explicitly run `rollback/0249_z_similar_voc_translation_draft.sql` and re-run the forward candidate. Ordinary product deletion means retirement, not authorization for historical reseeding. If a conflicting pre-existing `lineageweave/similar-voc/v1` resource exists, the migration stays blocked until an operator resolves that product decision; it never deletes or adopts the conflicting resource. + +A published bad version is not down-migrated. ADR 0362 requires a new immutable resource version and consumer routing to that reviewed version. + +## Evidence + +- RED `c5339b820eaf2af3fe169bbb3d14d5364d0bea72` requires a complete eight-locale Similar VOC candidate, replay preservation of reviewed copy, unowned-resource refusal, retirement without resurrection, and exact-owned-draft rollback/reseed behavior on real PostgreSQL. +- Generic ownership repair `5448d8d24caa07110152f8fa1e5e5c52044ae60f` moves active seed lifecycle enforcement from Customer-Master-specific trigger wiring to the ownership aggregate and reserves `lineageweave/similar-voc/v1`. +- Candidate seed `d5f574f6e9eb04afab86c9bb9aee8acf93fb4375` adds the 23×8 review matrix and makes `owned|retired` replay purpose-complete without rewriting reviewed data. +- Rollback repair `eaaf7397fcb6db32afea599b6c17fc981bc6c9bd` limits destructive rollback to the exact owned unpublished Similar VOC resource. + +These commits are source-level evidence only until exact-head PostgreSQL and hosted validation run. No publication, translation approval, consumer acceptance, merge, or release is implied by this ADR. From f0e79fd233e01fc512d146b9e544aa9a2780efc6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 13:59:05 +0900 Subject: [PATCH 06/37] test(i18n): keep blocked seed resources outside rollback authority --- ...translation_seed_generic_rollback_guard.py | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 tests/test_translation_seed_generic_rollback_guard.py diff --git a/tests/test_translation_seed_generic_rollback_guard.py b/tests/test_translation_seed_generic_rollback_guard.py new file mode 100644 index 000000000..74e1d37a5 --- /dev/null +++ b/tests/test_translation_seed_generic_rollback_guard.py @@ -0,0 +1,121 @@ +"""Rollback authority regression for generic UI translation seed ownership.""" + +from __future__ import annotations + +import asyncio +import os +import uuid +from pathlib import Path +from urllib.parse import urlsplit, urlunsplit + +import asyncpg +import pytest + +ROOT = Path(__file__).resolve().parents[1] +_ADMIN_DSN = os.environ.get( + "LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN", "postgresql://localhost/postgres" +) +_INITIAL_SCHEMA = ROOT / "migrations" / "0001_initial_schema.sql" +_MEMBER_LOCALE_MIGRATION = ROOT / "migrations" / "0044_member_locale_preference.sql" +_LEDGER_MIGRATION = ROOT / "migrations" / "0246_ui_translation_ledger.sql" +_TRUNCATE_GUARD_MIGRATION = ROOT / "migrations" / "0247_ui_translation_truncate_guard.sql" +_EXISTING_SEED_OWNERSHIP = ( + ROOT / "migrations" / "0247_z_customer_master_translation_seed_ownership.sql" +) +_GENERIC_SEED_OWNERSHIP = ( + ROOT / "migrations" / "0249_ui_translation_seed_ownership_generic.sql" +) +_ROLLBACK_FILE = "rollback/0249_z_similar_voc_translation_draft.sql" + + +async def _postgres_available_async() -> bool: + try: + connection = await asyncpg.connect(_ADMIN_DSN, timeout=2) + except (asyncpg.PostgresError, OSError, TimeoutError): + return False + await connection.close() + return True + + +def _postgres_available() -> bool: + return asyncio.run(_postgres_available_async()) + + +@pytest.mark.skipif( + not _postgres_available(), + reason=( + "no reachable PostgreSQL server at " + f"{_ADMIN_DSN} (set LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN)" + ), +) +def test_blocked_operator_resource_cannot_be_deleted_under_seed_rollback_context() -> None: + """Rollback provenance must not turn a blocked operator resource into seed-owned data.""" + + async def scenario() -> None: + database_name = f"lineageweave_seed_rollback_{uuid.uuid4().hex[:12]}" + admin_connection = await asyncpg.connect(_ADMIN_DSN) + await admin_connection.execute(f'create database "{database_name}"') + parsed_admin_dsn = urlsplit(_ADMIN_DSN) + database_dsn = urlunsplit(parsed_admin_dsn._replace(path=f"/{database_name}")) + try: + connection = await asyncpg.connect(database_dsn) + try: + for migration in ( + _INITIAL_SCHEMA, + _MEMBER_LOCALE_MIGRATION, + _LEDGER_MIGRATION, + _TRUNCATE_GUARD_MIGRATION, + _EXISTING_SEED_OWNERSHIP, + ): + await connection.execute(migration.read_text(encoding="utf-8")) + + resource_id = await connection.fetchval( + """ + insert into ui_translation_resource( + product_key, screen_key, resource_version + ) + values ('lineageweave', 'similar-voc', 1) + returning resource_id + """ + ) + await connection.execute( + _GENERIC_SEED_OWNERSHIP.read_text(encoding="utf-8") + ) + assert ( + await connection.fetchval( + """ + select ownership_state + from ui_translation_seed_ownership + where migration_key = '0249_z_similar_voc_translation_draft' + """ + ) + == "blocked" + ) + + await connection.execute( + "select set_config('lineageweave.migration_file', $1, false)", + _ROLLBACK_FILE, + ) + with pytest.raises( + asyncpg.PostgresError, + match="refuses resource .* outside exact seed ownership", + ): + await connection.execute( + "delete from ui_translation_resource where resource_id = $1", + resource_id, + ) + + assert ( + await connection.fetchval( + "select count(*) from ui_translation_resource where resource_id = $1", + resource_id, + ) + == 1 + ) + finally: + await connection.close() + finally: + await admin_connection.execute(f'drop database "{database_name}"') + await admin_connection.close() + + asyncio.run(scenario()) From 2e673c1e00b8a19dbe526cd623e81d7ec3769e59 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 13:59:48 +0900 Subject: [PATCH 07/37] fix(i18n): preserve blocked resources across rollback provenance --- migrations/0249_ui_translation_seed_ownership_generic.sql | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/migrations/0249_ui_translation_seed_ownership_generic.sql b/migrations/0249_ui_translation_seed_ownership_generic.sql index ea4610e17..dbb809e90 100644 --- a/migrations/0249_ui_translation_seed_ownership_generic.sql +++ b/migrations/0249_ui_translation_seed_ownership_generic.sql @@ -54,10 +54,16 @@ begin return new; end if; + -- Resolve DELETE provenance by product identity, not only resource_id. + -- blocked/pending reservations intentionally have resource_id = NULL, but a + -- rollback file carrying that migration key still must not gain authority + -- to delete the operator-owned resource that caused the block. select migration_key, ownership_state, resource_id into owner_migration_key, owner_state, owner_resource_id from public.ui_translation_seed_ownership - where resource_id = old.resource_id + where product_key = old.product_key + and screen_key = old.screen_key + and resource_version = old.resource_version for update; if owner_migration_key is null then From 27e8d53a549c34369f41f6365c99146e24cf0c72 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 14:00:33 +0900 Subject: [PATCH 08/37] test(i18n): require reversible generic seed ownership wiring --- .../test_translation_seed_generic_rollback.py | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 tests/test_translation_seed_generic_rollback.py diff --git a/tests/test_translation_seed_generic_rollback.py b/tests/test_translation_seed_generic_rollback.py new file mode 100644 index 000000000..084b2b80e --- /dev/null +++ b/tests/test_translation_seed_generic_rollback.py @@ -0,0 +1,152 @@ +"""Recovery contract for the generic UI translation seed ownership migration.""" + +from __future__ import annotations + +import asyncio +import os +import uuid +from pathlib import Path +from urllib.parse import urlsplit, urlunsplit + +import asyncpg +import pytest + +ROOT = Path(__file__).resolve().parents[1] +_ADMIN_DSN = os.environ.get( + "LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN", "postgresql://localhost/postgres" +) +_INITIAL_SCHEMA = ROOT / "migrations" / "0001_initial_schema.sql" +_MEMBER_LOCALE_MIGRATION = ROOT / "migrations" / "0044_member_locale_preference.sql" +_LEDGER_MIGRATION = ROOT / "migrations" / "0246_ui_translation_ledger.sql" +_TRUNCATE_GUARD_MIGRATION = ROOT / "migrations" / "0247_ui_translation_truncate_guard.sql" +_CUSTOMER_OWNERSHIP = ( + ROOT / "migrations" / "0247_z_customer_master_translation_seed_ownership.sql" +) +_CUSTOMER_SEED = ROOT / "migrations" / "0248_customer_master_translation_draft.sql" +_GENERIC_OWNERSHIP = ROOT / "migrations" / "0249_ui_translation_seed_ownership_generic.sql" +_GENERIC_OWNERSHIP_ROLLBACK = ( + ROOT / "migrations" / "rollback" / "0249_ui_translation_seed_ownership_generic.sql" +) +_SIMILAR_SEED = ROOT / "migrations" / "0249_z_similar_voc_translation_draft.sql" +_SIMILAR_SEED_ROLLBACK = ( + ROOT / "migrations" / "rollback" / "0249_z_similar_voc_translation_draft.sql" +) + + +async def _postgres_available_async() -> bool: + try: + connection = await asyncpg.connect(_ADMIN_DSN, timeout=2) + except (asyncpg.PostgresError, OSError, TimeoutError): + return False + await connection.close() + return True + + +def _postgres_available() -> bool: + return asyncio.run(_postgres_available_async()) + + +@pytest.mark.skipif( + not _postgres_available(), + reason=( + "no reachable PostgreSQL server at " + f"{_ADMIN_DSN} (set LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN)" + ), +) +def test_generic_ownership_rollback_restores_customer_master_owner_lane() -> None: + """Rollback removes only generic wiring and leaves 0248 ownership executable.""" + + async def scenario() -> None: + database_name = f"lineageweave_seed_generic_rb_{uuid.uuid4().hex[:12]}" + admin_connection = await asyncpg.connect(_ADMIN_DSN) + await admin_connection.execute(f'create database "{database_name}"') + parsed_admin_dsn = urlsplit(_ADMIN_DSN) + database_dsn = urlunsplit(parsed_admin_dsn._replace(path=f"/{database_name}")) + try: + connection = await asyncpg.connect(database_dsn) + try: + for migration in ( + _INITIAL_SCHEMA, + _MEMBER_LOCALE_MIGRATION, + _LEDGER_MIGRATION, + _TRUNCATE_GUARD_MIGRATION, + _CUSTOMER_OWNERSHIP, + _GENERIC_OWNERSHIP, + _SIMILAR_SEED, + ): + await connection.execute(migration.read_text(encoding="utf-8")) + + await connection.execute(_SIMILAR_SEED_ROLLBACK.read_text(encoding="utf-8")) + await connection.execute( + _GENERIC_OWNERSHIP_ROLLBACK.read_text(encoding="utf-8") + ) + + generic_trigger_count = await connection.fetchval( + """ + select count(*) + from pg_trigger + where tgname like 'ui_translation_seed_%_ownership_%' + and not tgisinternal + """ + ) + assert generic_trigger_count == 0 + expected_customer_triggers = { + "customer_master_seed_resource_ownership_guard", + "customer_master_seed_resource_ownership_bind", + "customer_master_seed_key_ownership_guard", + "customer_master_seed_text_ownership_guard", + } + trigger_rows = await connection.fetch( + """ + select tgname + from pg_trigger + where tgname = any($1::text[]) + and not tgisinternal + """, + list(expected_customer_triggers), + ) + assert {row["tgname"] for row in trigger_rows} == expected_customer_triggers + + assert ( + await connection.fetchval( + """ + select ownership_state + from ui_translation_seed_ownership + where migration_key = '0248_customer_master_translation_draft' + """ + ) + == "pending" + ) + assert ( + await connection.fetchval( + """ + select count(*) + from ui_translation_seed_ownership + where migration_key = '0249_z_similar_voc_translation_draft' + """ + ) + == 0 + ) + + await connection.execute( + "select set_config('lineageweave.migration_file', $1, false)", + "0248_customer_master_translation_draft.sql", + ) + await connection.execute(_CUSTOMER_SEED.read_text(encoding="utf-8")) + assert ( + await connection.fetchval( + """ + select ownership_state + from ui_translation_seed_ownership + where migration_key = '0248_customer_master_translation_draft' + """ + ) + == "owned" + ) + finally: + await connection.close() + finally: + await admin_connection.execute(f'drop database "{database_name}"') + await admin_connection.close() + + asyncio.run(scenario()) From 057eb6ed65cb80fdee40a391bc0727a0a4b925d4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 14:00:52 +0900 Subject: [PATCH 09/37] fix(i18n): add bounded rollback for generic seed ownership wiring --- ..._ui_translation_seed_ownership_generic.sql | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 migrations/rollback/0249_ui_translation_seed_ownership_generic.sql diff --git a/migrations/rollback/0249_ui_translation_seed_ownership_generic.sql b/migrations/rollback/0249_ui_translation_seed_ownership_generic.sql new file mode 100644 index 000000000..ed7023ac3 --- /dev/null +++ b/migrations/rollback/0249_ui_translation_seed_ownership_generic.sql @@ -0,0 +1,81 @@ +-- Restore the Customer Master-specific seed boundary after Similar VOC v1 is gone. +-- This rollback never removes a current product resource or another seed owner's +-- provenance record. +begin; + +do $generic_seed_ownership_rollback$ +begin + if exists ( + select 1 + from public.ui_translation_resource + where product_key = 'lineageweave' + and screen_key = 'similar-voc' + and resource_version = 1 + ) then + raise exception + 'Generic UI translation seed ownership rollback refuses while Similar VOC v1 exists'; + end if; + + delete from public.ui_translation_seed_ownership + where migration_key = '0249_z_similar_voc_translation_draft' + and ownership_state in ('pending', 'blocked', 'retired') + and resource_id is null; + + if exists ( + select 1 + from public.ui_translation_seed_ownership + where migration_key = '0249_z_similar_voc_translation_draft' + ) then + raise exception + 'Generic UI translation seed ownership rollback refuses while Similar VOC ownership remains'; + end if; + + if exists ( + select 1 + from public.ui_translation_seed_ownership + where migration_key <> '0248_customer_master_translation_draft' + ) then + raise exception + 'Generic UI translation seed ownership rollback refuses while another generic seed owner remains'; + end if; +end; +$generic_seed_ownership_rollback$; + +drop trigger if exists ui_translation_seed_text_ownership_guard + on public.ui_translation_text; +drop trigger if exists ui_translation_seed_key_ownership_guard + on public.ui_translation_key; +drop trigger if exists ui_translation_seed_resource_ownership_bind + on public.ui_translation_resource; +drop trigger if exists ui_translation_seed_resource_ownership_guard + on public.ui_translation_resource; + +drop trigger if exists customer_master_seed_text_ownership_guard + on public.ui_translation_text; +create trigger customer_master_seed_text_ownership_guard +before insert or update or delete on public.ui_translation_text +for each row execute function guard_customer_master_seed_child_ownership(); + +drop trigger if exists customer_master_seed_key_ownership_guard + on public.ui_translation_key; +create trigger customer_master_seed_key_ownership_guard +before insert or update or delete on public.ui_translation_key +for each row execute function guard_customer_master_seed_child_ownership(); + +drop trigger if exists customer_master_seed_resource_ownership_bind + on public.ui_translation_resource; +create trigger customer_master_seed_resource_ownership_bind +after insert on public.ui_translation_resource +for each row execute function bind_customer_master_seed_resource_ownership(); + +drop trigger if exists customer_master_seed_resource_ownership_guard + on public.ui_translation_resource; +create trigger customer_master_seed_resource_ownership_guard +before insert or delete on public.ui_translation_resource +for each row execute function guard_customer_master_seed_resource_ownership(); + +drop function if exists guard_ui_translation_seed_child_ownership(); +drop function if exists bind_ui_translation_seed_resource_ownership(); +drop function if exists guard_ui_translation_seed_resource_ownership(); + +commit; From 75fefc4e599f35fd7f24862ea0496d7c89717eb0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 14:02:39 +0900 Subject: [PATCH 10/37] docs(adr): record rollback provenance and recovery repair --- .../0377-similar-voc-versioned-screen-copy.md | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/docs/adr/0377-similar-voc-versioned-screen-copy.md b/docs/adr/0377-similar-voc-versioned-screen-copy.md index 215c41fe7..8d80bc836 100644 --- a/docs/adr/0377-similar-voc-versioned-screen-copy.md +++ b/docs/adr/0377-similar-voc-versioned-screen-copy.md @@ -22,6 +22,8 @@ This ADR does not move ontology labels, VOC semantic truth, similarity adjudicat - A resource that existed before the seed reservation is operator-owned and cannot be adopted implicitly. - Ordinary deletion of an exactly owned candidate retires the one-time seed receipt so startup cannot resurrect it. - Explicit rollback may delete only the exact owned unpublished draft. Published versions remain immutable under ADR 0362. +- Rollback provenance must resolve the ownership record by product/screen/version identity as well as resource id. `blocked` and `pending` reservations intentionally have `resource_id = NULL`; that must never let a rollback-labelled session delete the operator resource that caused the block. +- The generic ownership migration itself has a bounded rollback: it may be removed only after Similar VOC v1 and every non-Customer-Master generic owner are gone, and it must restore the pre-existing Customer Master trigger lane rather than dropping that owner boundary. - Seed ownership must be reusable for future LineageWeave screen-copy candidates rather than adding another Customer-Master-specific trigger family. - Similar VOC consumer cutover remains in its existing source-owner lane; this owner PR must not become a second writer for `SimilarVocPanel`. @@ -47,16 +49,22 @@ Rejected. A second screen-specific trigger family would encode the same lifecycl Rejected. Once a draft exists, reviewers own its copy. An upsert replay would silently restore historical migration text over reviewed product data. After first exact-owned materialization, replay must not mutate keys or text. +### Resolve rollback ownership by `resource_id` only + +Rejected. That works for `owned` rows but fails exactly where destructive authority matters most: `blocked` and `pending` rows carry no `resource_id`. A rollback-labelled session could otherwise bypass the ownership boundary and delete a same-identity operator resource. The resource's immutable product/screen/version tuple is therefore the lookup key; destructive rollback still requires exact `owned` state and exact `resource_id` equality. + ## Decision Migration `0249_ui_translation_seed_ownership_generic.sql` replaces the active Customer-Master-specific ownership trigger wiring with generic trigger functions over `ui_translation_seed_ownership`. The lifecycle is selected by the ownership row's `(migration_key, product_key, screen_key, resource_version)` identity and the trusted `lineageweave.migration_file` execution context. A `pending` ownership row reserves an otherwise-empty screen identity for its seed. The forward seed may create that root and an `AFTER INSERT` binder records the exact `resource_id` as `owned`. Child writes performed by the seed are accepted only after that binding. A pre-existing resource causes `blocked` state and the forward seed fails closed without touching it. -Ordinary deletion of an exact `owned` candidate first changes the receipt to `retired` and clears `resource_id`; the root can then be deleted without `ON DELETE CASCADE` erasing the one-time completion evidence. Forward replay in `owned` or `retired` state does not restore seed text. Explicit rollback is distinguished by `rollback/.sql`; it may delete the exact owned draft without retiring it, allowing the ownership row to cascade away so a later intentional forward migration can seed a fresh review candidate. +Ordinary deletion of an exact `owned` candidate first changes the receipt to `retired` and clears `resource_id`; the root can then be deleted without `ON DELETE CASCADE` erasing the one-time completion evidence. Forward replay in `owned` or `retired` state does not restore seed text. Explicit rollback is distinguished by `rollback/.sql`; the generic guard first resolves the ownership row by the root's immutable product/screen/version identity, then allows deletion only for exact `owned` state with the same `resource_id`. It may delete the exact owned draft without retiring it, allowing the ownership row to cascade away so a later intentional forward migration can seed a fresh review candidate. Migration `0249_z_similar_voc_translation_draft.sql` reserves no semantic authority. It creates 23 presentation keys × 8 locales only when ownership is `pending`, verifies the exact 184-row matrix, and then stops. Publication remains a separate one-way ADR-0362 transition after independent language/product review and unchanged-head consumer acceptance. +`rollback/0249_ui_translation_seed_ownership_generic.sql` is deliberately narrower than the forward migration. It refuses while Similar VOC v1 exists or while any non-Customer-Master generic seed owner remains. Once those dependents are absent, it removes only the generic trigger/functions and restores the already-existing Customer Master ownership trigger functions from migration 0247. It does not remove the ownership table or Customer Master provenance. + ## DDD mapping - Subdomain: product composition / presentation read model. @@ -65,13 +73,15 @@ Migration `0249_z_similar_voc_translation_draft.sql` reserves no semantic author - Value identities: translation key and locale-tagged presentation copy. - Repository: PostgreSQL translation ledger; Valkey remains an optional read cache under ADR 0362. - Domain service: one-time candidate-seed ownership lifecycle. -- Invariants: no implicit adoption, no reviewed-copy replay overwrite, no post-retirement resurrection, rollback limited to exact owned draft, eight-locale completeness before any publication, ontology labels excluded. +- Invariants: no implicit adoption, no reviewed-copy replay overwrite, no post-retirement resurrection, rollback limited to exact owned draft, blocked/pending resources outside rollback authority, eight-locale completeness before any publication, ontology labels excluded. - ACL: #1126 consumes only a released/published screen-copy contract; similarity adjudication and authorization continue to come from their existing owners. ## Recovery and rollout If the Similar VOC draft is wrong before publication, reviewers may edit it in place or explicitly run `rollback/0249_z_similar_voc_translation_draft.sql` and re-run the forward candidate. Ordinary product deletion means retirement, not authorization for historical reseeding. If a conflicting pre-existing `lineageweave/similar-voc/v1` resource exists, the migration stays blocked until an operator resolves that product decision; it never deletes or adopts the conflicting resource. +To remove the generic ownership layer itself, first remove the exact Similar VOC draft through its owned rollback (or otherwise resolve the product resource) and ensure no other generic seed owner remains. Then run `rollback/0249_ui_translation_seed_ownership_generic.sql`; Customer Master ownership remains active through its original 0247 functions/triggers. + A published bad version is not down-migrated. ADR 0362 requires a new immutable resource version and consumer routing to that reviewed version. ## Evidence @@ -79,6 +89,10 @@ A published bad version is not down-migrated. ADR 0362 requires a new immutable - RED `c5339b820eaf2af3fe169bbb3d14d5364d0bea72` requires a complete eight-locale Similar VOC candidate, replay preservation of reviewed copy, unowned-resource refusal, retirement without resurrection, and exact-owned-draft rollback/reseed behavior on real PostgreSQL. - Generic ownership repair `5448d8d24caa07110152f8fa1e5e5c52044ae60f` moves active seed lifecycle enforcement from Customer-Master-specific trigger wiring to the ownership aggregate and reserves `lineageweave/similar-voc/v1`. - Candidate seed `d5f574f6e9eb04afab86c9bb9aee8acf93fb4375` adds the 23×8 review matrix and makes `owned|retired` replay purpose-complete without rewriting reviewed data. -- Rollback repair `eaaf7397fcb6db32afea599b6c17fc981bc6c9bd` limits destructive rollback to the exact owned unpublished Similar VOC resource. +- Draft rollback `eaaf7397fcb6db32afea599b6c17fc981bc6c9bd` limits destructive Similar VOC rollback to the exact owned unpublished resource. +- Rollback-provenance RED `f0e79fd233e01fc512d146b9e544aa9a2780efc6` found that a `resource_id`-only DELETE lookup did not see blocked ownership rows and could therefore let rollback provenance delete the operator-owned conflicting root. +- Causal rollback-provenance repair `2e673c1e00b8a19dbe526cd623e81d7ec3769e59` resolves DELETE ownership by immutable product/screen/version identity and still requires exact owned-resource equality before destructive rollback. +- Generic-migration recovery RED `27e8d53a549c34369f41f6365c99146e24cf0c72` requires rollback of the generalized trigger layer to leave the original Customer Master ownership lane executable. +- Generic-migration rollback `057eb6ed65cb80fdee40a391bc0727a0a4b925d4` refuses while dependent generic owners remain, restores the Customer Master trigger wiring, and removes only the generic trigger/functions. These commits are source-level evidence only until exact-head PostgreSQL and hosted validation run. No publication, translation approval, consumer acceptance, merge, or release is implied by this ADR. From 73c7397ecd7e49439662b07516d087505a769d16 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 14:16:27 +0900 Subject: [PATCH 11/37] test(i18n): guard blocked seed child rollback provenance --- ...translation_seed_generic_rollback_guard.py | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/tests/test_translation_seed_generic_rollback_guard.py b/tests/test_translation_seed_generic_rollback_guard.py index 74e1d37a5..b244d327a 100644 --- a/tests/test_translation_seed_generic_rollback_guard.py +++ b/tests/test_translation_seed_generic_rollback_guard.py @@ -119,3 +119,99 @@ async def scenario() -> None: await admin_connection.close() asyncio.run(scenario()) + + +@pytest.mark.skipif( + not _postgres_available(), + reason=( + "no reachable PostgreSQL server at " + f"{_ADMIN_DSN} (set LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN)" + ), +) +def test_blocked_operator_children_cannot_be_deleted_under_seed_rollback_context() -> None: + """Rollback provenance must not mutate children of a blocked operator draft.""" + + async def scenario() -> None: + database_name = f"lineageweave_seed_child_rb_{uuid.uuid4().hex[:12]}" + admin_connection = await asyncpg.connect(_ADMIN_DSN) + await admin_connection.execute(f'create database "{database_name}"') + parsed_admin_dsn = urlsplit(_ADMIN_DSN) + database_dsn = urlunsplit(parsed_admin_dsn._replace(path=f"/{database_name}")) + try: + connection = await asyncpg.connect(database_dsn) + try: + for migration in ( + _INITIAL_SCHEMA, + _MEMBER_LOCALE_MIGRATION, + _LEDGER_MIGRATION, + _TRUNCATE_GUARD_MIGRATION, + _EXISTING_SEED_OWNERSHIP, + ): + await connection.execute(migration.read_text(encoding="utf-8")) + + resource_id = await connection.fetchval( + """ + insert into ui_translation_resource( + product_key, screen_key, resource_version + ) + values ('lineageweave', 'similar-voc', 1) + returning resource_id + """ + ) + await connection.execute( + """ + insert into ui_translation_key(resource_id, translation_key) + values ($1, 'operator-owned-copy') + """, + resource_id, + ) + await connection.execute( + _GENERIC_SEED_OWNERSHIP.read_text(encoding="utf-8") + ) + assert ( + await connection.fetchval( + """ + select ownership_state + from ui_translation_seed_ownership + where migration_key = '0249_z_similar_voc_translation_draft' + """ + ) + == "blocked" + ) + + await connection.execute( + "select set_config('lineageweave.migration_file', $1, false)", + _ROLLBACK_FILE, + ) + with pytest.raises( + asyncpg.PostgresError, + match="refuses child mutation outside exact seed ownership", + ): + await connection.execute( + """ + delete from ui_translation_key + where resource_id = $1 + and translation_key = 'operator-owned-copy' + """, + resource_id, + ) + + assert ( + await connection.fetchval( + """ + select count(*) + from ui_translation_key + where resource_id = $1 + and translation_key = 'operator-owned-copy' + """, + resource_id, + ) + == 1 + ) + finally: + await connection.close() + finally: + await admin_connection.execute(f'drop database "{database_name}"') + await admin_connection.close() + + asyncio.run(scenario()) From dde6ea349cdf244e1c2dd754d600c041db2f8b6f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 14:17:45 +0900 Subject: [PATCH 12/37] fix(i18n): bind rollback child mutations to exact seed ownership --- .../0249_ui_translation_seed_ownership_generic.sql | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/migrations/0249_ui_translation_seed_ownership_generic.sql b/migrations/0249_ui_translation_seed_ownership_generic.sql index dbb809e90..2ba28d780 100644 --- a/migrations/0249_ui_translation_seed_ownership_generic.sql +++ b/migrations/0249_ui_translation_seed_ownership_generic.sql @@ -175,7 +175,15 @@ begin end if; migration_file := current_setting('lineageweave.migration_file', true); - if migration_file = owner_migration_key || '.sql' + if migration_file = 'rollback/' || owner_migration_key || '.sql' then + if tg_op <> 'DELETE' + or owner_state <> 'owned' + or owner_resource_id <> target_resource_id then + raise exception + 'UI translation seed % refuses child mutation outside exact seed ownership', + owner_migration_key; + end if; + elsif migration_file = owner_migration_key || '.sql' and (owner_state <> 'owned' or owner_resource_id <> target_resource_id) then raise exception 'UI translation seed % refuses child mutation outside exact seed ownership', From 714d828fa5ecfc85962737eb43cc374b476daa00 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 14:18:33 +0900 Subject: [PATCH 13/37] docs(adr): bind rollback child provenance to exact ownership --- docs/adr/0377-similar-voc-versioned-screen-copy.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/adr/0377-similar-voc-versioned-screen-copy.md b/docs/adr/0377-similar-voc-versioned-screen-copy.md index 8d80bc836..c030d139b 100644 --- a/docs/adr/0377-similar-voc-versioned-screen-copy.md +++ b/docs/adr/0377-similar-voc-versioned-screen-copy.md @@ -23,6 +23,7 @@ This ADR does not move ontology labels, VOC semantic truth, similarity adjudicat - Ordinary deletion of an exactly owned candidate retires the one-time seed receipt so startup cannot resurrect it. - Explicit rollback may delete only the exact owned unpublished draft. Published versions remain immutable under ADR 0362. - Rollback provenance must resolve the ownership record by product/screen/version identity as well as resource id. `blocked` and `pending` reservations intentionally have `resource_id = NULL`; that must never let a rollback-labelled session delete the operator resource that caused the block. +- Rollback-labelled child mutation is narrower still: only `DELETE` against the exact `owned` resource is admissible. A blocked/pending operator draft must not become mutable merely because the session carries the seed rollback migration key, and rollback must not gain insert/update authority over reviewed child copy. - The generic ownership migration itself has a bounded rollback: it may be removed only after Similar VOC v1 and every non-Customer-Master generic owner are gone, and it must restore the pre-existing Customer Master trigger lane rather than dropping that owner boundary. - Seed ownership must be reusable for future LineageWeave screen-copy candidates rather than adding another Customer-Master-specific trigger family. - Similar VOC consumer cutover remains in its existing source-owner lane; this owner PR must not become a second writer for `SimilarVocPanel`. @@ -53,13 +54,17 @@ Rejected. Once a draft exists, reviewers own its copy. An upsert replay would si Rejected. That works for `owned` rows but fails exactly where destructive authority matters most: `blocked` and `pending` rows carry no `resource_id`. A rollback-labelled session could otherwise bypass the ownership boundary and delete a same-identity operator resource. The resource's immutable product/screen/version tuple is therefore the lookup key; destructive rollback still requires exact `owned` state and exact `resource_id` equality. +### Treat the rollback migration key as blanket child-write authority + +Rejected. The rollback file exists to remove an exact owned draft, not to edit review data or touch a blocked operator resource. Child writes therefore remain provenance-bound: forward seed writes require exact owned resource binding, while rollback provenance permits only delete operations on that same exact owned resource. + ## Decision Migration `0249_ui_translation_seed_ownership_generic.sql` replaces the active Customer-Master-specific ownership trigger wiring with generic trigger functions over `ui_translation_seed_ownership`. The lifecycle is selected by the ownership row's `(migration_key, product_key, screen_key, resource_version)` identity and the trusted `lineageweave.migration_file` execution context. A `pending` ownership row reserves an otherwise-empty screen identity for its seed. The forward seed may create that root and an `AFTER INSERT` binder records the exact `resource_id` as `owned`. Child writes performed by the seed are accepted only after that binding. A pre-existing resource causes `blocked` state and the forward seed fails closed without touching it. -Ordinary deletion of an exact `owned` candidate first changes the receipt to `retired` and clears `resource_id`; the root can then be deleted without `ON DELETE CASCADE` erasing the one-time completion evidence. Forward replay in `owned` or `retired` state does not restore seed text. Explicit rollback is distinguished by `rollback/.sql`; the generic guard first resolves the ownership row by the root's immutable product/screen/version identity, then allows deletion only for exact `owned` state with the same `resource_id`. It may delete the exact owned draft without retiring it, allowing the ownership row to cascade away so a later intentional forward migration can seed a fresh review candidate. +Ordinary deletion of an exact `owned` candidate first changes the receipt to `retired` and clears `resource_id`; the root can then be deleted without `ON DELETE CASCADE` erasing the one-time completion evidence. Forward replay in `owned` or `retired` state does not restore seed text. Explicit rollback is distinguished by `rollback/.sql`; the generic root guard first resolves the ownership row by the root's immutable product/screen/version identity, then allows deletion only for exact `owned` state with the same `resource_id`. The child guard applies the same exact ownership check and additionally rejects rollback-labelled `INSERT` or `UPDATE`, so rollback provenance cannot mutate blocked operator child rows or rewrite reviewed copy. Cascading child deletes from the exact owned root remain admissible. The exact owned draft can therefore be removed without retiring it, allowing the ownership row to cascade away so a later intentional forward migration can seed a fresh review candidate. Migration `0249_z_similar_voc_translation_draft.sql` reserves no semantic authority. It creates 23 presentation keys × 8 locales only when ownership is `pending`, verifies the exact 184-row matrix, and then stops. Publication remains a separate one-way ADR-0362 transition after independent language/product review and unchanged-head consumer acceptance. @@ -73,7 +78,7 @@ Migration `0249_z_similar_voc_translation_draft.sql` reserves no semantic author - Value identities: translation key and locale-tagged presentation copy. - Repository: PostgreSQL translation ledger; Valkey remains an optional read cache under ADR 0362. - Domain service: one-time candidate-seed ownership lifecycle. -- Invariants: no implicit adoption, no reviewed-copy replay overwrite, no post-retirement resurrection, rollback limited to exact owned draft, blocked/pending resources outside rollback authority, eight-locale completeness before any publication, ontology labels excluded. +- Invariants: no implicit adoption, no reviewed-copy replay overwrite, no post-retirement resurrection, rollback limited to exact owned draft, blocked/pending roots and child rows outside rollback authority, rollback child mutation limited to exact-owned deletion, eight-locale completeness before any publication, ontology labels excluded. - ACL: #1126 consumes only a released/published screen-copy contract; similarity adjudication and authorization continue to come from their existing owners. ## Recovery and rollout @@ -94,5 +99,7 @@ A published bad version is not down-migrated. ADR 0362 requires a new immutable - Causal rollback-provenance repair `2e673c1e00b8a19dbe526cd623e81d7ec3769e59` resolves DELETE ownership by immutable product/screen/version identity and still requires exact owned-resource equality before destructive rollback. - Generic-migration recovery RED `27e8d53a549c34369f41f6365c99146e24cf0c72` requires rollback of the generalized trigger layer to leave the original Customer Master ownership lane executable. - Generic-migration rollback `057eb6ed65cb80fdee40a391bc0727a0a4b925d4` refuses while dependent generic owners remain, restores the Customer Master trigger wiring, and removes only the generic trigger/functions. +- Child rollback-provenance RED `73c7397ecd7e49439662b07516d087505a769d16` proves that a blocked operator draft's child key was still directly deletable when the session carried the Similar VOC rollback migration key. +- Causal child-rollback repair `dde6ea349cdf244e1c2dd754d600c041db2f8b6f` restricts rollback-labelled child mutation to `DELETE` on the exact `owned` resource while preserving the normal exact-owned cascading rollback path. These commits are source-level evidence only until exact-head PostgreSQL and hosted validation run. No publication, translation approval, consumer acceptance, merge, or release is implied by this ADR. From eaf25d0630faa81c9e6509fe80d48c57b394395d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 14:46:38 +0900 Subject: [PATCH 14/37] test(i18n): reject rollback child resource escape --- ...tion_seed_generic_rollback_update_guard.py | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 tests/test_translation_seed_generic_rollback_update_guard.py diff --git a/tests/test_translation_seed_generic_rollback_update_guard.py b/tests/test_translation_seed_generic_rollback_update_guard.py new file mode 100644 index 000000000..660c6ce97 --- /dev/null +++ b/tests/test_translation_seed_generic_rollback_update_guard.py @@ -0,0 +1,147 @@ +"""Rollback provenance must not move translation children out of a governed resource.""" + +from __future__ import annotations + +import asyncio +import os +import uuid +from pathlib import Path +from urllib.parse import urlsplit, urlunsplit + +import asyncpg +import pytest + +ROOT = Path(__file__).resolve().parents[1] +_ADMIN_DSN = os.environ.get( + "LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN", "postgresql://localhost/postgres" +) +_INITIAL_SCHEMA = ROOT / "migrations" / "0001_initial_schema.sql" +_MEMBER_LOCALE_MIGRATION = ROOT / "migrations" / "0044_member_locale_preference.sql" +_LEDGER_MIGRATION = ROOT / "migrations" / "0246_ui_translation_ledger.sql" +_TRUNCATE_GUARD_MIGRATION = ROOT / "migrations" / "0247_ui_translation_truncate_guard.sql" +_EXISTING_SEED_OWNERSHIP = ( + ROOT / "migrations" / "0247_z_customer_master_translation_seed_ownership.sql" +) +_GENERIC_SEED_OWNERSHIP = ( + ROOT / "migrations" / "0249_ui_translation_seed_ownership_generic.sql" +) +_ROLLBACK_FILE = "rollback/0249_z_similar_voc_translation_draft.sql" + + +async def _postgres_available_async() -> bool: + try: + connection = await asyncpg.connect(_ADMIN_DSN, timeout=2) + except (asyncpg.PostgresError, OSError, TimeoutError): + return False + await connection.close() + return True + + +def _postgres_available() -> bool: + return asyncio.run(_postgres_available_async()) + + +@pytest.mark.skipif( + not _postgres_available(), + reason=( + "no reachable PostgreSQL server at " + f"{_ADMIN_DSN} (set LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN)" + ), +) +def test_rollback_context_cannot_move_blocked_child_to_unowned_resource() -> None: + """UPDATE must not escape rollback provenance by changing resource_id first.""" + + async def scenario() -> None: + database_name = f"lineageweave_seed_child_move_{uuid.uuid4().hex[:12]}" + admin_connection = await asyncpg.connect(_ADMIN_DSN) + await admin_connection.execute(f'create database "{database_name}"') + parsed_admin_dsn = urlsplit(_ADMIN_DSN) + database_dsn = urlunsplit(parsed_admin_dsn._replace(path=f"/{database_name}")) + try: + connection = await asyncpg.connect(database_dsn) + try: + for migration in ( + _INITIAL_SCHEMA, + _MEMBER_LOCALE_MIGRATION, + _LEDGER_MIGRATION, + _TRUNCATE_GUARD_MIGRATION, + _EXISTING_SEED_OWNERSHIP, + ): + await connection.execute(migration.read_text(encoding="utf-8")) + + blocked_resource_id = await connection.fetchval( + """ + insert into ui_translation_resource( + product_key, screen_key, resource_version + ) + values ('lineageweave', 'similar-voc', 1) + returning resource_id + """ + ) + target_resource_id = await connection.fetchval( + """ + insert into ui_translation_resource( + product_key, screen_key, resource_version + ) + values ('lineageweave', 'unowned-target', 1) + returning resource_id + """ + ) + await connection.execute( + """ + insert into ui_translation_key(resource_id, translation_key) + values ($1, 'operator-owned-copy') + """, + blocked_resource_id, + ) + + await connection.execute( + _GENERIC_SEED_OWNERSHIP.read_text(encoding="utf-8") + ) + assert ( + await connection.fetchval( + """ + select ownership_state + from ui_translation_seed_ownership + where migration_key = '0249_z_similar_voc_translation_draft' + """ + ) + == "blocked" + ) + + await connection.execute( + "select set_config('lineageweave.migration_file', $1, false)", + _ROLLBACK_FILE, + ) + with pytest.raises( + asyncpg.PostgresError, + match="refuses child mutation outside exact seed ownership", + ): + await connection.execute( + """ + update ui_translation_key + set resource_id = $1 + where resource_id = $2 + and translation_key = 'operator-owned-copy' + """, + target_resource_id, + blocked_resource_id, + ) + + assert ( + await connection.fetchval( + """ + select resource_id + from ui_translation_key + where translation_key = 'operator-owned-copy' + """ + ) + == blocked_resource_id + ) + finally: + await connection.close() + finally: + await admin_connection.execute(f'drop database "{database_name}"') + await admin_connection.close() + + asyncio.run(scenario()) From cf14dc8197bc820204784e423418b76362d4d7e3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 14:48:05 +0900 Subject: [PATCH 15/37] fix(i18n): guard child provenance before resource move --- ...i_translation_seed_ownership_generic_b.sql | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 migrations/0249_ui_translation_seed_ownership_generic_b.sql diff --git a/migrations/0249_ui_translation_seed_ownership_generic_b.sql b/migrations/0249_ui_translation_seed_ownership_generic_b.sql new file mode 100644 index 000000000..e8127225e --- /dev/null +++ b/migrations/0249_ui_translation_seed_ownership_generic_b.sql @@ -0,0 +1,95 @@ +-- Harden generic UI translation child provenance for resource-id UPDATEs. +-- This companion migration runs after 0249_ui_translation_seed_ownership_generic.sql +-- and before the Similar VOC seed. A child UPDATE must not escape a seed-owned or +-- blocked resource by changing resource_id before the provenance lookup occurs. +begin; + +create or replace function guard_ui_translation_seed_child_ownership() +returns trigger +language plpgsql +set search_path = pg_catalog, public, pg_temp +as $$ +declare + target_resource_id bigint; + owner_migration_key text; + owner_state text; + owner_resource_id bigint; + migration_file text; +begin + migration_file := current_setting('lineageweave.migration_file', true); + + -- UPDATE is the only operation with two resource identities. Inspect the + -- source before the target so changing resource_id cannot move a child out + -- of a governed resource and thereby make the ownership join disappear. + if tg_op = 'UPDATE' and old.resource_id is distinct from new.resource_id then + select ownership.migration_key, ownership.ownership_state, ownership.resource_id + into owner_migration_key, owner_state, owner_resource_id + from public.ui_translation_resource as resource + join public.ui_translation_seed_ownership as ownership + on ownership.product_key = resource.product_key + and ownership.screen_key = resource.screen_key + and ownership.resource_version = resource.resource_version + where resource.resource_id = old.resource_id + for update of ownership; + + if owner_migration_key is not null + and migration_file in ( + owner_migration_key || '.sql', + 'rollback/' || owner_migration_key || '.sql' + ) then + raise exception + 'UI translation seed % refuses child mutation outside exact seed ownership', + owner_migration_key; + end if; + end if; + + if tg_op = 'DELETE' then + target_resource_id := old.resource_id; + else + target_resource_id := new.resource_id; + end if; + + owner_migration_key := null; + owner_state := null; + owner_resource_id := null; + + select ownership.migration_key, ownership.ownership_state, ownership.resource_id + into owner_migration_key, owner_state, owner_resource_id + from public.ui_translation_resource as resource + join public.ui_translation_seed_ownership as ownership + on ownership.product_key = resource.product_key + and ownership.screen_key = resource.screen_key + and ownership.resource_version = resource.resource_version + where resource.resource_id = target_resource_id + for update of ownership; + + if owner_migration_key is null then + if tg_op = 'DELETE' then + return old; + end if; + return new; + end if; + + if migration_file = 'rollback/' || owner_migration_key || '.sql' then + if tg_op <> 'DELETE' + or owner_state <> 'owned' + or owner_resource_id <> target_resource_id then + raise exception + 'UI translation seed % refuses child mutation outside exact seed ownership', + owner_migration_key; + end if; + elsif migration_file = owner_migration_key || '.sql' + and (owner_state <> 'owned' or owner_resource_id <> target_resource_id) then + raise exception + 'UI translation seed % refuses child mutation outside exact seed ownership', + owner_migration_key; + end if; + + if tg_op = 'DELETE' then + return old; + end if; + return new; +end; +$$; + +commit; From 6137835740da198984f6e32d2cb49712d0bf9269 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 14:48:28 +0900 Subject: [PATCH 16/37] test(i18n): exercise hardened child update guard --- .../test_translation_seed_generic_rollback_update_guard.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_translation_seed_generic_rollback_update_guard.py b/tests/test_translation_seed_generic_rollback_update_guard.py index 660c6ce97..568cee790 100644 --- a/tests/test_translation_seed_generic_rollback_update_guard.py +++ b/tests/test_translation_seed_generic_rollback_update_guard.py @@ -25,6 +25,9 @@ _GENERIC_SEED_OWNERSHIP = ( ROOT / "migrations" / "0249_ui_translation_seed_ownership_generic.sql" ) +_GENERIC_CHILD_UPDATE_GUARD = ( + ROOT / "migrations" / "0249_ui_translation_seed_ownership_generic_b.sql" +) _ROLLBACK_FILE = "rollback/0249_z_similar_voc_translation_draft.sql" @@ -98,6 +101,9 @@ async def scenario() -> None: await connection.execute( _GENERIC_SEED_OWNERSHIP.read_text(encoding="utf-8") ) + await connection.execute( + _GENERIC_CHILD_UPDATE_GUARD.read_text(encoding="utf-8") + ) assert ( await connection.fetchval( """ From 86c6863da2a3609b8c5a73ce7f5b263867e15772 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 14:49:22 +0900 Subject: [PATCH 17/37] test(i18n): pin child guard migration ordering --- ...est_translation_seed_generic_rollback_update_guard.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_translation_seed_generic_rollback_update_guard.py b/tests/test_translation_seed_generic_rollback_update_guard.py index 568cee790..715249fcc 100644 --- a/tests/test_translation_seed_generic_rollback_update_guard.py +++ b/tests/test_translation_seed_generic_rollback_update_guard.py @@ -28,9 +28,18 @@ _GENERIC_CHILD_UPDATE_GUARD = ( ROOT / "migrations" / "0249_ui_translation_seed_ownership_generic_b.sql" ) +_SIMILAR_VOC_SEED = ROOT / "migrations" / "0249_z_similar_voc_translation_draft.sql" _ROLLBACK_FILE = "rollback/0249_z_similar_voc_translation_draft.sql" +def test_child_update_guard_replays_after_generic_owner_before_similar_voc_seed() -> None: + """Sorted migration replay must install the hardened guard before seed writes.""" + names = sorted(path.name for path in (ROOT / "migrations").glob("*.sql")) + assert names.index(_GENERIC_SEED_OWNERSHIP.name) < names.index( + _GENERIC_CHILD_UPDATE_GUARD.name + ) < names.index(_SIMILAR_VOC_SEED.name) + + async def _postgres_available_async() -> bool: try: connection = await asyncpg.connect(_ADMIN_DSN, timeout=2) From 2d08963b46f57d106549296874bb4d8f4f925b7d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 14:50:10 +0900 Subject: [PATCH 18/37] docs(adr): record child resource-move provenance --- docs/adr/0377-similar-voc-versioned-screen-copy.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/adr/0377-similar-voc-versioned-screen-copy.md b/docs/adr/0377-similar-voc-versioned-screen-copy.md index c030d139b..cdd2a7148 100644 --- a/docs/adr/0377-similar-voc-versioned-screen-copy.md +++ b/docs/adr/0377-similar-voc-versioned-screen-copy.md @@ -24,6 +24,7 @@ This ADR does not move ontology labels, VOC semantic truth, similarity adjudicat - Explicit rollback may delete only the exact owned unpublished draft. Published versions remain immutable under ADR 0362. - Rollback provenance must resolve the ownership record by product/screen/version identity as well as resource id. `blocked` and `pending` reservations intentionally have `resource_id = NULL`; that must never let a rollback-labelled session delete the operator resource that caused the block. - Rollback-labelled child mutation is narrower still: only `DELETE` against the exact `owned` resource is admissible. A blocked/pending operator draft must not become mutable merely because the session carries the seed rollback migration key, and rollback must not gain insert/update authority over reviewed child copy. +- Child `UPDATE` provenance must consider the source resource before the target resource. Changing `resource_id` must not make the governing ownership row disappear from the trigger lookup and thereby turn a rollback-labelled update into an ungoverned move. - The generic ownership migration itself has a bounded rollback: it may be removed only after Similar VOC v1 and every non-Customer-Master generic owner are gone, and it must restore the pre-existing Customer Master trigger lane rather than dropping that owner boundary. - Seed ownership must be reusable for future LineageWeave screen-copy candidates rather than adding another Customer-Master-specific trigger family. - Similar VOC consumer cutover remains in its existing source-owner lane; this owner PR must not become a second writer for `SimilarVocPanel`. @@ -58,6 +59,10 @@ Rejected. That works for `owned` rows but fails exactly where destructive author Rejected. The rollback file exists to remove an exact owned draft, not to edit review data or touch a blocked operator resource. Child writes therefore remain provenance-bound: forward seed writes require exact owned resource binding, while rollback provenance permits only delete operations on that same exact owned resource. +### Validate only the target `resource_id` on child UPDATE + +Rejected. `UPDATE` has both a source and target identity. Looking up only `NEW.resource_id` lets a rollback-labelled statement move a child out of a blocked or owned resource into an unrelated resource with no seed-ownership row; the trigger then sees no owner and returns early. The source identity must be checked before any target lookup whenever the resource changes. + ## Decision Migration `0249_ui_translation_seed_ownership_generic.sql` replaces the active Customer-Master-specific ownership trigger wiring with generic trigger functions over `ui_translation_seed_ownership`. The lifecycle is selected by the ownership row's `(migration_key, product_key, screen_key, resource_version)` identity and the trusted `lineageweave.migration_file` execution context. @@ -66,6 +71,8 @@ A `pending` ownership row reserves an otherwise-empty screen identity for its se Ordinary deletion of an exact `owned` candidate first changes the receipt to `retired` and clears `resource_id`; the root can then be deleted without `ON DELETE CASCADE` erasing the one-time completion evidence. Forward replay in `owned` or `retired` state does not restore seed text. Explicit rollback is distinguished by `rollback/.sql`; the generic root guard first resolves the ownership row by the root's immutable product/screen/version identity, then allows deletion only for exact `owned` state with the same `resource_id`. The child guard applies the same exact ownership check and additionally rejects rollback-labelled `INSERT` or `UPDATE`, so rollback provenance cannot mutate blocked operator child rows or rewrite reviewed copy. Cascading child deletes from the exact owned root remain admissible. The exact owned draft can therefore be removed without retiring it, allowing the ownership row to cascade away so a later intentional forward migration can seed a fresh review candidate. +Migration `0249_ui_translation_seed_ownership_generic_b.sql` hardens the child guard before any Similar VOC seed write can run. Sorted migration replay installs the generic owner first, then this companion hardening, then `0249_z_similar_voc_translation_draft.sql`. For `UPDATE` statements that change `resource_id`, the guard resolves the source resource first. If the current migration context is the matching forward or rollback seed, moving the child out of that governed resource is rejected before a target-resource lookup can erase the provenance signal. The ordinary product lifecycle remains outside migration provenance when no matching seed context is active. The existing generic rollback remains sufficient recovery because it drops the active generic child-guard function after all dependent generic owners are gone. + Migration `0249_z_similar_voc_translation_draft.sql` reserves no semantic authority. It creates 23 presentation keys × 8 locales only when ownership is `pending`, verifies the exact 184-row matrix, and then stops. Publication remains a separate one-way ADR-0362 transition after independent language/product review and unchanged-head consumer acceptance. `rollback/0249_ui_translation_seed_ownership_generic.sql` is deliberately narrower than the forward migration. It refuses while Similar VOC v1 exists or while any non-Customer-Master generic seed owner remains. Once those dependents are absent, it removes only the generic trigger/functions and restores the already-existing Customer Master ownership trigger functions from migration 0247. It does not remove the ownership table or Customer Master provenance. @@ -78,14 +85,14 @@ Migration `0249_z_similar_voc_translation_draft.sql` reserves no semantic author - Value identities: translation key and locale-tagged presentation copy. - Repository: PostgreSQL translation ledger; Valkey remains an optional read cache under ADR 0362. - Domain service: one-time candidate-seed ownership lifecycle. -- Invariants: no implicit adoption, no reviewed-copy replay overwrite, no post-retirement resurrection, rollback limited to exact owned draft, blocked/pending roots and child rows outside rollback authority, rollback child mutation limited to exact-owned deletion, eight-locale completeness before any publication, ontology labels excluded. +- Invariants: no implicit adoption, no reviewed-copy replay overwrite, no post-retirement resurrection, rollback limited to exact owned draft, blocked/pending roots and child rows outside rollback authority, rollback child mutation limited to exact-owned deletion, child resource moves cannot escape provenance by changing identity first, eight-locale completeness before any publication, ontology labels excluded. - ACL: #1126 consumes only a released/published screen-copy contract; similarity adjudication and authorization continue to come from their existing owners. ## Recovery and rollout If the Similar VOC draft is wrong before publication, reviewers may edit it in place or explicitly run `rollback/0249_z_similar_voc_translation_draft.sql` and re-run the forward candidate. Ordinary product deletion means retirement, not authorization for historical reseeding. If a conflicting pre-existing `lineageweave/similar-voc/v1` resource exists, the migration stays blocked until an operator resolves that product decision; it never deletes or adopts the conflicting resource. -To remove the generic ownership layer itself, first remove the exact Similar VOC draft through its owned rollback (or otherwise resolve the product resource) and ensure no other generic seed owner remains. Then run `rollback/0249_ui_translation_seed_ownership_generic.sql`; Customer Master ownership remains active through its original 0247 functions/triggers. +To remove the generic ownership layer itself, first remove the exact Similar VOC draft through its owned rollback (or otherwise resolve the product resource) and ensure no other generic seed owner remains. Then run `rollback/0249_ui_translation_seed_ownership_generic.sql`; Customer Master ownership remains active through its original 0247 functions/triggers. The companion child-update hardening needs no separate destructive rollback because the generic rollback drops the function it replaces as part of the same bounded owner-layer removal. A published bad version is not down-migrated. ADR 0362 requires a new immutable resource version and consumer routing to that reviewed version. @@ -101,5 +108,7 @@ A published bad version is not down-migrated. ADR 0362 requires a new immutable - Generic-migration rollback `057eb6ed65cb80fdee40a391bc0727a0a4b925d4` refuses while dependent generic owners remain, restores the Customer Master trigger wiring, and removes only the generic trigger/functions. - Child rollback-provenance RED `73c7397ecd7e49439662b07516d087505a769d16` proves that a blocked operator draft's child key was still directly deletable when the session carried the Similar VOC rollback migration key. - Causal child-rollback repair `dde6ea349cdf244e1c2dd754d600c041db2f8b6f` restricts rollback-labelled child mutation to `DELETE` on the exact `owned` resource while preserving the normal exact-owned cascading rollback path. +- Child resource-move RED `eaf25d0630faa81c9e6509fe80d48c57b394395d` proves that `UPDATE ... SET resource_id = ...` could otherwise move a blocked operator child to an unrelated unowned resource because the predecessor guard looked up only `NEW.resource_id`. +- Causal source-provenance repair `cf14dc8197bc820204784e423418b76362d4d7e3` installs a source-first child-update guard before the Similar VOC seed; test wiring `6137835740da198984f6e32d2cb49712d0bf9269` exercises that hardened migration and ordering contract `86c6863da2a3609b8c5a73ce7f5b263867e15772` pins sorted replay between the generic owner and Similar VOC seed. These commits are source-level evidence only until exact-head PostgreSQL and hosted validation run. No publication, translation approval, consumer acceptance, merge, or release is implied by this ADR. From 6ed44ea51f4cb935625dbc6249d4c3e498ca7357 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 15:47:35 +0900 Subject: [PATCH 19/37] test(i18n): pin child move authority to base ledger --- ...tion_seed_generic_rollback_update_guard.py | 23 +++++++------------ 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/tests/test_translation_seed_generic_rollback_update_guard.py b/tests/test_translation_seed_generic_rollback_update_guard.py index 715249fcc..06e212850 100644 --- a/tests/test_translation_seed_generic_rollback_update_guard.py +++ b/tests/test_translation_seed_generic_rollback_update_guard.py @@ -1,4 +1,4 @@ -"""Rollback provenance must not move translation children out of a governed resource.""" +"""Translation child resource identity remains owned by the base ledger boundary.""" from __future__ import annotations @@ -25,19 +25,15 @@ _GENERIC_SEED_OWNERSHIP = ( ROOT / "migrations" / "0249_ui_translation_seed_ownership_generic.sql" ) -_GENERIC_CHILD_UPDATE_GUARD = ( +_DUPLICATE_CHILD_MOVE_GUARD = ( ROOT / "migrations" / "0249_ui_translation_seed_ownership_generic_b.sql" ) -_SIMILAR_VOC_SEED = ROOT / "migrations" / "0249_z_similar_voc_translation_draft.sql" _ROLLBACK_FILE = "rollback/0249_z_similar_voc_translation_draft.sql" -def test_child_update_guard_replays_after_generic_owner_before_similar_voc_seed() -> None: - """Sorted migration replay must install the hardened guard before seed writes.""" - names = sorted(path.name for path in (ROOT / "migrations").glob("*.sql")) - assert names.index(_GENERIC_SEED_OWNERSHIP.name) < names.index( - _GENERIC_CHILD_UPDATE_GUARD.name - ) < names.index(_SIMILAR_VOC_SEED.name) +def test_seed_owner_does_not_duplicate_base_child_move_boundary() -> None: + """ADR 0362, not a seed-specific migration, owns cross-resource child moves.""" + assert not _DUPLICATE_CHILD_MOVE_GUARD.exists() async def _postgres_available_async() -> bool: @@ -60,8 +56,8 @@ def _postgres_available() -> bool: f"{_ADMIN_DSN} (set LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN)" ), ) -def test_rollback_context_cannot_move_blocked_child_to_unowned_resource() -> None: - """UPDATE must not escape rollback provenance by changing resource_id first.""" +def test_base_ledger_guard_blocks_resource_move_before_seed_provenance() -> None: + """A rollback label cannot bypass the ledger's immutable child resource identity.""" async def scenario() -> None: database_name = f"lineageweave_seed_child_move_{uuid.uuid4().hex[:12]}" @@ -110,9 +106,6 @@ async def scenario() -> None: await connection.execute( _GENERIC_SEED_OWNERSHIP.read_text(encoding="utf-8") ) - await connection.execute( - _GENERIC_CHILD_UPDATE_GUARD.read_text(encoding="utf-8") - ) assert ( await connection.fetchval( """ @@ -130,7 +123,7 @@ async def scenario() -> None: ) with pytest.raises( asyncpg.PostgresError, - match="refuses child mutation outside exact seed ownership", + match="UI translation child rows cannot move between resources", ): await connection.execute( """ From 8d220fbcd8d3016fd310d3a5aac1fc597d3969a9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 15:47:44 +0900 Subject: [PATCH 20/37] fix(i18n): remove duplicate child move guard --- ...i_translation_seed_ownership_generic_b.sql | 95 ------------------- 1 file changed, 95 deletions(-) delete mode 100644 migrations/0249_ui_translation_seed_ownership_generic_b.sql diff --git a/migrations/0249_ui_translation_seed_ownership_generic_b.sql b/migrations/0249_ui_translation_seed_ownership_generic_b.sql deleted file mode 100644 index e8127225e..000000000 --- a/migrations/0249_ui_translation_seed_ownership_generic_b.sql +++ /dev/null @@ -1,95 +0,0 @@ --- Harden generic UI translation child provenance for resource-id UPDATEs. --- This companion migration runs after 0249_ui_translation_seed_ownership_generic.sql --- and before the Similar VOC seed. A child UPDATE must not escape a seed-owned or --- blocked resource by changing resource_id before the provenance lookup occurs. -begin; - -create or replace function guard_ui_translation_seed_child_ownership() -returns trigger -language plpgsql -set search_path = pg_catalog, public, pg_temp -as $$ -declare - target_resource_id bigint; - owner_migration_key text; - owner_state text; - owner_resource_id bigint; - migration_file text; -begin - migration_file := current_setting('lineageweave.migration_file', true); - - -- UPDATE is the only operation with two resource identities. Inspect the - -- source before the target so changing resource_id cannot move a child out - -- of a governed resource and thereby make the ownership join disappear. - if tg_op = 'UPDATE' and old.resource_id is distinct from new.resource_id then - select ownership.migration_key, ownership.ownership_state, ownership.resource_id - into owner_migration_key, owner_state, owner_resource_id - from public.ui_translation_resource as resource - join public.ui_translation_seed_ownership as ownership - on ownership.product_key = resource.product_key - and ownership.screen_key = resource.screen_key - and ownership.resource_version = resource.resource_version - where resource.resource_id = old.resource_id - for update of ownership; - - if owner_migration_key is not null - and migration_file in ( - owner_migration_key || '.sql', - 'rollback/' || owner_migration_key || '.sql' - ) then - raise exception - 'UI translation seed % refuses child mutation outside exact seed ownership', - owner_migration_key; - end if; - end if; - - if tg_op = 'DELETE' then - target_resource_id := old.resource_id; - else - target_resource_id := new.resource_id; - end if; - - owner_migration_key := null; - owner_state := null; - owner_resource_id := null; - - select ownership.migration_key, ownership.ownership_state, ownership.resource_id - into owner_migration_key, owner_state, owner_resource_id - from public.ui_translation_resource as resource - join public.ui_translation_seed_ownership as ownership - on ownership.product_key = resource.product_key - and ownership.screen_key = resource.screen_key - and ownership.resource_version = resource.resource_version - where resource.resource_id = target_resource_id - for update of ownership; - - if owner_migration_key is null then - if tg_op = 'DELETE' then - return old; - end if; - return new; - end if; - - if migration_file = 'rollback/' || owner_migration_key || '.sql' then - if tg_op <> 'DELETE' - or owner_state <> 'owned' - or owner_resource_id <> target_resource_id then - raise exception - 'UI translation seed % refuses child mutation outside exact seed ownership', - owner_migration_key; - end if; - elsif migration_file = owner_migration_key || '.sql' - and (owner_state <> 'owned' or owner_resource_id <> target_resource_id) then - raise exception - 'UI translation seed % refuses child mutation outside exact seed ownership', - owner_migration_key; - end if; - - if tg_op = 'DELETE' then - return old; - end if; - return new; -end; -$$; - -commit; From 9f5a1e398f16767fc6095ef0004b200ed18f7e82 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 15:48:40 +0900 Subject: [PATCH 21/37] docs(adr): restore ledger ownership of child moves --- .../0377-similar-voc-versioned-screen-copy.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/adr/0377-similar-voc-versioned-screen-copy.md b/docs/adr/0377-similar-voc-versioned-screen-copy.md index cdd2a7148..1bdec24a2 100644 --- a/docs/adr/0377-similar-voc-versioned-screen-copy.md +++ b/docs/adr/0377-similar-voc-versioned-screen-copy.md @@ -24,7 +24,7 @@ This ADR does not move ontology labels, VOC semantic truth, similarity adjudicat - Explicit rollback may delete only the exact owned unpublished draft. Published versions remain immutable under ADR 0362. - Rollback provenance must resolve the ownership record by product/screen/version identity as well as resource id. `blocked` and `pending` reservations intentionally have `resource_id = NULL`; that must never let a rollback-labelled session delete the operator resource that caused the block. - Rollback-labelled child mutation is narrower still: only `DELETE` against the exact `owned` resource is admissible. A blocked/pending operator draft must not become mutable merely because the session carries the seed rollback migration key, and rollback must not gain insert/update authority over reviewed child copy. -- Child `UPDATE` provenance must consider the source resource before the target resource. Changing `resource_id` must not make the governing ownership row disappear from the trigger lookup and thereby turn a rollback-labelled update into an ungoverned move. +- Child resource identity remains owned by ADR 0362's base ledger guard. `ui_translation_key` and `ui_translation_text` rows cannot move between resources at all; seed ownership must not duplicate or weaken that invariant. - The generic ownership migration itself has a bounded rollback: it may be removed only after Similar VOC v1 and every non-Customer-Master generic owner are gone, and it must restore the pre-existing Customer Master trigger lane rather than dropping that owner boundary. - Seed ownership must be reusable for future LineageWeave screen-copy candidates rather than adding another Customer-Master-specific trigger family. - Similar VOC consumer cutover remains in its existing source-owner lane; this owner PR must not become a second writer for `SimilarVocPanel`. @@ -59,9 +59,9 @@ Rejected. That works for `owned` rows but fails exactly where destructive author Rejected. The rollback file exists to remove an exact owned draft, not to edit review data or touch a blocked operator resource. Child writes therefore remain provenance-bound: forward seed writes require exact owned resource binding, while rollback provenance permits only delete operations on that same exact owned resource. -### Validate only the target `resource_id` on child UPDATE +### Add a seed-specific source-first child UPDATE guard -Rejected. `UPDATE` has both a source and target identity. Looking up only `NEW.resource_id` lets a rollback-labelled statement move a child out of a blocked or owned resource into an unrelated resource with no seed-ownership row; the trigger then sees no owner and returns early. The source identity must be checked before any target lookup whenever the resource changes. +Rejected after review. ADR 0362 already installs `guard_ui_translation_child_mutation()`, which rejects every `resource_id` change before seed ownership needs to classify source or target provenance. PostgreSQL fires multiple triggers of the same kind in alphabetical trigger-name order, so the existing `ui_translation_key_mutation_guard` / `ui_translation_text_mutation_guard` runs before the later `ui_translation_seed_*_ownership_guard`. A companion migration that reimplements source-first UPDATE handling is therefore dead authority rather than a causal safety repair. PostgreSQL 18 documents the trigger-order rule at https://www.postgresql.org/docs/18/trigger-definition.html. ## Decision @@ -71,7 +71,7 @@ A `pending` ownership row reserves an otherwise-empty screen identity for its se Ordinary deletion of an exact `owned` candidate first changes the receipt to `retired` and clears `resource_id`; the root can then be deleted without `ON DELETE CASCADE` erasing the one-time completion evidence. Forward replay in `owned` or `retired` state does not restore seed text. Explicit rollback is distinguished by `rollback/.sql`; the generic root guard first resolves the ownership row by the root's immutable product/screen/version identity, then allows deletion only for exact `owned` state with the same `resource_id`. The child guard applies the same exact ownership check and additionally rejects rollback-labelled `INSERT` or `UPDATE`, so rollback provenance cannot mutate blocked operator child rows or rewrite reviewed copy. Cascading child deletes from the exact owned root remain admissible. The exact owned draft can therefore be removed without retiring it, allowing the ownership row to cascade away so a later intentional forward migration can seed a fresh review candidate. -Migration `0249_ui_translation_seed_ownership_generic_b.sql` hardens the child guard before any Similar VOC seed write can run. Sorted migration replay installs the generic owner first, then this companion hardening, then `0249_z_similar_voc_translation_draft.sql`. For `UPDATE` statements that change `resource_id`, the guard resolves the source resource first. If the current migration context is the matching forward or rollback seed, moving the child out of that governed resource is rejected before a target-resource lookup can erase the provenance signal. The ordinary product lifecycle remains outside migration provenance when no matching seed context is active. The existing generic rollback remains sufficient recovery because it drops the active generic child-guard function after all dependent generic owners are gone. +Cross-resource child UPDATE remains exclusively owned by the base translation-ledger guard from migration 0246. The generic seed-ownership layer does not redefine that rule. This keeps the DDD boundary single-writer: ADR 0362 owns resource/key/text identity and immutability, while ADR 0377 owns only one-time seed provenance and recovery for the Similar VOC presentation-copy candidate. Migration `0249_z_similar_voc_translation_draft.sql` reserves no semantic authority. It creates 23 presentation keys × 8 locales only when ownership is `pending`, verifies the exact 184-row matrix, and then stops. Publication remains a separate one-way ADR-0362 transition after independent language/product review and unchanged-head consumer acceptance. @@ -85,14 +85,14 @@ Migration `0249_z_similar_voc_translation_draft.sql` reserves no semantic author - Value identities: translation key and locale-tagged presentation copy. - Repository: PostgreSQL translation ledger; Valkey remains an optional read cache under ADR 0362. - Domain service: one-time candidate-seed ownership lifecycle. -- Invariants: no implicit adoption, no reviewed-copy replay overwrite, no post-retirement resurrection, rollback limited to exact owned draft, blocked/pending roots and child rows outside rollback authority, rollback child mutation limited to exact-owned deletion, child resource moves cannot escape provenance by changing identity first, eight-locale completeness before any publication, ontology labels excluded. +- Invariants: no implicit adoption, no reviewed-copy replay overwrite, no post-retirement resurrection, rollback limited to exact owned draft, blocked/pending roots and child rows outside rollback authority, rollback child mutation limited to exact-owned deletion, child resource moves remain prohibited by the single ADR-0362 base guard, eight-locale completeness before any publication, ontology labels excluded. - ACL: #1126 consumes only a released/published screen-copy contract; similarity adjudication and authorization continue to come from their existing owners. ## Recovery and rollout If the Similar VOC draft is wrong before publication, reviewers may edit it in place or explicitly run `rollback/0249_z_similar_voc_translation_draft.sql` and re-run the forward candidate. Ordinary product deletion means retirement, not authorization for historical reseeding. If a conflicting pre-existing `lineageweave/similar-voc/v1` resource exists, the migration stays blocked until an operator resolves that product decision; it never deletes or adopts the conflicting resource. -To remove the generic ownership layer itself, first remove the exact Similar VOC draft through its owned rollback (or otherwise resolve the product resource) and ensure no other generic seed owner remains. Then run `rollback/0249_ui_translation_seed_ownership_generic.sql`; Customer Master ownership remains active through its original 0247 functions/triggers. The companion child-update hardening needs no separate destructive rollback because the generic rollback drops the function it replaces as part of the same bounded owner-layer removal. +To remove the generic ownership layer itself, first remove the exact Similar VOC draft through its owned rollback (or otherwise resolve the product resource) and ensure no other generic seed owner remains. Then run `rollback/0249_ui_translation_seed_ownership_generic.sql`; Customer Master ownership remains active through its original 0247 functions/triggers. No separate child-move rollback exists because no seed-specific child-move migration is retained. A published bad version is not down-migrated. ADR 0362 requires a new immutable resource version and consumer routing to that reviewed version. @@ -108,7 +108,8 @@ A published bad version is not down-migrated. ADR 0362 requires a new immutable - Generic-migration rollback `057eb6ed65cb80fdee40a391bc0727a0a4b925d4` refuses while dependent generic owners remain, restores the Customer Master trigger wiring, and removes only the generic trigger/functions. - Child rollback-provenance RED `73c7397ecd7e49439662b07516d087505a769d16` proves that a blocked operator draft's child key was still directly deletable when the session carried the Similar VOC rollback migration key. - Causal child-rollback repair `dde6ea349cdf244e1c2dd754d600c041db2f8b6f` restricts rollback-labelled child mutation to `DELETE` on the exact `owned` resource while preserving the normal exact-owned cascading rollback path. -- Child resource-move RED `eaf25d0630faa81c9e6509fe80d48c57b394395d` proves that `UPDATE ... SET resource_id = ...` could otherwise move a blocked operator child to an unrelated unowned resource because the predecessor guard looked up only `NEW.resource_id`. -- Causal source-provenance repair `cf14dc8197bc820204784e423418b76362d4d7e3` installs a source-first child-update guard before the Similar VOC seed; test wiring `6137835740da198984f6e32d2cb49712d0bf9269` exercises that hardened migration and ordering contract `86c6863da2a3609b8c5a73ce7f5b263867e15772` pins sorted replay between the generic owner and Similar VOC seed. +- Intermediate `eaf25d0630faa81c9e6509fe80d48c57b394395d` / `cf14dc8197bc820204784e423418b76362d4d7e3` / `6137835740da198984f6e32d2cb49712d0bf9269` / `86c6863da2a3609b8c5a73ce7f5b263867e15772` attempted to treat cross-resource UPDATE as a seed-provenance escape. Fresh review rejected that causal claim because ADR 0362 already prohibits the move before seed-specific logic runs. +- Correction RED `6ed44ea51f4cb935625dbc6249d4c3e498ca7357` pins the single-writer boundary: no seed-specific companion migration may own child resource moves, and the real-PostgreSQL error must come from the base ledger guard even under a rollback-labelled session. +- Causal correction `8d220fbcd8d3016fd310d3a5aac1fc597d3969a9` removes the duplicate companion migration without weakening the base resource-identity invariant. These commits are source-level evidence only until exact-head PostgreSQL and hosted validation run. No publication, translation approval, consumer acceptance, merge, or release is implied by this ADR. From 0d598231ca49aac1b61fef572f3bf8f7378d9bf0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 15:53:38 +0900 Subject: [PATCH 22/37] test(i18n): cover key and text child identity --- ...tion_seed_generic_rollback_update_guard.py | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/tests/test_translation_seed_generic_rollback_update_guard.py b/tests/test_translation_seed_generic_rollback_update_guard.py index 06e212850..9d386bf3f 100644 --- a/tests/test_translation_seed_generic_rollback_update_guard.py +++ b/tests/test_translation_seed_generic_rollback_update_guard.py @@ -56,8 +56,8 @@ def _postgres_available() -> bool: f"{_ADMIN_DSN} (set LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN)" ), ) -def test_base_ledger_guard_blocks_resource_move_before_seed_provenance() -> None: - """A rollback label cannot bypass the ledger's immutable child resource identity.""" +def test_base_ledger_guard_blocks_key_and_text_resource_moves_under_seed_context() -> None: + """Rollback provenance cannot bypass the ledger's immutable child resource identity.""" async def scenario() -> None: database_name = f"lineageweave_seed_child_move_{uuid.uuid4().hex[:12]}" @@ -102,6 +102,16 @@ async def scenario() -> None: """, blocked_resource_id, ) + translation_text_id = await connection.fetchval( + """ + insert into ui_translation_text( + resource_id, translation_key, locale, translated_text + ) + values ($1, 'operator-owned-copy', 'en', 'Operator-owned copy') + returning translation_text_id + """, + blocked_resource_id, + ) await connection.execute( _GENERIC_SEED_OWNERSHIP.read_text(encoding="utf-8") @@ -136,6 +146,20 @@ async def scenario() -> None: blocked_resource_id, ) + with pytest.raises( + asyncpg.PostgresError, + match="UI translation child rows cannot move between resources", + ): + await connection.execute( + """ + update ui_translation_text + set resource_id = $1 + where translation_text_id = $2 + """, + target_resource_id, + translation_text_id, + ) + assert ( await connection.fetchval( """ @@ -146,6 +170,17 @@ async def scenario() -> None: ) == blocked_resource_id ) + assert ( + await connection.fetchval( + """ + select resource_id + from ui_translation_text + where translation_text_id = $1 + """, + translation_text_id, + ) + == blocked_resource_id + ) finally: await connection.close() finally: From 260296705871eefd03219523dd9fe7cccc5aac4f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 15:54:31 +0900 Subject: [PATCH 23/37] docs(adr): state trigger ordering without overclaim --- docs/adr/0377-similar-voc-versioned-screen-copy.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/adr/0377-similar-voc-versioned-screen-copy.md b/docs/adr/0377-similar-voc-versioned-screen-copy.md index 1bdec24a2..6fc2306a4 100644 --- a/docs/adr/0377-similar-voc-versioned-screen-copy.md +++ b/docs/adr/0377-similar-voc-versioned-screen-copy.md @@ -61,7 +61,7 @@ Rejected. The rollback file exists to remove an exact owned draft, not to edit r ### Add a seed-specific source-first child UPDATE guard -Rejected after review. ADR 0362 already installs `guard_ui_translation_child_mutation()`, which rejects every `resource_id` change before seed ownership needs to classify source or target provenance. PostgreSQL fires multiple triggers of the same kind in alphabetical trigger-name order, so the existing `ui_translation_key_mutation_guard` / `ui_translation_text_mutation_guard` runs before the later `ui_translation_seed_*_ownership_guard`. A companion migration that reimplements source-first UPDATE handling is therefore dead authority rather than a causal safety repair. PostgreSQL 18 documents the trigger-order rule at https://www.postgresql.org/docs/18/trigger-definition.html. +Rejected after review. ADR 0362 already installs `guard_ui_translation_child_mutation()`, which rejects every `resource_id` change. PostgreSQL fires same-kind triggers in alphabetical trigger-name order, but the resulting order is table-specific here: the key mutation guard precedes the seed-key guard, while the seed-text guard precedes the text mutation guard. That ordering does not create an escape. If the seed guard does not reject first, it returns the row and the ADR-0362 base guard subsequently rejects the cross-resource move. A companion migration that reimplements source-first UPDATE handling therefore adds no permissible-state distinction and duplicates the base identity authority. PostgreSQL 18 documents the trigger-order rule at https://www.postgresql.org/docs/18/trigger-definition.html. ## Decision @@ -108,8 +108,9 @@ A published bad version is not down-migrated. ADR 0362 requires a new immutable - Generic-migration rollback `057eb6ed65cb80fdee40a391bc0727a0a4b925d4` refuses while dependent generic owners remain, restores the Customer Master trigger wiring, and removes only the generic trigger/functions. - Child rollback-provenance RED `73c7397ecd7e49439662b07516d087505a769d16` proves that a blocked operator draft's child key was still directly deletable when the session carried the Similar VOC rollback migration key. - Causal child-rollback repair `dde6ea349cdf244e1c2dd754d600c041db2f8b6f` restricts rollback-labelled child mutation to `DELETE` on the exact `owned` resource while preserving the normal exact-owned cascading rollback path. -- Intermediate `eaf25d0630faa81c9e6509fe80d48c57b394395d` / `cf14dc8197bc820204784e423418b76362d4d7e3` / `6137835740da198984f6e32d2cb49712d0bf9269` / `86c6863da2a3609b8c5a73ce7f5b263867e15772` attempted to treat cross-resource UPDATE as a seed-provenance escape. Fresh review rejected that causal claim because ADR 0362 already prohibits the move before seed-specific logic runs. -- Correction RED `6ed44ea51f4cb935625dbc6249d4c3e498ca7357` pins the single-writer boundary: no seed-specific companion migration may own child resource moves, and the real-PostgreSQL error must come from the base ledger guard even under a rollback-labelled session. +- Intermediate `eaf25d0630faa81c9e6509fe80d48c57b394395d` / `cf14dc8197bc820204784e423418b76362d4d7e3` / `6137835740da198984f6e32d2cb49712d0bf9269` / `86c6863da2a3609b8c5a73ce7f5b263867e15772` attempted to treat cross-resource UPDATE as a seed-provenance escape. Fresh review rejected that causal claim because ADR 0362 already prohibits the move independently of seed-specific provenance. +- Correction RED `6ed44ea51f4cb935625dbc6249d4c3e498ca7357` pins the single-writer boundary: no seed-specific companion migration may own child resource moves, and rollback-labelled child moves must still reach the ADR-0362 immutable-identity rejection when an earlier seed guard does not already reject them. - Causal correction `8d220fbcd8d3016fd310d3a5aac1fc597d3969a9` removes the duplicate companion migration without weakening the base resource-identity invariant. +- Coverage `0d598231ca49aac1b61fef572f3bf8f7378d9bf0` exercises both `ui_translation_key` and `ui_translation_text`, covering both trigger-name orderings and requiring the same base-ledger cross-resource-move rejection. These commits are source-level evidence only until exact-head PostgreSQL and hosted validation run. No publication, translation approval, consumer acceptance, merge, or release is implied by this ADR. From e9eb46990fc3e70d9d06afb01f2321745c1088ed Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 16:49:37 +0900 Subject: [PATCH 24/37] test(i18n): preserve blocked operator copy on owner rollback --- .../test_translation_seed_generic_rollback.py | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/tests/test_translation_seed_generic_rollback.py b/tests/test_translation_seed_generic_rollback.py index 084b2b80e..10223b304 100644 --- a/tests/test_translation_seed_generic_rollback.py +++ b/tests/test_translation_seed_generic_rollback.py @@ -150,3 +150,128 @@ async def scenario() -> None: await admin_connection.close() asyncio.run(scenario()) + + +@pytest.mark.skipif( + not _postgres_available(), + reason=( + "no reachable PostgreSQL server at " + f"{_ADMIN_DSN} (set LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN)" + ), +) +def test_generic_ownership_rollback_preserves_blocked_operator_resource() -> None: + """A blocked seed can roll back generic wiring without deleting operator copy.""" + + async def scenario() -> None: + database_name = f"lineageweave_seed_blocked_rb_{uuid.uuid4().hex[:12]}" + admin_connection = await asyncpg.connect(_ADMIN_DSN) + await admin_connection.execute(f'create database "{database_name}"') + parsed_admin_dsn = urlsplit(_ADMIN_DSN) + database_dsn = urlunsplit(parsed_admin_dsn._replace(path=f"/{database_name}")) + try: + connection = await asyncpg.connect(database_dsn) + try: + for migration in ( + _INITIAL_SCHEMA, + _MEMBER_LOCALE_MIGRATION, + _LEDGER_MIGRATION, + _TRUNCATE_GUARD_MIGRATION, + _CUSTOMER_OWNERSHIP, + ): + await connection.execute(migration.read_text(encoding="utf-8")) + + resource_id = await connection.fetchval( + """ + insert into ui_translation_resource( + product_key, screen_key, resource_version + ) + values ('lineageweave', 'similar-voc', 1) + returning resource_id + """ + ) + await connection.execute( + """ + insert into ui_translation_key(resource_id, translation_key) + values ($1, 'operator-owned-copy') + """, + resource_id, + ) + await connection.execute(_GENERIC_OWNERSHIP.read_text(encoding="utf-8")) + assert ( + await connection.fetchval( + """ + select ownership_state + from ui_translation_seed_ownership + where migration_key = '0249_z_similar_voc_translation_draft' + """ + ) + == "blocked" + ) + + await connection.execute( + _GENERIC_OWNERSHIP_ROLLBACK.read_text(encoding="utf-8") + ) + + assert ( + await connection.fetchval( + "select count(*) from ui_translation_resource where resource_id = $1", + resource_id, + ) + == 1 + ) + assert ( + await connection.fetchval( + """ + select count(*) + from ui_translation_key + where resource_id = $1 + and translation_key = 'operator-owned-copy' + """, + resource_id, + ) + == 1 + ) + assert ( + await connection.fetchval( + """ + select count(*) + from ui_translation_seed_ownership + where migration_key = '0249_z_similar_voc_translation_draft' + """ + ) + == 0 + ) + assert ( + await connection.fetchval( + """ + select count(*) + from pg_trigger + where tgname like 'ui_translation_seed_%_ownership_%' + and not tgisinternal + """ + ) + == 0 + ) + expected_customer_triggers = { + "customer_master_seed_resource_ownership_guard", + "customer_master_seed_resource_ownership_bind", + "customer_master_seed_key_ownership_guard", + "customer_master_seed_text_ownership_guard", + } + trigger_rows = await connection.fetch( + """ + select tgname + from pg_trigger + where tgname = any($1::text[]) + and not tgisinternal + """, + list(expected_customer_triggers), + ) + assert {row["tgname"] for row in trigger_rows} == expected_customer_triggers + finally: + await connection.close() + finally: + await admin_connection.execute(f'drop database "{database_name}"') + await admin_connection.close() + + asyncio.run(scenario()) From df9a39e98df366a6d49e3dbb8b635698e867cd2b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 16:50:14 +0900 Subject: [PATCH 25/37] fix(i18n): allow non-destructive blocked owner rollback --- ...9_ui_translation_seed_ownership_generic.sql | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/migrations/rollback/0249_ui_translation_seed_ownership_generic.sql b/migrations/rollback/0249_ui_translation_seed_ownership_generic.sql index ed7023ac3..6b9c01d0b 100644 --- a/migrations/rollback/0249_ui_translation_seed_ownership_generic.sql +++ b/migrations/rollback/0249_ui_translation_seed_ownership_generic.sql @@ -1,26 +1,20 @@ -- Restore the Customer Master-specific seed boundary after Similar VOC v1 is gone. -- This rollback never removes a current product resource or another seed owner's --- provenance record. +-- provenance record. A blocked/pending/retired receipt owns no resource, so the +-- generic layer can be removed while preserving any operator-owned Similar VOC v1. begin; do $generic_seed_ownership_rollback$ begin - if exists ( - select 1 - from public.ui_translation_resource - where product_key = 'lineageweave' - and screen_key = 'similar-voc' - and resource_version = 1 - ) then - raise exception - 'Generic UI translation seed ownership rollback refuses while Similar VOC v1 exists'; - end if; - delete from public.ui_translation_seed_ownership where migration_key = '0249_z_similar_voc_translation_draft' and ownership_state in ('pending', 'blocked', 'retired') and resource_id is null; + -- An owned receipt is the destructive-authority boundary. Refuse to remove + -- the generic trigger layer until its exact owned resource has first been + -- handled by the bounded Similar VOC rollback. Unowned operator copy is not + -- a dependency of this migration and must survive rollback untouched. if exists ( select 1 from public.ui_translation_seed_ownership From c519138c54d3b68e52d1cda4cacdc0d64a9e74c8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 16:51:39 +0900 Subject: [PATCH 26/37] docs(adr): record non-destructive blocked rollback boundary --- .../0377-similar-voc-versioned-screen-copy.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/adr/0377-similar-voc-versioned-screen-copy.md b/docs/adr/0377-similar-voc-versioned-screen-copy.md index 6fc2306a4..124332b60 100644 --- a/docs/adr/0377-similar-voc-versioned-screen-copy.md +++ b/docs/adr/0377-similar-voc-versioned-screen-copy.md @@ -25,7 +25,7 @@ This ADR does not move ontology labels, VOC semantic truth, similarity adjudicat - Rollback provenance must resolve the ownership record by product/screen/version identity as well as resource id. `blocked` and `pending` reservations intentionally have `resource_id = NULL`; that must never let a rollback-labelled session delete the operator resource that caused the block. - Rollback-labelled child mutation is narrower still: only `DELETE` against the exact `owned` resource is admissible. A blocked/pending operator draft must not become mutable merely because the session carries the seed rollback migration key, and rollback must not gain insert/update authority over reviewed child copy. - Child resource identity remains owned by ADR 0362's base ledger guard. `ui_translation_key` and `ui_translation_text` rows cannot move between resources at all; seed ownership must not duplicate or weaken that invariant. -- The generic ownership migration itself has a bounded rollback: it may be removed only after Similar VOC v1 and every non-Customer-Master generic owner are gone, and it must restore the pre-existing Customer Master trigger lane rather than dropping that owner boundary. +- The generic ownership migration itself has a bounded rollback. It must refuse while an exact `owned` Similar VOC receipt or another non-Customer-Master generic owner remains, but a `blocked`, `pending`, or `retired` receipt owns no resource and may be removed without deleting an operator-owned same-identity resource. The rollback must restore the pre-existing Customer Master trigger lane rather than dropping that owner boundary. - Seed ownership must be reusable for future LineageWeave screen-copy candidates rather than adding another Customer-Master-specific trigger family. - Similar VOC consumer cutover remains in its existing source-owner lane; this owner PR must not become a second writer for `SimilarVocPanel`. @@ -63,6 +63,10 @@ Rejected. The rollback file exists to remove an exact owned draft, not to edit r Rejected after review. ADR 0362 already installs `guard_ui_translation_child_mutation()`, which rejects every `resource_id` change. PostgreSQL fires same-kind triggers in alphabetical trigger-name order, but the resulting order is table-specific here: the key mutation guard precedes the seed-key guard, while the seed-text guard precedes the text mutation guard. That ordering does not create an escape. If the seed guard does not reject first, it returns the row and the ADR-0362 base guard subsequently rejects the cross-resource move. A companion migration that reimplements source-first UPDATE handling therefore adds no permissible-state distinction and duplicates the base identity authority. PostgreSQL 18 documents the trigger-order rule at https://www.postgresql.org/docs/18/trigger-definition.html. +### Require the Similar VOC resource to be absent before rolling back generic ownership wiring + +Rejected after recovery review. Absence is necessary only when the seed actually owns the resource. A `blocked` receipt is explicit evidence that the same-identity resource predates the seed and is operator-owned. Requiring that resource to be deleted before generic rollback makes a failed deployment non-recoverable without destructive operator action. The safe boundary is the ownership receipt: `owned` blocks generic-layer rollback; `pending`, `blocked`, and `retired` have `resource_id = NULL` and can be retired with the generic wiring while any operator resource remains untouched. + ## Decision Migration `0249_ui_translation_seed_ownership_generic.sql` replaces the active Customer-Master-specific ownership trigger wiring with generic trigger functions over `ui_translation_seed_ownership`. The lifecycle is selected by the ownership row's `(migration_key, product_key, screen_key, resource_version)` identity and the trusted `lineageweave.migration_file` execution context. @@ -75,7 +79,7 @@ Cross-resource child UPDATE remains exclusively owned by the base translation-le Migration `0249_z_similar_voc_translation_draft.sql` reserves no semantic authority. It creates 23 presentation keys × 8 locales only when ownership is `pending`, verifies the exact 184-row matrix, and then stops. Publication remains a separate one-way ADR-0362 transition after independent language/product review and unchanged-head consumer acceptance. -`rollback/0249_ui_translation_seed_ownership_generic.sql` is deliberately narrower than the forward migration. It refuses while Similar VOC v1 exists or while any non-Customer-Master generic seed owner remains. Once those dependents are absent, it removes only the generic trigger/functions and restores the already-existing Customer Master ownership trigger functions from migration 0247. It does not remove the ownership table or Customer Master provenance. +`rollback/0249_ui_translation_seed_ownership_generic.sql` uses ownership, not mere resource existence, as the dependency boundary. It removes `pending`, `blocked`, or `retired` Similar VOC receipts because those states carry no `resource_id` and therefore own no product resource. It refuses while the Similar VOC receipt is still `owned` or while any non-Customer-Master generic seed owner remains. Once those ownership dependencies are absent, it removes only the generic trigger/functions and restores the already-existing Customer Master ownership trigger functions from migration 0247. An operator-owned `lineageweave/similar-voc/v1` resource may remain in place throughout this rollback and is neither deleted nor adopted. ## DDD mapping @@ -85,14 +89,14 @@ Migration `0249_z_similar_voc_translation_draft.sql` reserves no semantic author - Value identities: translation key and locale-tagged presentation copy. - Repository: PostgreSQL translation ledger; Valkey remains an optional read cache under ADR 0362. - Domain service: one-time candidate-seed ownership lifecycle. -- Invariants: no implicit adoption, no reviewed-copy replay overwrite, no post-retirement resurrection, rollback limited to exact owned draft, blocked/pending roots and child rows outside rollback authority, rollback child mutation limited to exact-owned deletion, child resource moves remain prohibited by the single ADR-0362 base guard, eight-locale completeness before any publication, ontology labels excluded. +- Invariants: no implicit adoption, no reviewed-copy replay overwrite, no post-retirement resurrection, rollback limited to exact owned draft, blocked/pending roots and child rows outside destructive seed rollback authority, generic-layer rollback may preserve operator-owned conflicting copy, rollback child mutation limited to exact-owned deletion, child resource moves remain prohibited by the single ADR-0362 base guard, eight-locale completeness before any publication, ontology labels excluded. - ACL: #1126 consumes only a released/published screen-copy contract; similarity adjudication and authorization continue to come from their existing owners. ## Recovery and rollout -If the Similar VOC draft is wrong before publication, reviewers may edit it in place or explicitly run `rollback/0249_z_similar_voc_translation_draft.sql` and re-run the forward candidate. Ordinary product deletion means retirement, not authorization for historical reseeding. If a conflicting pre-existing `lineageweave/similar-voc/v1` resource exists, the migration stays blocked until an operator resolves that product decision; it never deletes or adopts the conflicting resource. +If the Similar VOC draft is wrong before publication, reviewers may edit it in place or explicitly run `rollback/0249_z_similar_voc_translation_draft.sql` and re-run the forward candidate. Ordinary product deletion means retirement, not authorization for historical reseeding. If a conflicting pre-existing `lineageweave/similar-voc/v1` resource exists, the forward seed stays blocked and never deletes or adopts that resource. -To remove the generic ownership layer itself, first remove the exact Similar VOC draft through its owned rollback (or otherwise resolve the product resource) and ensure no other generic seed owner remains. Then run `rollback/0249_ui_translation_seed_ownership_generic.sql`; Customer Master ownership remains active through its original 0247 functions/triggers. No separate child-move rollback exists because no seed-specific child-move migration is retained. +If deployment must be rolled back while that conflict remains, the generic ownership layer may still be removed because `blocked` proves the seed owns no resource. Run `rollback/0249_ui_translation_seed_ownership_generic.sql`; it deletes only the unowned Similar VOC receipt, preserves the operator resource and children, restores Customer Master ownership triggers, and removes the generic functions. If the receipt is `owned`, first remove the exact Similar VOC draft through its bounded rollback. Any other generic seed owner must also be resolved before the shared generic layer is removed. No separate child-move rollback exists because no seed-specific child-move migration is retained. A published bad version is not down-migrated. ADR 0362 requires a new immutable resource version and consumer routing to that reviewed version. @@ -105,12 +109,14 @@ A published bad version is not down-migrated. ADR 0362 requires a new immutable - Rollback-provenance RED `f0e79fd233e01fc512d146b9e544aa9a2780efc6` found that a `resource_id`-only DELETE lookup did not see blocked ownership rows and could therefore let rollback provenance delete the operator-owned conflicting root. - Causal rollback-provenance repair `2e673c1e00b8a19dbe526cd623e81d7ec3769e59` resolves DELETE ownership by immutable product/screen/version identity and still requires exact owned-resource equality before destructive rollback. - Generic-migration recovery RED `27e8d53a549c34369f41f6365c99146e24cf0c72` requires rollback of the generalized trigger layer to leave the original Customer Master ownership lane executable. -- Generic-migration rollback `057eb6ed65cb80fdee40a391bc0727a0a4b925d4` refuses while dependent generic owners remain, restores the Customer Master trigger wiring, and removes only the generic trigger/functions. +- Generic-migration rollback `057eb6ed65cb80fdee40a391bc0727a0a4b925d4` restores the Customer Master trigger wiring and removes only the generic trigger/functions after its then-known dependencies are absent. - Child rollback-provenance RED `73c7397ecd7e49439662b07516d087505a769d16` proves that a blocked operator draft's child key was still directly deletable when the session carried the Similar VOC rollback migration key. - Causal child-rollback repair `dde6ea349cdf244e1c2dd754d600c041db2f8b6f` restricts rollback-labelled child mutation to `DELETE` on the exact `owned` resource while preserving the normal exact-owned cascading rollback path. - Intermediate `eaf25d0630faa81c9e6509fe80d48c57b394395d` / `cf14dc8197bc820204784e423418b76362d4d7e3` / `6137835740da198984f6e32d2cb49712d0bf9269` / `86c6863da2a3609b8c5a73ce7f5b263867e15772` attempted to treat cross-resource UPDATE as a seed-provenance escape. Fresh review rejected that causal claim because ADR 0362 already prohibits the move independently of seed-specific provenance. - Correction RED `6ed44ea51f4cb935625dbc6249d4c3e498ca7357` pins the single-writer boundary: no seed-specific companion migration may own child resource moves, and rollback-labelled child moves must still reach the ADR-0362 immutable-identity rejection when an earlier seed guard does not already reject them. - Causal correction `8d220fbcd8d3016fd310d3a5aac1fc597d3969a9` removes the duplicate companion migration without weakening the base resource-identity invariant. - Coverage `0d598231ca49aac1b61fef572f3bf8f7378d9bf0` exercises both `ui_translation_key` and `ui_translation_text`, covering both trigger-name orderings and requiring the same base-ledger cross-resource-move rejection. +- Blocked-rollback RED `e9eb46990fc3e70d9d06afb01f2321745c1088ed` reproduces the failed-deployment recovery case: a pre-existing operator-owned Similar VOC v1 causes `blocked`, after which the generic-layer rollback must succeed without deleting the operator resource or its child copy. +- Causal blocked-rollback repair `df9a39e98df366a6d49e3dbb8b635698e867cd2b` removes the resource-absence precondition and makes the ownership receipt the dependency boundary: unowned `pending|blocked|retired` receipts are removable, while an `owned` receipt still refuses generic-layer rollback. These commits are source-level evidence only until exact-head PostgreSQL and hosted validation run. No publication, translation approval, consumer acceptance, merge, or release is implied by this ADR. From 12e4857381551b7617242a92a518518ef5425e85 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 17:50:44 +0900 Subject: [PATCH 27/37] test(i18n): preserve retired seed receipt across rollback --- .../test_translation_seed_generic_rollback.py | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/tests/test_translation_seed_generic_rollback.py b/tests/test_translation_seed_generic_rollback.py index 10223b304..2b6138e1c 100644 --- a/tests/test_translation_seed_generic_rollback.py +++ b/tests/test_translation_seed_generic_rollback.py @@ -275,3 +275,107 @@ async def scenario() -> None: await admin_connection.close() asyncio.run(scenario()) + + +@pytest.mark.skipif( + not _postgres_available(), + reason=( + "no reachable PostgreSQL server at " + f"{_ADMIN_DSN} (set LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN)" + ), +) +def test_generic_ownership_rollback_preserves_retirement_across_reapply() -> None: + """Rolling back shared wiring must not erase a deliberate no-resurrection receipt.""" + + async def scenario() -> None: + database_name = f"lineageweave_seed_retired_rb_{uuid.uuid4().hex[:12]}" + admin_connection = await asyncpg.connect(_ADMIN_DSN) + await admin_connection.execute(f'create database "{database_name}"') + parsed_admin_dsn = urlsplit(_ADMIN_DSN) + database_dsn = urlunsplit(parsed_admin_dsn._replace(path=f"/{database_name}")) + try: + connection = await asyncpg.connect(database_dsn) + try: + for migration in ( + _INITIAL_SCHEMA, + _MEMBER_LOCALE_MIGRATION, + _LEDGER_MIGRATION, + _TRUNCATE_GUARD_MIGRATION, + _CUSTOMER_OWNERSHIP, + _GENERIC_OWNERSHIP, + _SIMILAR_SEED, + ): + await connection.execute(migration.read_text(encoding="utf-8")) + + resource_id = await connection.fetchval( + """ + select resource_id + from ui_translation_resource + where product_key = 'lineageweave' + and screen_key = 'similar-voc' + and resource_version = 1 + """ + ) + assert resource_id is not None + + await connection.execute( + "delete from ui_translation_resource where resource_id = $1", + resource_id, + ) + receipt = await connection.fetchrow( + """ + select ownership_state, resource_id + from ui_translation_seed_ownership + where migration_key = '0249_z_similar_voc_translation_draft' + """ + ) + assert receipt is not None + assert receipt["ownership_state"] == "retired" + assert receipt["resource_id"] is None + + await connection.execute( + _GENERIC_OWNERSHIP_ROLLBACK.read_text(encoding="utf-8") + ) + assert ( + await connection.fetchval( + """ + select ownership_state + from ui_translation_seed_ownership + where migration_key = '0249_z_similar_voc_translation_draft' + """ + ) + == "retired" + ) + + await connection.execute(_GENERIC_OWNERSHIP.read_text(encoding="utf-8")) + await connection.execute(_SIMILAR_SEED.read_text(encoding="utf-8")) + + assert ( + await connection.fetchval( + """ + select count(*) + from ui_translation_resource + where product_key = 'lineageweave' + and screen_key = 'similar-voc' + and resource_version = 1 + """ + ) + == 0 + ) + assert ( + await connection.fetchval( + """ + select ownership_state + from ui_translation_seed_ownership + where migration_key = '0249_z_similar_voc_translation_draft' + """ + ) + == "retired" + ) + finally: + await connection.close() + finally: + await admin_connection.execute(f'drop database "{database_name}"') + await admin_connection.close() + + asyncio.run(scenario()) From 8860128580dcb8c9455c922f3b64955148318598 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 17:51:16 +0900 Subject: [PATCH 28/37] fix(i18n): retain retired seed history across owner rollback --- ..._ui_translation_seed_ownership_generic.sql | 34 +++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/migrations/rollback/0249_ui_translation_seed_ownership_generic.sql b/migrations/rollback/0249_ui_translation_seed_ownership_generic.sql index 6b9c01d0b..c2b2edc02 100644 --- a/migrations/rollback/0249_ui_translation_seed_ownership_generic.sql +++ b/migrations/rollback/0249_ui_translation_seed_ownership_generic.sql @@ -1,36 +1,50 @@ -- Restore the Customer Master-specific seed boundary after Similar VOC v1 is gone. -- This rollback never removes a current product resource or another seed owner's --- provenance record. A blocked/pending/retired receipt owns no resource, so the --- generic layer can be removed while preserving any operator-owned Similar VOC v1. +-- provenance record. Pending/blocked reservations may be withdrawn because they +-- never owned product data; retired receipts remain durable no-resurrection history. begin; do $generic_seed_ownership_rollback$ begin delete from public.ui_translation_seed_ownership where migration_key = '0249_z_similar_voc_translation_draft' - and ownership_state in ('pending', 'blocked', 'retired') + and ownership_state in ('pending', 'blocked') and resource_id is null; - -- An owned receipt is the destructive-authority boundary. Refuse to remove - -- the generic trigger layer until its exact owned resource has first been - -- handled by the bounded Similar VOC rollback. Unowned operator copy is not - -- a dependency of this migration and must survive rollback untouched. + -- An owned receipt is the destructive-authority boundary. A valid retired + -- receipt is deliberately retained even while the generic trigger wiring is + -- removed: dropping that receipt would let a later migration reapply seed + -- historical copy that product lifecycle explicitly retired. if exists ( select 1 from public.ui_translation_seed_ownership where migration_key = '0249_z_similar_voc_translation_draft' + and ( + ownership_state <> 'retired' + or resource_id is not null + ) ) then raise exception - 'Generic UI translation seed ownership rollback refuses while Similar VOC ownership remains'; + 'Generic UI translation seed ownership rollback refuses while active Similar VOC ownership remains'; end if; + -- Other generic owners still require this shared trigger layer unless their + -- lifecycle is already retired. Retired NULL-resource receipts are historical + -- no-resurrection markers, not active trigger dependencies, and must survive. if exists ( select 1 from public.ui_translation_seed_ownership - where migration_key <> '0248_customer_master_translation_draft' + where migration_key not in ( + '0248_customer_master_translation_draft', + '0249_z_similar_voc_translation_draft' + ) + and ( + ownership_state <> 'retired' + or resource_id is not null + ) ) then raise exception - 'Generic UI translation seed ownership rollback refuses while another generic seed owner remains'; + 'Generic UI translation seed ownership rollback refuses while another active generic seed owner remains'; end if; end; $generic_seed_ownership_rollback$; From 9c35c477d9bed0acee7720d4e4737024dd7f0461 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 17:52:39 +0900 Subject: [PATCH 29/37] docs(adr): keep seed retirement durable across rollback --- .../0377-similar-voc-versioned-screen-copy.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/adr/0377-similar-voc-versioned-screen-copy.md b/docs/adr/0377-similar-voc-versioned-screen-copy.md index 124332b60..659882c6c 100644 --- a/docs/adr/0377-similar-voc-versioned-screen-copy.md +++ b/docs/adr/0377-similar-voc-versioned-screen-copy.md @@ -25,7 +25,7 @@ This ADR does not move ontology labels, VOC semantic truth, similarity adjudicat - Rollback provenance must resolve the ownership record by product/screen/version identity as well as resource id. `blocked` and `pending` reservations intentionally have `resource_id = NULL`; that must never let a rollback-labelled session delete the operator resource that caused the block. - Rollback-labelled child mutation is narrower still: only `DELETE` against the exact `owned` resource is admissible. A blocked/pending operator draft must not become mutable merely because the session carries the seed rollback migration key, and rollback must not gain insert/update authority over reviewed child copy. - Child resource identity remains owned by ADR 0362's base ledger guard. `ui_translation_key` and `ui_translation_text` rows cannot move between resources at all; seed ownership must not duplicate or weaken that invariant. -- The generic ownership migration itself has a bounded rollback. It must refuse while an exact `owned` Similar VOC receipt or another non-Customer-Master generic owner remains, but a `blocked`, `pending`, or `retired` receipt owns no resource and may be removed without deleting an operator-owned same-identity resource. The rollback must restore the pre-existing Customer Master trigger lane rather than dropping that owner boundary. +- The generic ownership migration itself has a bounded rollback. It must refuse while an exact `owned` Similar VOC receipt or another active non-Customer-Master generic owner remains. `pending` and `blocked` Similar VOC reservations own no product data and may be withdrawn during rollback, but a `retired` receipt is durable no-resurrection history and must survive removal and later reapplication of the shared trigger wiring. The rollback must restore the pre-existing Customer Master trigger lane rather than dropping that owner boundary. - Seed ownership must be reusable for future LineageWeave screen-copy candidates rather than adding another Customer-Master-specific trigger family. - Similar VOC consumer cutover remains in its existing source-owner lane; this owner PR must not become a second writer for `SimilarVocPanel`. @@ -65,7 +65,11 @@ Rejected after review. ADR 0362 already installs `guard_ui_translation_child_mut ### Require the Similar VOC resource to be absent before rolling back generic ownership wiring -Rejected after recovery review. Absence is necessary only when the seed actually owns the resource. A `blocked` receipt is explicit evidence that the same-identity resource predates the seed and is operator-owned. Requiring that resource to be deleted before generic rollback makes a failed deployment non-recoverable without destructive operator action. The safe boundary is the ownership receipt: `owned` blocks generic-layer rollback; `pending`, `blocked`, and `retired` have `resource_id = NULL` and can be retired with the generic wiring while any operator resource remains untouched. +Rejected after recovery review. Absence is necessary only when the seed actually owns the resource. A `blocked` receipt is explicit evidence that the same-identity resource predates the seed and is operator-owned. Requiring that resource to be deleted before generic rollback makes a failed deployment non-recoverable without destructive operator action. The safe boundary is the ownership receipt: `owned` blocks generic-layer rollback, while an unowned `pending` or `blocked` reservation may be withdrawn with the generic wiring and any operator resource remains untouched. + +### Delete `retired` receipts when rolling back generic ownership wiring + +Rejected after replay review. `retired` does not merely mean “currently owns no row”; it is the durable product-lifecycle fact that historical seed bytes must never regain authority after deliberate deletion. Deleting that receipt during shared-wiring rollback makes the next forward application create a fresh `pending` reservation and reseed the retired candidate. The trigger layer can therefore be removed while valid `retired`/NULL-resource receipts remain. They are historical no-resurrection markers, not active dependencies. ## Decision @@ -79,7 +83,7 @@ Cross-resource child UPDATE remains exclusively owned by the base translation-le Migration `0249_z_similar_voc_translation_draft.sql` reserves no semantic authority. It creates 23 presentation keys × 8 locales only when ownership is `pending`, verifies the exact 184-row matrix, and then stops. Publication remains a separate one-way ADR-0362 transition after independent language/product review and unchanged-head consumer acceptance. -`rollback/0249_ui_translation_seed_ownership_generic.sql` uses ownership, not mere resource existence, as the dependency boundary. It removes `pending`, `blocked`, or `retired` Similar VOC receipts because those states carry no `resource_id` and therefore own no product resource. It refuses while the Similar VOC receipt is still `owned` or while any non-Customer-Master generic seed owner remains. Once those ownership dependencies are absent, it removes only the generic trigger/functions and restores the already-existing Customer Master ownership trigger functions from migration 0247. An operator-owned `lineageweave/similar-voc/v1` resource may remain in place throughout this rollback and is neither deleted nor adopted. +`rollback/0249_ui_translation_seed_ownership_generic.sql` uses ownership state, not mere resource existence, as the dependency boundary. It removes only the Similar VOC `pending` or `blocked` reservation because those states never established product-data ownership. It refuses while Similar VOC remains actively owned, and it preserves a valid `retired`/NULL-resource receipt so generic rollback followed by reapplication cannot resurrect historical seed copy. Other generic owners block removal of the shared trigger layer unless their lifecycle is already a valid retired/NULL-resource marker. Once active ownership dependencies are absent, the rollback removes only the generic trigger/functions and restores the existing Customer Master ownership trigger functions. An operator-owned `lineageweave/similar-voc/v1` resource may remain in place throughout this rollback and is neither deleted nor adopted. ## DDD mapping @@ -89,14 +93,14 @@ Migration `0249_z_similar_voc_translation_draft.sql` reserves no semantic author - Value identities: translation key and locale-tagged presentation copy. - Repository: PostgreSQL translation ledger; Valkey remains an optional read cache under ADR 0362. - Domain service: one-time candidate-seed ownership lifecycle. -- Invariants: no implicit adoption, no reviewed-copy replay overwrite, no post-retirement resurrection, rollback limited to exact owned draft, blocked/pending roots and child rows outside destructive seed rollback authority, generic-layer rollback may preserve operator-owned conflicting copy, rollback child mutation limited to exact-owned deletion, child resource moves remain prohibited by the single ADR-0362 base guard, eight-locale completeness before any publication, ontology labels excluded. +- Invariants: no implicit adoption, no reviewed-copy replay overwrite, no post-retirement resurrection even across generic-layer rollback/reapply, rollback limited to exact owned draft, blocked/pending roots and child rows outside destructive seed rollback authority, generic-layer rollback may preserve operator-owned conflicting copy, rollback child mutation limited to exact-owned deletion, child resource moves remain prohibited by the single ADR-0362 base guard, eight-locale completeness before any publication, ontology labels excluded. - ACL: #1126 consumes only a released/published screen-copy contract; similarity adjudication and authorization continue to come from their existing owners. ## Recovery and rollout If the Similar VOC draft is wrong before publication, reviewers may edit it in place or explicitly run `rollback/0249_z_similar_voc_translation_draft.sql` and re-run the forward candidate. Ordinary product deletion means retirement, not authorization for historical reseeding. If a conflicting pre-existing `lineageweave/similar-voc/v1` resource exists, the forward seed stays blocked and never deletes or adopts that resource. -If deployment must be rolled back while that conflict remains, the generic ownership layer may still be removed because `blocked` proves the seed owns no resource. Run `rollback/0249_ui_translation_seed_ownership_generic.sql`; it deletes only the unowned Similar VOC receipt, preserves the operator resource and children, restores Customer Master ownership triggers, and removes the generic functions. If the receipt is `owned`, first remove the exact Similar VOC draft through its bounded rollback. Any other generic seed owner must also be resolved before the shared generic layer is removed. No separate child-move rollback exists because no seed-specific child-move migration is retained. +If deployment must be rolled back while that conflict remains, the generic ownership layer may still be removed because `blocked` proves the seed owns no resource. Run `rollback/0249_ui_translation_seed_ownership_generic.sql`; it deletes only the unowned pending/blocked Similar VOC reservation, preserves the operator resource and children, restores Customer Master ownership triggers, and removes the generic functions. If the receipt is `owned`, first remove the exact Similar VOC draft through its bounded rollback. If the receipt is `retired`, leave it in place: it is the no-resurrection marker that must survive a later reapply. Any other active generic seed owner must also be resolved before the shared generic layer is removed; retired NULL-resource history is not an active dependency. No separate child-move rollback exists because no seed-specific child-move migration is retained. A published bad version is not down-migrated. ADR 0362 requires a new immutable resource version and consumer routing to that reviewed version. @@ -117,6 +121,8 @@ A published bad version is not down-migrated. ADR 0362 requires a new immutable - Causal correction `8d220fbcd8d3016fd310d3a5aac1fc597d3969a9` removes the duplicate companion migration without weakening the base resource-identity invariant. - Coverage `0d598231ca49aac1b61fef572f3bf8f7378d9bf0` exercises both `ui_translation_key` and `ui_translation_text`, covering both trigger-name orderings and requiring the same base-ledger cross-resource-move rejection. - Blocked-rollback RED `e9eb46990fc3e70d9d06afb01f2321745c1088ed` reproduces the failed-deployment recovery case: a pre-existing operator-owned Similar VOC v1 causes `blocked`, after which the generic-layer rollback must succeed without deleting the operator resource or its child copy. -- Causal blocked-rollback repair `df9a39e98df366a6d49e3dbb8b635698e867cd2b` removes the resource-absence precondition and makes the ownership receipt the dependency boundary: unowned `pending|blocked|retired` receipts are removable, while an `owned` receipt still refuses generic-layer rollback. +- Causal blocked-rollback repair `df9a39e98df366a6d49e3dbb8b635698e867cd2b` removes the resource-absence precondition and makes the ownership receipt the dependency boundary: unowned `pending|blocked` reservations are removable, while an `owned` receipt still refuses generic-layer rollback. +- Retirement-replay RED `12e4857381551b7617242a92a518518ef5425e85` retires an exactly owned candidate by ordinary product deletion, rolls back the shared generic wiring, reapplies it and reruns the seed, and requires the resource to remain absent with the receipt still `retired`. +- Causal retirement preservation `8860128580dcb8c9455c922f3b64955148318598` keeps retired NULL-resource receipts across generic rollback while allowing inactive retired history to coexist with restoration of the Customer Master-specific trigger lane. These commits are source-level evidence only until exact-head PostgreSQL and hosted validation run. No publication, translation approval, consumer acceptance, merge, or release is implied by this ADR. From 7a01cfbf3e892b2a8f86a5c395ee1ba88f9c32a2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 22:20:44 +0900 Subject: [PATCH 30/37] test: pin generic seed rollback before parent ownership removal --- .../test_similar_voc_parent_rollback_order.py | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 tests/test_similar_voc_parent_rollback_order.py diff --git a/tests/test_similar_voc_parent_rollback_order.py b/tests/test_similar_voc_parent_rollback_order.py new file mode 100644 index 000000000..e1f694845 --- /dev/null +++ b/tests/test_similar_voc_parent_rollback_order.py @@ -0,0 +1,135 @@ +"""Recovery ordering contract for the generic Similar VOC seed layer.""" + +from __future__ import annotations + +import asyncio +import os +import uuid +from collections.abc import Awaitable, Callable +from pathlib import Path +from urllib.parse import urlsplit, urlunsplit + +import asyncpg +import pytest + +ROOT = Path(__file__).resolve().parents[1] +_ADMIN_DSN = os.environ.get( + "LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN", "postgresql://localhost/postgres" +) +_INITIAL_SCHEMA = ROOT / "migrations" / "0001_initial_schema.sql" +_MEMBER_LOCALE_MIGRATION = ROOT / "migrations" / "0044_member_locale_preference.sql" +_LEDGER_MIGRATION = ROOT / "migrations" / "0246_ui_translation_ledger.sql" +_TRUNCATE_GUARD_MIGRATION = ROOT / "migrations" / "0247_ui_translation_truncate_guard.sql" +_SEED_OWNERSHIP_MIGRATION = ( + ROOT / "migrations" / "0247_z_customer_master_translation_seed_ownership.sql" +) +_OWNERSHIP_TRUNCATE_GUARD_MIGRATION = ( + ROOT / "migrations" / "0247_za_ui_translation_seed_ownership_truncate_guard.sql" +) +_CUSTOMER_MASTER_REPLAY_GUARD_MIGRATION = ( + ROOT / "migrations" / "0247_zz_customer_master_translation_seed_replay_guard.sql" +) +_GENERIC_SEED_OWNERSHIP = ROOT / "migrations" / "0249_ui_translation_seed_ownership_generic.sql" +_SIMILAR_VOC_SEED = ROOT / "migrations" / "0249_z_similar_voc_translation_draft.sql" +_SIMILAR_VOC_ROLLBACK = ( + ROOT / "migrations" / "rollback" / "0249_z_similar_voc_translation_draft.sql" +) +_PARENT_OWNERSHIP_ROLLBACK = ( + ROOT / "migrations" / "rollback" / "0247_z_customer_master_translation_seed_ownership.sql" +) + + +async def _postgres_available_async() -> bool: + """Return whether the configured PostgreSQL admin endpoint is reachable.""" + try: + connection = await asyncpg.connect(_ADMIN_DSN, timeout=2) + except (asyncpg.PostgresError, OSError, TimeoutError): + return False + await connection.close() + return True + + +def _postgres_available() -> bool: + """Probe PostgreSQL once during collection without adding a sync driver.""" + return asyncio.run(_postgres_available_async()) + + +pytestmark = pytest.mark.skipif( + not _postgres_available(), + reason=( + "no reachable PostgreSQL server at " + f"{_ADMIN_DSN} (set LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN)" + ), +) + + +async def _run_in_database( + scenario: Callable[[asyncpg.Connection], Awaitable[None]], +) -> None: + """Run one rollback-order scenario in an isolated PostgreSQL database.""" + database_name = f"lineageweave_similar_voc_rollback_{uuid.uuid4().hex[:12]}" + admin_connection = await asyncpg.connect(_ADMIN_DSN) + await admin_connection.execute(f'create database "{database_name}"') + parsed_admin_dsn = urlsplit(_ADMIN_DSN) + database_dsn = urlunsplit(parsed_admin_dsn._replace(path=f"/{database_name}")) + try: + connection = await asyncpg.connect(database_dsn) + try: + for migration in ( + _INITIAL_SCHEMA, + _MEMBER_LOCALE_MIGRATION, + _LEDGER_MIGRATION, + _TRUNCATE_GUARD_MIGRATION, + _SEED_OWNERSHIP_MIGRATION, + _OWNERSHIP_TRUNCATE_GUARD_MIGRATION, + _CUSTOMER_MASTER_REPLAY_GUARD_MIGRATION, + ): + await connection.execute(migration.read_text(encoding="utf-8")) + await scenario(connection) + finally: + await connection.close() + finally: + await admin_connection.execute(f'drop database "{database_name}"') + await admin_connection.close() + + +def test_parent_ownership_rollback_refuses_live_generic_seed_layer() -> None: + """A base rollback cannot strand descendant generic triggers without their table.""" + + async def scenario(connection: asyncpg.Connection) -> None: + await connection.execute(_GENERIC_SEED_OWNERSHIP.read_text(encoding="utf-8")) + await connection.execute(_SIMILAR_VOC_SEED.read_text(encoding="utf-8")) + await connection.execute(_SIMILAR_VOC_ROLLBACK.read_text(encoding="utf-8")) + + assert ( + await connection.fetchval( + """ + select count(*) + from public.ui_translation_seed_ownership + where migration_key = '0249_z_similar_voc_translation_draft' + """ + ) + == 0 + ) + + with pytest.raises( + asyncpg.PostgresError, + match="generic UI translation seed layer remains", + ): + await connection.execute(_PARENT_OWNERSHIP_ROLLBACK.read_text(encoding="utf-8")) + await connection.execute("rollback") + + assert ( + await connection.fetchval( + "select to_regclass('public.ui_translation_seed_ownership') is not null" + ) + is True + ) + assert ( + await connection.fetchval( + "select to_regprocedure('public.guard_ui_translation_seed_resource_ownership()') is not null" + ) + is True + ) + + asyncio.run(_run_in_database(scenario)) From 200110f5a2677ba59c63be32867ef242cfd7e0f9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 22:21:06 +0900 Subject: [PATCH 31/37] fix: fail closed on parent rollback with generic seed layer --- ...7_z_customer_master_translation_seed_ownership.sql | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/migrations/rollback/0247_z_customer_master_translation_seed_ownership.sql b/migrations/rollback/0247_z_customer_master_translation_seed_ownership.sql index 11fd74227..e13ee8998 100644 --- a/migrations/rollback/0247_z_customer_master_translation_seed_ownership.sql +++ b/migrations/rollback/0247_z_customer_master_translation_seed_ownership.sql @@ -3,6 +3,17 @@ begin; do $customer_master_seed_ownership_rollback$ begin + -- Descendant generic ownership wiring depends on this table even when its + -- current seed receipts have already been removed. Rolling back the base + -- ownership table first would strand live generic triggers whose functions + -- query a relation that no longer exists. Require reverse-order rollback. + if to_regprocedure('public.guard_ui_translation_seed_resource_ownership()') is not null + or to_regprocedure('public.bind_ui_translation_seed_resource_ownership()') is not null + or to_regprocedure('public.guard_ui_translation_seed_child_ownership()') is not null then + raise exception + 'Customer Master seed ownership rollback refuses while generic UI translation seed layer remains; roll back migration 0249 first'; + end if; + if exists ( select 1 from ui_translation_resource From 2c2d20f9ec28eb39f2448b7e7b7159859da94ed4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 22:24:01 +0900 Subject: [PATCH 32/37] docs: bind generic rollback order to base ownership lifecycle --- .../adr/0377-similar-voc-versioned-screen-copy.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/adr/0377-similar-voc-versioned-screen-copy.md b/docs/adr/0377-similar-voc-versioned-screen-copy.md index 659882c6c..176ccb8ac 100644 --- a/docs/adr/0377-similar-voc-versioned-screen-copy.md +++ b/docs/adr/0377-similar-voc-versioned-screen-copy.md @@ -26,6 +26,7 @@ This ADR does not move ontology labels, VOC semantic truth, similarity adjudicat - Rollback-labelled child mutation is narrower still: only `DELETE` against the exact `owned` resource is admissible. A blocked/pending operator draft must not become mutable merely because the session carries the seed rollback migration key, and rollback must not gain insert/update authority over reviewed child copy. - Child resource identity remains owned by ADR 0362's base ledger guard. `ui_translation_key` and `ui_translation_text` rows cannot move between resources at all; seed ownership must not duplicate or weaken that invariant. - The generic ownership migration itself has a bounded rollback. It must refuse while an exact `owned` Similar VOC receipt or another active non-Customer-Master generic owner remains. `pending` and `blocked` Similar VOC reservations own no product data and may be withdrawn during rollback, but a `retired` receipt is durable no-resurrection history and must survive removal and later reapplication of the shared trigger wiring. The rollback must restore the pre-existing Customer Master trigger lane rather than dropping that owner boundary. +- The base Customer Master ownership rollback must also refuse while the generic ownership functions are installed, even if all descendant ownership rows have already been removed. Otherwise it can drop `ui_translation_seed_ownership` underneath live generic triggers and leave future ledger writes calling functions whose required relation no longer exists. Recovery therefore follows strict reverse dependency order: Similar VOC draft rollback when needed, generic ownership rollback, then base ownership rollback. - Seed ownership must be reusable for future LineageWeave screen-copy candidates rather than adding another Customer-Master-specific trigger family. - Similar VOC consumer cutover remains in its existing source-owner lane; this owner PR must not become a second writer for `SimilarVocPanel`. @@ -71,6 +72,10 @@ Rejected after recovery review. Absence is necessary only when the seed actually Rejected after replay review. `retired` does not merely mean “currently owns no row”; it is the durable product-lifecycle fact that historical seed bytes must never regain authority after deliberate deletion. Deleting that receipt during shared-wiring rollback makes the next forward application create a fresh `pending` reservation and reseed the retired candidate. The trigger layer can therefore be removed while valid `retired`/NULL-resource receipts remain. They are historical no-resurrection markers, not active dependencies. +### Let the base ownership rollback infer descendant removal from an empty ownership table + +Rejected after recovery-order review. Exact Similar VOC rollback deliberately removes its owned resource and lets the corresponding ownership row cascade away, while the generic trigger/functions remain installed until `rollback/0249_ui_translation_seed_ownership_generic.sql` runs. At that point an empty table does not prove the descendant layer is gone. Dropping the base table on that signal strands generic trigger functions that query `public.ui_translation_seed_ownership`. The installed generic functions are therefore an explicit reverse-order dependency and base rollback fails closed until the generic layer is removed. + ## Decision Migration `0249_ui_translation_seed_ownership_generic.sql` replaces the active Customer-Master-specific ownership trigger wiring with generic trigger functions over `ui_translation_seed_ownership`. The lifecycle is selected by the ownership row's `(migration_key, product_key, screen_key, resource_version)` identity and the trusted `lineageweave.migration_file` execution context. @@ -85,6 +90,8 @@ Migration `0249_z_similar_voc_translation_draft.sql` reserves no semantic author `rollback/0249_ui_translation_seed_ownership_generic.sql` uses ownership state, not mere resource existence, as the dependency boundary. It removes only the Similar VOC `pending` or `blocked` reservation because those states never established product-data ownership. It refuses while Similar VOC remains actively owned, and it preserves a valid `retired`/NULL-resource receipt so generic rollback followed by reapplication cannot resurrect historical seed copy. Other generic owners block removal of the shared trigger layer unless their lifecycle is already a valid retired/NULL-resource marker. Once active ownership dependencies are absent, the rollback removes only the generic trigger/functions and restores the existing Customer Master ownership trigger functions. An operator-owned `lineageweave/similar-voc/v1` resource may remain in place throughout this rollback and is neither deleted nor adopted. +The descendant migration also extends `rollback/0247_z_customer_master_translation_seed_ownership.sql` with a composition guard. Presence of any generic ownership function means the descendant layer is still installed, regardless of whether current generic ownership rows are empty. Base rollback refuses in that state. After `rollback/0249_ui_translation_seed_ownership_generic.sql` restores the Customer Master trigger lane and removes the generic functions, the base rollback may evaluate its own resource, retirement, and ownership-table preconditions normally. This is a recovery-order integration guard, not a transfer of generic lifecycle ownership into ADR 0362. + ## DDD mapping - Subdomain: product composition / presentation read model. @@ -93,7 +100,7 @@ Migration `0249_z_similar_voc_translation_draft.sql` reserves no semantic author - Value identities: translation key and locale-tagged presentation copy. - Repository: PostgreSQL translation ledger; Valkey remains an optional read cache under ADR 0362. - Domain service: one-time candidate-seed ownership lifecycle. -- Invariants: no implicit adoption, no reviewed-copy replay overwrite, no post-retirement resurrection even across generic-layer rollback/reapply, rollback limited to exact owned draft, blocked/pending roots and child rows outside destructive seed rollback authority, generic-layer rollback may preserve operator-owned conflicting copy, rollback child mutation limited to exact-owned deletion, child resource moves remain prohibited by the single ADR-0362 base guard, eight-locale completeness before any publication, ontology labels excluded. +- Invariants: no implicit adoption, no reviewed-copy replay overwrite, no post-retirement resurrection even across generic-layer rollback/reapply, rollback limited to exact owned draft, blocked/pending roots and child rows outside destructive seed rollback authority, generic-layer rollback may preserve operator-owned conflicting copy, rollback child mutation limited to exact-owned deletion, child resource moves remain prohibited by the single ADR-0362 base guard, descendant generic wiring must be removed before base ownership-table rollback, eight-locale completeness before any publication, ontology labels excluded. - ACL: #1126 consumes only a released/published screen-copy contract; similarity adjudication and authorization continue to come from their existing owners. ## Recovery and rollout @@ -102,6 +109,8 @@ If the Similar VOC draft is wrong before publication, reviewers may edit it in p If deployment must be rolled back while that conflict remains, the generic ownership layer may still be removed because `blocked` proves the seed owns no resource. Run `rollback/0249_ui_translation_seed_ownership_generic.sql`; it deletes only the unowned pending/blocked Similar VOC reservation, preserves the operator resource and children, restores Customer Master ownership triggers, and removes the generic functions. If the receipt is `owned`, first remove the exact Similar VOC draft through its bounded rollback. If the receipt is `retired`, leave it in place: it is the no-resurrection marker that must survive a later reapply. Any other active generic seed owner must also be resolved before the shared generic layer is removed; retired NULL-resource history is not an active dependency. No separate child-move rollback exists because no seed-specific child-move migration is retained. +Only after the generic ownership rollback has removed the generic functions may `rollback/0247_z_customer_master_translation_seed_ownership.sql` remove the base ownership table. If that base rollback is attempted first, it now refuses before deleting any base reservation or dropping the table. This keeps reverse-order recovery fail closed even in the edge case where exact Similar VOC rollback already removed the descendant ownership row. + A published bad version is not down-migrated. ADR 0362 requires a new immutable resource version and consumer routing to that reviewed version. ## Evidence @@ -124,5 +133,7 @@ A published bad version is not down-migrated. ADR 0362 requires a new immutable - Causal blocked-rollback repair `df9a39e98df366a6d49e3dbb8b635698e867cd2b` removes the resource-absence precondition and makes the ownership receipt the dependency boundary: unowned `pending|blocked` reservations are removable, while an `owned` receipt still refuses generic-layer rollback. - Retirement-replay RED `12e4857381551b7617242a92a518518ef5425e85` retires an exactly owned candidate by ordinary product deletion, rolls back the shared generic wiring, reapplies it and reruns the seed, and requires the resource to remain absent with the receipt still `retired`. - Causal retirement preservation `8860128580dcb8c9455c922f3b64955148318598` keeps retired NULL-resource receipts across generic rollback while allowing inactive retired history to coexist with restoration of the Customer Master-specific trigger lane. +- Recovery-order RED `7a01cfbf3e892b2a8f86a5c395ee1ba88f9c32a2` materializes and exactly rolls back the Similar VOC draft while leaving generic trigger wiring installed, then requires the base Customer Master ownership rollback to refuse rather than drop `ui_translation_seed_ownership` underneath live descendant functions. +- Causal recovery-order repair `200110f5a2677ba59c63be32867ef242cfd7e0f9` makes the base ownership rollback fail closed while any generic ownership function remains installed; normal reverse-order recovery succeeds after the generic rollback removes those functions. -These commits are source-level evidence only until exact-head PostgreSQL and hosted validation run. No publication, translation approval, consumer acceptance, merge, or release is implied by this ADR. +These commits are source-level evidence only until exact-head PostgreSQL and hosted validation run. No publication, translation approval, consumer acceptance, merge, or release is implied by this ADR. \ No newline at end of file From 220bda0f92f09d1b230ce200371f59a605d63b15 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 22:27:47 +0900 Subject: [PATCH 33/37] test: require Similar VOC fixture to replay parent guards --- ..._similar_voc_translation_fixture_contract.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 tests/test_similar_voc_translation_fixture_contract.py diff --git a/tests/test_similar_voc_translation_fixture_contract.py b/tests/test_similar_voc_translation_fixture_contract.py new file mode 100644 index 000000000..1b41ed6f9 --- /dev/null +++ b/tests/test_similar_voc_translation_fixture_contract.py @@ -0,0 +1,17 @@ +"""Structural contract keeping Similar VOC PostgreSQL evidence on the parent migration path.""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +_FIXTURE = ROOT / "tests" / "test_similar_voc_translation_seed.py" + + +def test_similar_voc_fixture_includes_current_parent_guard_chain() -> None: + """The seed scenarios must include every parent guard added before migration 0249.""" + source = _FIXTURE.read_text(encoding="utf-8") + start = source.index("async def _apply_base") + end = source.index("async def _run_in_database", start) + apply_base = source[start:end] + + assert "_OWNERSHIP_TRUNCATE_GUARD_MIGRATION" in apply_base + assert "_CUSTOMER_MASTER_REPLAY_GUARD_MIGRATION" in apply_base From 4ae8b1d364deb2ac2fb04d7e1ada23b2e80eeece Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 22:28:50 +0900 Subject: [PATCH 34/37] test: replay current parent guards in Similar VOC fixtures --- tests/test_similar_voc_translation_seed.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_similar_voc_translation_seed.py b/tests/test_similar_voc_translation_seed.py index 83bd3765d..cafd6460a 100644 --- a/tests/test_similar_voc_translation_seed.py +++ b/tests/test_similar_voc_translation_seed.py @@ -24,6 +24,12 @@ _EXISTING_SEED_OWNERSHIP = ( ROOT / "migrations" / "0247_z_customer_master_translation_seed_ownership.sql" ) +_OWNERSHIP_TRUNCATE_GUARD_MIGRATION = ( + ROOT / "migrations" / "0247_za_ui_translation_seed_ownership_truncate_guard.sql" +) +_CUSTOMER_MASTER_REPLAY_GUARD_MIGRATION = ( + ROOT / "migrations" / "0247_zz_customer_master_translation_seed_replay_guard.sql" +) _GENERIC_SEED_OWNERSHIP = ( ROOT / "migrations" / "0249_ui_translation_seed_ownership_generic.sql" ) @@ -92,6 +98,8 @@ async def _apply_base(connection: asyncpg.Connection) -> None: _LEDGER_MIGRATION, _TRUNCATE_GUARD_MIGRATION, _EXISTING_SEED_OWNERSHIP, + _OWNERSHIP_TRUNCATE_GUARD_MIGRATION, + _CUSTOMER_MASTER_REPLAY_GUARD_MIGRATION, ): await connection.execute(migration.read_text(encoding="utf-8")) From 718dcd793cc6da2be0c836217bd9c4eab27a4f8e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 23:00:34 +0900 Subject: [PATCH 35/37] test(i18n): expose Similar VOC replay delete deadlock --- ...nslation_seed_delete_replay_concurrency.py | 189 ++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 tests/test_similar_voc_translation_seed_delete_replay_concurrency.py diff --git a/tests/test_similar_voc_translation_seed_delete_replay_concurrency.py b/tests/test_similar_voc_translation_seed_delete_replay_concurrency.py new file mode 100644 index 000000000..6a92f1b5d --- /dev/null +++ b/tests/test_similar_voc_translation_seed_delete_replay_concurrency.py @@ -0,0 +1,189 @@ +"""Concurrency regression for Similar VOC seed replay versus ordinary deletion.""" + +from __future__ import annotations + +import asyncio +import os +import uuid +from contextlib import suppress +from pathlib import Path +from urllib.parse import urlsplit, urlunsplit + +import asyncpg +import pytest + +ROOT = Path(__file__).resolve().parents[1] +_ADMIN_DSN = os.environ.get( + "LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN", "postgresql://localhost/postgres" +) +_BASE_MIGRATIONS = ( + ROOT / "migrations" / "0001_initial_schema.sql", + ROOT / "migrations" / "0044_member_locale_preference.sql", + ROOT / "migrations" / "0246_ui_translation_ledger.sql", + ROOT / "migrations" / "0247_ui_translation_truncate_guard.sql", + ROOT / "migrations" / "0247_z_customer_master_translation_seed_ownership.sql", + ROOT / "migrations" / "0247_za_ui_translation_seed_ownership_truncate_guard.sql", + ROOT / "migrations" / "0247_zz_customer_master_translation_seed_replay_guard.sql", + ROOT / "migrations" / "0249_ui_translation_seed_ownership_generic.sql", +) +_SIMILAR_VOC_SEED = ROOT / "migrations" / "0249_z_similar_voc_translation_draft.sql" + + +async def _postgres_available_async() -> bool: + try: + connection = await asyncpg.connect(_ADMIN_DSN, timeout=2) + except (asyncpg.PostgresError, OSError, TimeoutError): + return False + await connection.close() + return True + + +def _postgres_available() -> bool: + return asyncio.run(_postgres_available_async()) + + +async def _wait_until_lock_wait( + observer: asyncpg.Connection, contender_pid: int +) -> None: + """Wait until a backend is blocked on a PostgreSQL lock.""" + for _ in range(200): + waiting = await observer.fetchval( + """ + select wait_event_type = 'Lock' + from pg_stat_activity + where pid = $1 + """, + contender_pid, + ) + if waiting: + return + await asyncio.sleep(0.01) + pytest.fail(f"backend {contender_pid} never reached the expected lock wait") + + +@pytest.mark.skipif( + not _postgres_available(), + reason=( + "no reachable PostgreSQL server at " + f"{_ADMIN_DSN} (set LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN)" + ), +) +def test_similar_voc_seed_replay_does_not_deadlock_with_owned_root_delete() -> None: + """Seed replay and normal root retirement must share one ownership lock order.""" + + async def scenario() -> None: + database_name = f"lineageweave_similar_delete_race_{uuid.uuid4().hex[:12]}" + admin = await asyncpg.connect(_ADMIN_DSN) + await admin.execute(f'create database "{database_name}"') + parsed_admin_dsn = urlsplit(_ADMIN_DSN) + database_dsn = urlunsplit(parsed_admin_dsn._replace(path=f"/{database_name}")) + + setup: asyncpg.Connection | None = None + ownership_holder: asyncpg.Connection | None = None + replay: asyncpg.Connection | None = None + deleter: asyncpg.Connection | None = None + observer: asyncpg.Connection | None = None + replay_task: asyncio.Task[str] | None = None + delete_task: asyncio.Task[None] | None = None + try: + setup = await asyncpg.connect(database_dsn) + for migration in _BASE_MIGRATIONS: + await setup.execute(migration.read_text(encoding="utf-8")) + await setup.execute(_SIMILAR_VOC_SEED.read_text(encoding="utf-8")) + + resource_id = await setup.fetchval( + """ + select resource_id + from public.ui_translation_resource + where product_key = 'lineageweave' + and screen_key = 'similar-voc' + and resource_version = 1 + """ + ) + assert resource_id is not None + assert ( + await setup.fetchval( + """ + select ownership_state + from public.ui_translation_seed_ownership + where migration_key = '0249_z_similar_voc_translation_draft' + """ + ) + == "owned" + ) + + ownership_holder = await asyncpg.connect(database_dsn) + replay = await asyncpg.connect(database_dsn) + deleter = await asyncpg.connect(database_dsn) + observer = await asyncpg.connect(database_dsn) + + await ownership_holder.execute("begin") + await ownership_holder.fetchval( + """ + select 1 + from public.ui_translation_seed_ownership + where migration_key = '0249_z_similar_voc_translation_draft' + for update + """ + ) + + seed_sql = _SIMILAR_VOC_SEED.read_text(encoding="utf-8") + replay_task = asyncio.create_task(replay.execute(seed_sql)) + await _wait_until_lock_wait(observer, replay.get_server_pid()) + + async def delete_owned_root() -> None: + assert deleter is not None + await deleter.execute("begin") + try: + await deleter.execute( + "delete from public.ui_translation_resource where resource_id = $1", + resource_id, + ) + except Exception: + await deleter.execute("rollback") + raise + else: + await deleter.execute("commit") + + delete_task = asyncio.create_task(delete_owned_root()) + await _wait_until_lock_wait(observer, deleter.get_server_pid()) + + await ownership_holder.execute("commit") + results = await asyncio.wait_for( + asyncio.gather(replay_task, delete_task, return_exceptions=True), + timeout=5, + ) + assert all(not isinstance(result, Exception) for result in results), results + + assert ( + await setup.fetchval( + "select count(*) from public.ui_translation_resource where resource_id = $1", + resource_id, + ) + == 0 + ) + receipt = await setup.fetchrow( + """ + select ownership_state, resource_id + from public.ui_translation_seed_ownership + where migration_key = '0249_z_similar_voc_translation_draft' + """ + ) + assert receipt is not None + assert receipt["ownership_state"] == "retired" + assert receipt["resource_id"] is None + finally: + for task in (delete_task, replay_task): + if task is not None and not task.done(): + task.cancel() + with suppress(asyncio.CancelledError, asyncpg.PostgresError): + await task + for connection in (observer, deleter, replay, ownership_holder, setup): + if connection is not None and not connection.is_closed(): + with suppress(asyncpg.PostgresError): + await connection.execute("rollback") + await connection.close() + await admin.execute(f'drop database "{database_name}"') + await admin.close() + + asyncio.run(scenario()) From 2c350be6fe3fd702b0813feb947e05a06fe577ec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 23:01:54 +0900 Subject: [PATCH 36/37] fix(i18n): remove Similar VOC replay root lock inversion --- migrations/0249_z_similar_voc_translation_draft.sql | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/migrations/0249_z_similar_voc_translation_draft.sql b/migrations/0249_z_similar_voc_translation_draft.sql index 350e43388..eb60e8f5b 100644 --- a/migrations/0249_z_similar_voc_translation_draft.sql +++ b/migrations/0249_z_similar_voc_translation_draft.sql @@ -81,13 +81,16 @@ begin raise exception 'Similar VOC translation seed refuses to adopt an existing unowned resource'; end if; + -- The ownership row serializes replay with root lifecycle mutation. A plain + -- MVCC read is enough to validate the resource identity while ownership is + -- held; row-locking the root here would invert ordinary DELETE's + -- root -> ownership order and can deadlock. select resource_id into target_resource_id from public.ui_translation_resource where product_key = 'lineageweave' and screen_key = 'similar-voc' - and resource_version = 1 - for update; + and resource_version = 1; if owner_state = 'owned' then if target_resource_id is distinct from owner_resource_id then From bfb4c8780e05fb53b78d96cf6762ea4fe1b6f462 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 23:08:05 +0900 Subject: [PATCH 37/37] docs(adr): record Similar VOC replay lock order --- docs/adr/0377-similar-voc-versioned-screen-copy.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/adr/0377-similar-voc-versioned-screen-copy.md b/docs/adr/0377-similar-voc-versioned-screen-copy.md index 176ccb8ac..6df4ba979 100644 --- a/docs/adr/0377-similar-voc-versioned-screen-copy.md +++ b/docs/adr/0377-similar-voc-versioned-screen-copy.md @@ -27,6 +27,7 @@ This ADR does not move ontology labels, VOC semantic truth, similarity adjudicat - Child resource identity remains owned by ADR 0362's base ledger guard. `ui_translation_key` and `ui_translation_text` rows cannot move between resources at all; seed ownership must not duplicate or weaken that invariant. - The generic ownership migration itself has a bounded rollback. It must refuse while an exact `owned` Similar VOC receipt or another active non-Customer-Master generic owner remains. `pending` and `blocked` Similar VOC reservations own no product data and may be withdrawn during rollback, but a `retired` receipt is durable no-resurrection history and must survive removal and later reapplication of the shared trigger wiring. The rollback must restore the pre-existing Customer Master trigger lane rather than dropping that owner boundary. - The base Customer Master ownership rollback must also refuse while the generic ownership functions are installed, even if all descendant ownership rows have already been removed. Otherwise it can drop `ui_translation_seed_ownership` underneath live generic triggers and leave future ledger writes calling functions whose required relation no longer exists. Recovery therefore follows strict reverse dependency order: Similar VOC draft rollback when needed, generic ownership rollback, then base ownership rollback. +- Seed replay must not hold the ownership receipt and then row-lock the translation root. Ordinary root deletion acquires the root row before its BEFORE DELETE ownership-retirement path; an `ownership -> root` replay edge would invert that path. The ownership receipt is the seed-lifecycle serialization point, and root identity validation while it is held is a plain MVCC read. - Seed ownership must be reusable for future LineageWeave screen-copy candidates rather than adding another Customer-Master-specific trigger family. - Similar VOC consumer cutover remains in its existing source-owner lane; this owner PR must not become a second writer for `SimilarVocPanel`. @@ -76,6 +77,10 @@ Rejected after replay review. `retired` does not merely mean “currently owns n Rejected after recovery-order review. Exact Similar VOC rollback deliberately removes its owned resource and lets the corresponding ownership row cascade away, while the generic trigger/functions remain installed until `rollback/0249_ui_translation_seed_ownership_generic.sql` runs. At that point an empty table does not prove the descendant layer is gone. Dropping the base table on that signal strands generic trigger functions that query `public.ui_translation_seed_ownership`. The installed generic functions are therefore an explicit reverse-order dependency and base rollback fails closed until the generic layer is removed. +### Lock the translation root after locking seed ownership during replay + +Rejected after concurrency review. Ordinary root deletion obtains the root row lock before the BEFORE DELETE trigger retires the ownership receipt. If replay first locks ownership and then requests `FOR UPDATE` on the same root, replay and deletion can form a cycle. Holding the ownership receipt is sufficient to serialize exact-owned retirement: the delete cannot commit the ownership transition until replay releases that receipt. Root identity validation therefore remains a non-locking MVCC read while ownership is held. + ## Decision Migration `0249_ui_translation_seed_ownership_generic.sql` replaces the active Customer-Master-specific ownership trigger wiring with generic trigger functions over `ui_translation_seed_ownership`. The lifecycle is selected by the ownership row's `(migration_key, product_key, screen_key, resource_version)` identity and the trusted `lineageweave.migration_file` execution context. @@ -86,7 +91,7 @@ Ordinary deletion of an exact `owned` candidate first changes the receipt to `re Cross-resource child UPDATE remains exclusively owned by the base translation-ledger guard from migration 0246. The generic seed-ownership layer does not redefine that rule. This keeps the DDD boundary single-writer: ADR 0362 owns resource/key/text identity and immutability, while ADR 0377 owns only one-time seed provenance and recovery for the Similar VOC presentation-copy candidate. -Migration `0249_z_similar_voc_translation_draft.sql` reserves no semantic authority. It creates 23 presentation keys × 8 locales only when ownership is `pending`, verifies the exact 184-row matrix, and then stops. Publication remains a separate one-way ADR-0362 transition after independent language/product review and unchanged-head consumer acceptance. +Migration `0249_z_similar_voc_translation_draft.sql` reserves no semantic authority. It creates 23 presentation keys × 8 locales only when ownership is `pending`, verifies the exact 184-row matrix, and then stops. Replay locks the ownership receipt but does not row-lock an existing translation root. The ownership receipt serializes exact-owned retirement, while the root lookup is a plain MVCC identity check; this avoids the inverse lock edge against ordinary `DELETE` without granting replay any additional product-data authority. Publication remains a separate one-way ADR-0362 transition after independent language/product review and unchanged-head consumer acceptance. `rollback/0249_ui_translation_seed_ownership_generic.sql` uses ownership state, not mere resource existence, as the dependency boundary. It removes only the Similar VOC `pending` or `blocked` reservation because those states never established product-data ownership. It refuses while Similar VOC remains actively owned, and it preserves a valid `retired`/NULL-resource receipt so generic rollback followed by reapplication cannot resurrect historical seed copy. Other generic owners block removal of the shared trigger layer unless their lifecycle is already a valid retired/NULL-resource marker. Once active ownership dependencies are absent, the rollback removes only the generic trigger/functions and restores the existing Customer Master ownership trigger functions. An operator-owned `lineageweave/similar-voc/v1` resource may remain in place throughout this rollback and is neither deleted nor adopted. @@ -100,7 +105,7 @@ The descendant migration also extends `rollback/0247_z_customer_master_translati - Value identities: translation key and locale-tagged presentation copy. - Repository: PostgreSQL translation ledger; Valkey remains an optional read cache under ADR 0362. - Domain service: one-time candidate-seed ownership lifecycle. -- Invariants: no implicit adoption, no reviewed-copy replay overwrite, no post-retirement resurrection even across generic-layer rollback/reapply, rollback limited to exact owned draft, blocked/pending roots and child rows outside destructive seed rollback authority, generic-layer rollback may preserve operator-owned conflicting copy, rollback child mutation limited to exact-owned deletion, child resource moves remain prohibited by the single ADR-0362 base guard, descendant generic wiring must be removed before base ownership-table rollback, eight-locale completeness before any publication, ontology labels excluded. +- Invariants: no implicit adoption, no reviewed-copy replay overwrite, no post-retirement resurrection even across generic-layer rollback/reapply, rollback limited to exact owned draft, blocked/pending roots and child rows outside destructive seed rollback authority, generic-layer rollback may preserve operator-owned conflicting copy, rollback child mutation limited to exact-owned deletion, child resource moves remain prohibited by the single ADR-0362 base guard, descendant generic wiring must be removed before base ownership-table rollback, seed replay never introduces an ownership-to-root row-lock edge against ordinary deletion, eight-locale completeness before any publication, ontology labels excluded. - ACL: #1126 consumes only a released/published screen-copy contract; similarity adjudication and authorization continue to come from their existing owners. ## Recovery and rollout @@ -135,5 +140,8 @@ A published bad version is not down-migrated. ADR 0362 requires a new immutable - Causal retirement preservation `8860128580dcb8c9455c922f3b64955148318598` keeps retired NULL-resource receipts across generic rollback while allowing inactive retired history to coexist with restoration of the Customer Master-specific trigger lane. - Recovery-order RED `7a01cfbf3e892b2a8f86a5c395ee1ba88f9c32a2` materializes and exactly rolls back the Similar VOC draft while leaving generic trigger wiring installed, then requires the base Customer Master ownership rollback to refuse rather than drop `ui_translation_seed_ownership` underneath live descendant functions. - Causal recovery-order repair `200110f5a2677ba59c63be32867ef242cfd7e0f9` makes the base ownership rollback fail closed while any generic ownership function remains installed; normal reverse-order recovery succeeds after the generic rollback removes those functions. +- Parent lock-order RED `b3df3853f38a7c5019dac0994c9dddcd80ad0750` and repair `8153ece6be2a7671cef507a04c7da08145881826` remove the same ownership-replay/root-delete inversion from canonical Customer Master seed ownership before this child converges on that parent. +- Similar VOC lock-order RED `718dcd793cc6da2be0c836217bd9c4eab27a4f8e` stages an exact-owned seed replay and ordinary root retirement behind the same ownership holder; the predecessor can deadlock when replay holds ownership and then requests the root row held by DELETE. +- Causal Similar VOC lock-order repair `2c350be6fe3fd702b0813feb947e05a06fe577ec` removes the replay-side root row lock and retains the ownership receipt as the single lifecycle serialization point. -These commits are source-level evidence only until exact-head PostgreSQL and hosted validation run. No publication, translation approval, consumer acceptance, merge, or release is implied by this ADR. \ No newline at end of file +These commits are source-level evidence only until exact-head PostgreSQL and hosted validation run. No publication, translation approval, consumer acceptance, merge, or release is implied by this ADR.