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..6df4ba979 --- /dev/null +++ b/docs/adr/0377-similar-voc-versioned-screen-copy.md @@ -0,0 +1,147 @@ +# 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. +- 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 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`. + +## 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. + +### 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. + +### 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. + +### 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. 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, 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. + +### 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. + +### 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. + +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 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. + +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. 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. + +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. +- 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 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 + +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 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 + +- 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. +- 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` 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` 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. +- 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. 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..2ba28d780 --- /dev/null +++ b/migrations/0249_ui_translation_seed_ownership_generic.sql @@ -0,0 +1,329 @@ +-- 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; + + -- 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 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 + 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 = '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; +$$; + +-- 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; 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..eb60e8f5b --- /dev/null +++ b/migrations/0249_z_similar_voc_translation_draft.sql @@ -0,0 +1,181 @@ +-- 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; + + -- 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; + + 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; 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 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..c2b2edc02 --- /dev/null +++ b/migrations/rollback/0249_ui_translation_seed_ownership_generic.sql @@ -0,0 +1,89 @@ +-- 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. 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') + and resource_id is null; + + -- 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 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 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 active 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; 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; 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)) 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 diff --git a/tests/test_similar_voc_translation_seed.py b/tests/test_similar_voc_translation_seed.py new file mode 100644 index 000000000..cafd6460a --- /dev/null +++ b/tests/test_similar_voc_translation_seed.py @@ -0,0 +1,359 @@ +"""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" +) +_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" +) +_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, + _OWNERSHIP_TRUNCATE_GUARD_MIGRATION, + _CUSTOMER_MASTER_REPLAY_GUARD_MIGRATION, + ): + 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)) 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()) diff --git a/tests/test_translation_seed_generic_rollback.py b/tests/test_translation_seed_generic_rollback.py new file mode 100644 index 000000000..2b6138e1c --- /dev/null +++ b/tests/test_translation_seed_generic_rollback.py @@ -0,0 +1,381 @@ +"""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()) + + +@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()) + + +@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()) 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..b244d327a --- /dev/null +++ b/tests/test_translation_seed_generic_rollback_guard.py @@ -0,0 +1,217 @@ +"""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()) + + +@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()) 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..9d386bf3f --- /dev/null +++ b/tests/test_translation_seed_generic_rollback_update_guard.py @@ -0,0 +1,190 @@ +"""Translation child resource identity remains owned by the base ledger boundary.""" + +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" +) +_DUPLICATE_CHILD_MOVE_GUARD = ( + ROOT / "migrations" / "0249_ui_translation_seed_ownership_generic_b.sql" +) +_ROLLBACK_FILE = "rollback/0249_z_similar_voc_translation_draft.sql" + + +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: + 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_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]}" + 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, + ) + 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") + ) + 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="UI translation child rows cannot move between resources", + ): + 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, + ) + + 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( + """ + select resource_id + from ui_translation_key + where translation_key = 'operator-owned-copy' + """ + ) + == 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: + await admin_connection.execute(f'drop database "{database_name}"') + await admin_connection.close() + + asyncio.run(scenario())