diff --git a/database/migrations/0009_accounting_book_period_control.sql b/database/migrations/0009_accounting_book_period_control.sql index dec50703..c99d6131 100644 --- a/database/migrations/0009_accounting_book_period_control.sql +++ b/database/migrations/0009_accounting_book_period_control.sql @@ -22,12 +22,13 @@ CREATE INDEX accounting_book_period_scope_index tenant_account_id, accounting_book_id, fiscal_period_id, period_status_code ); -ALTER TABLE accounting_core.accounting_book_period_control ENABLE ROW LEVEL SECURITY; -ALTER TABLE accounting_core.accounting_book_period_control FORCE ROW LEVEL SECURITY; -CREATE POLICY accounting_book_period_isolation - ON accounting_core.accounting_book_period_control - USING (tenant_account_id = accounting_core.current_tenant_account_id()) - WITH CHECK (tenant_account_id = accounting_core.current_tenant_account_id()); +-- Migrations 0005+ force the table owner through tenant RLS on both source +-- relations. An unbound NOSUPERUSER/NOBYPASSRLS migration owner therefore +-- needs its normal owner visibility restored on the sources before this +-- all-tenant backfill. RLS stays enabled for non-owner roles, and FORCE is +-- restored before the migration transaction commits. +ALTER TABLE accounting_core.accounting_book NO FORCE ROW LEVEL SECURITY; +ALTER TABLE accounting_core.fiscal_period NO FORCE ROW LEVEL SECURITY; INSERT INTO accounting_core.accounting_book_period_control ( tenant_account_id, accounting_book_id, fiscal_period_id, @@ -44,6 +45,16 @@ JOIN accounting_core.fiscal_period WHERE accounting_book.valid_to IS NULL ON CONFLICT (tenant_account_id, accounting_book_id, fiscal_period_id) DO NOTHING; +ALTER TABLE accounting_core.fiscal_period FORCE ROW LEVEL SECURITY; +ALTER TABLE accounting_core.accounting_book FORCE ROW LEVEL SECURITY; + +ALTER TABLE accounting_core.accounting_book_period_control ENABLE ROW LEVEL SECURITY; +ALTER TABLE accounting_core.accounting_book_period_control FORCE ROW LEVEL SECURITY; +CREATE POLICY accounting_book_period_isolation + ON accounting_core.accounting_book_period_control + USING (tenant_account_id = accounting_core.current_tenant_account_id()) + WITH CHECK (tenant_account_id = accounting_core.current_tenant_account_id()); + CREATE OR REPLACE FUNCTION accounting_core.guard_period_insert() RETURNS trigger LANGUAGE plpgsql diff --git a/database/migrations/0029_trial_balance_snapshot_population_unique_index.sql b/database/migrations/0029_trial_balance_snapshot_population_unique_index.sql new file mode 100644 index 00000000..08ca2fe1 --- /dev/null +++ b/database/migrations/0029_trial_balance_snapshot_population_unique_index.sql @@ -0,0 +1,6 @@ +-- PostgreSQL requires CREATE INDEX CONCURRENTLY to run outside a transaction block. +-- Keep this migration to the single concurrent build so the canonical installer can +-- apply it as one autocommit statement without blocking ordinary table writes. +CREATE UNIQUE INDEX CONCURRENTLY trial_balance_snapshot_one_population_per_book_period +ON accounting_reporting.trial_balance_snapshot + (tenant_account_id, accounting_book_id, fiscal_period_id); diff --git a/database/migrations/0030_trial_balance_snapshot_immutability.sql b/database/migrations/0030_trial_balance_snapshot_immutability.sql new file mode 100644 index 00000000..a2b3bdb6 --- /dev/null +++ b/database/migrations/0030_trial_balance_snapshot_immutability.sql @@ -0,0 +1,245 @@ +BEGIN; + +-- The unique index was built concurrently in migration 0029. Attaching it as a +-- constraint is a short metadata operation and makes the physical invariant part +-- of the table contract without rebuilding the index under a long write-blocking lock. +ALTER TABLE accounting_reporting.trial_balance_snapshot + ADD CONSTRAINT trial_balance_snapshot_one_population_per_book_period + UNIQUE USING INDEX trial_balance_snapshot_one_population_per_book_period; + +-- Retained trial-balance values are accounting evidence, not three independently +-- writable amounts. Add the row-local invariant without scanning inherited rows +-- under this transaction's ADD-CONSTRAINT lock. Migration 0031 validates history +-- in a separate autocommit statement after this transaction releases its locks. +ALTER TABLE accounting_reporting.trial_balance_line + ADD CONSTRAINT trial_balance_line_net_balance_conservation + CHECK (net_balance_amount = debit_total_amount - credit_total_amount) + NOT VALID; + +CREATE OR REPLACE FUNCTION accounting_reporting.reject_trial_balance_snapshot_mutation() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + RAISE EXCEPTION + 'hard-close trial balance evidence is immutable (trial_balance_snapshot_immutable)' + USING ERRCODE = 'check_violation'; +END; +$$; + +CREATE TRIGGER trial_balance_snapshot_immutable_guard + BEFORE UPDATE OR DELETE + ON accounting_reporting.trial_balance_snapshot + FOR EACH ROW + EXECUTE FUNCTION accounting_reporting.reject_trial_balance_snapshot_mutation(); + +CREATE OR REPLACE FUNCTION accounting_reporting.reject_trial_balance_line_mutation() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + RAISE EXCEPTION + 'hard-close trial balance evidence is immutable (trial_balance_snapshot_immutable)' + USING ERRCODE = 'check_violation'; +END; +$$; + +CREATE TRIGGER trial_balance_line_immutable_guard + BEFORE UPDATE OR DELETE + ON accounting_reporting.trial_balance_line + FOR EACH ROW + EXECUTE FUNCTION accounting_reporting.reject_trial_balance_line_mutation(); + +CREATE OR REPLACE FUNCTION accounting_reporting.guard_trial_balance_snapshot_insert() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, pg_temp +AS $$ +DECLARE + period_status_value text; + book_legal_entity_id uuid; + book_reporting_currency_code text; + close_command_lock_held boolean; +BEGIN + SELECT accounting_book_period_control.period_status_code + INTO period_status_value + FROM accounting_core.accounting_book_period_control + WHERE accounting_book_period_control.tenant_account_id = NEW.tenant_account_id + AND accounting_book_period_control.accounting_book_id = NEW.accounting_book_id + AND accounting_book_period_control.fiscal_period_id = NEW.fiscal_period_id + FOR UPDATE; + + IF period_status_value IS NULL THEN + RAISE EXCEPTION + 'trial balance snapshot has no matching book-period authority (trial_balance_snapshot_scope_missing)' + USING ERRCODE = 'check_violation'; + END IF; + + SELECT accounting_book.legal_entity_id, + accounting_book.reporting_currency_code + INTO book_legal_entity_id, + book_reporting_currency_code + FROM accounting_core.accounting_book + WHERE accounting_book.tenant_account_id = NEW.tenant_account_id + AND accounting_book.accounting_book_id = NEW.accounting_book_id; + + IF book_legal_entity_id IS NOT NULL + AND book_legal_entity_id IS DISTINCT FROM NEW.legal_entity_id THEN + RAISE EXCEPTION + 'trial balance snapshot legal entity must own the accounting book (trial_balance_snapshot_book_entity_mismatch)' + USING ERRCODE = 'check_violation'; + END IF; + + IF book_reporting_currency_code IS NOT NULL + AND book_reporting_currency_code IS DISTINCT FROM NEW.snapshot_currency_code THEN + RAISE EXCEPTION + 'trial balance snapshot currency must match the accounting book reporting currency (trial_balance_snapshot_currency_mismatch)' + USING ERRCODE = 'check_violation'; + END IF; + + IF period_status_value = 'hard_closed' THEN + RAISE EXCEPTION + 'hard-close trial balance evidence is immutable (trial_balance_snapshot_immutable)' + USING ERRCODE = 'check_violation'; + END IF; + + -- The hard-close command always acquires this tenant/book/period advisory + -- lock before assembling close evidence. The lock remains present even when + -- zero net revenue/expense means no period-closing journal is emitted. A + -- caller-set journal_write_role GUC is therefore never snapshot authority. + SELECT EXISTS ( + SELECT 1 + FROM pg_catalog.pg_locks AS held_lock + JOIN accounting_core.tenant_account + ON tenant_account.tenant_account_id = NEW.tenant_account_id + JOIN accounting_core.accounting_book + ON accounting_book.tenant_account_id = NEW.tenant_account_id + AND accounting_book.accounting_book_id = NEW.accounting_book_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id = NEW.tenant_account_id + AND fiscal_period.fiscal_period_id = NEW.fiscal_period_id + WHERE held_lock.locktype = 'advisory' + AND held_lock.pid = pg_backend_pid() + AND held_lock.database = ( + SELECT pg_database.oid + FROM pg_catalog.pg_database + WHERE pg_database.datname = current_database() + ) + AND held_lock.mode = 'ExclusiveLock' + AND held_lock.granted + AND held_lock.objsubid = 2 + AND held_lock.classid::bigint = ( + hashtext(tenant_account.tenant_account_code)::bigint & 4294967295 + ) + AND held_lock.objid::bigint = ( + hashtext( + 'period:' || accounting_book.accounting_book_id::text || ':' || fiscal_period.period_code + )::bigint & 4294967295 + ) + ) INTO close_command_lock_held; + + IF period_status_value <> 'soft_closed' + OR NOT pg_has_role(session_user, 'accounting_closing_writer', 'MEMBER') + OR NOT close_command_lock_held + THEN + RAISE EXCEPTION + 'trial balance snapshot creation requires the purpose-limited hard-close writer (trial_balance_snapshot_authority_required)' + USING ERRCODE = 'check_violation'; + END IF; + + -- Snapshot chronology is an AIS system-time fact. A purpose-limited closing + -- writer may supply accounting evidence but cannot select the recording clock. + NEW.snapshot_generated_at := clock_timestamp(); + + IF EXISTS ( + SELECT 1 + FROM accounting_reporting.trial_balance_snapshot + WHERE trial_balance_snapshot.tenant_account_id = NEW.tenant_account_id + AND trial_balance_snapshot.accounting_book_id = NEW.accounting_book_id + AND trial_balance_snapshot.fiscal_period_id = NEW.fiscal_period_id + ) THEN + RAISE EXCEPTION + 'trial balance snapshot population already occupies this book-period (trial_balance_snapshot_population_conflict)' + USING ERRCODE = 'check_violation'; + END IF; + + RETURN NEW; +END; +$$; + +REVOKE ALL ON FUNCTION accounting_reporting.guard_trial_balance_snapshot_insert() + FROM PUBLIC; + +CREATE TRIGGER trial_balance_snapshot_population_guard + BEFORE INSERT + ON accounting_reporting.trial_balance_snapshot + FOR EACH ROW + EXECUTE FUNCTION accounting_reporting.guard_trial_balance_snapshot_insert(); + +CREATE OR REPLACE FUNCTION accounting_reporting.guard_trial_balance_line_insert() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, pg_temp +AS $$ +DECLARE + period_status_value text; + snapshot_book_id uuid; + chart_account_book_id uuid; +BEGIN + SELECT accounting_book_period_control.period_status_code, + trial_balance_snapshot.accounting_book_id + INTO period_status_value, snapshot_book_id + FROM accounting_reporting.trial_balance_snapshot + JOIN accounting_core.accounting_book_period_control + ON accounting_book_period_control.tenant_account_id + = trial_balance_snapshot.tenant_account_id + AND accounting_book_period_control.accounting_book_id + = trial_balance_snapshot.accounting_book_id + AND accounting_book_period_control.fiscal_period_id + = trial_balance_snapshot.fiscal_period_id + WHERE trial_balance_snapshot.tenant_account_id = NEW.tenant_account_id + AND trial_balance_snapshot.trial_balance_snapshot_id + = NEW.trial_balance_snapshot_id + FOR UPDATE OF accounting_book_period_control; + + IF period_status_value IS NULL THEN + RAISE EXCEPTION + 'trial balance snapshot has no matching book-period authority (trial_balance_snapshot_scope_missing)' + USING ERRCODE = 'check_violation'; + END IF; + + SELECT chart_account.accounting_book_id + INTO chart_account_book_id + FROM accounting_core.chart_account + WHERE chart_account.tenant_account_id = NEW.tenant_account_id + AND chart_account.chart_account_id = NEW.chart_account_id; + + IF chart_account_book_id IS NOT NULL + AND snapshot_book_id IS DISTINCT FROM chart_account_book_id THEN + RAISE EXCEPTION + 'trial balance line chart account must belong to the snapshot accounting book (trial_balance_line_book_scope_mismatch)' + USING ERRCODE = 'check_violation'; + END IF; + + IF period_status_value = 'hard_closed' THEN + RAISE EXCEPTION + 'hard-close trial balance evidence is immutable (trial_balance_snapshot_immutable)' + USING ERRCODE = 'check_violation'; + END IF; + + RETURN NEW; +END; +$$; + +REVOKE ALL ON FUNCTION accounting_reporting.guard_trial_balance_line_insert() + FROM PUBLIC; + +CREATE TRIGGER trial_balance_line_population_guard + BEFORE INSERT + ON accounting_reporting.trial_balance_line + FOR EACH ROW + EXECUTE FUNCTION accounting_reporting.guard_trial_balance_line_insert(); + +COMMIT; diff --git a/database/migrations/0031_trial_balance_line_conservation_validation.sql b/database/migrations/0031_trial_balance_line_conservation_validation.sql new file mode 100644 index 00000000..a894a43a --- /dev/null +++ b/database/migrations/0031_trial_balance_line_conservation_validation.sql @@ -0,0 +1,2 @@ +ALTER TABLE accounting_reporting.trial_balance_line + VALIDATE CONSTRAINT trial_balance_line_net_balance_conservation; diff --git a/database/migrations/0032_period_close_journal_population_fence.sql b/database/migrations/0032_period_close_journal_population_fence.sql new file mode 100644 index 00000000..f59a7b4b --- /dev/null +++ b/database/migrations/0032_period_close_journal_population_fence.sql @@ -0,0 +1,100 @@ +BEGIN; + +-- Journals admitted while a period is soft-closed change the authoritative +-- population that hard close is about to freeze. Version those close-window +-- writes on the same control row that hard close later locks. Ordinary open- +-- period posting must not UPDATE that row on every journal: concurrent open +-- posting instead holds a shared row lock so a period-state transition cannot +-- overtake a journal that was admitted under the open-state contract. +ALTER TABLE accounting_core.accounting_book_period_control + ADD COLUMN journal_population_revision bigint NOT NULL DEFAULT 0 + CHECK (journal_population_revision >= 0); + +CREATE OR REPLACE FUNCTION accounting_core.guard_period_insert() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, pg_temp +AS $$ +DECLARE + period_status_value text; + locked_period_status_value text; + journal_write_role_value text; +BEGIN + SELECT accounting_book_period_control.period_status_code + INTO period_status_value + FROM accounting_core.accounting_book_period_control + WHERE accounting_book_period_control.tenant_account_id = NEW.tenant_account_id + AND accounting_book_period_control.accounting_book_id = NEW.accounting_book_id + AND accounting_book_period_control.fiscal_period_id = NEW.fiscal_period_id; + + IF period_status_value IS NULL THEN + RAISE EXCEPTION + 'accounting book fiscal period control is missing for this journal insert (period_control_missing)' + USING ERRCODE = 'check_violation'; + END IF; + + IF period_status_value = 'open' THEN + -- Many ordinary postings may share this row lock concurrently. A soft-close + -- UPDATE must wait for all journals admitted as open-period work to commit. + -- If the period changed while this statement waited, retry from a fresh + -- transaction rather than silently applying open-period authority to a + -- soft-closed period. + SELECT accounting_book_period_control.period_status_code + INTO locked_period_status_value + FROM accounting_core.accounting_book_period_control + WHERE accounting_book_period_control.tenant_account_id = NEW.tenant_account_id + AND accounting_book_period_control.accounting_book_id = NEW.accounting_book_id + AND accounting_book_period_control.fiscal_period_id = NEW.fiscal_period_id + FOR SHARE; + + IF locked_period_status_value = 'open' THEN + RETURN NEW; + END IF; + + RAISE EXCEPTION + 'accounting book fiscal period changed during journal admission (period_state_changed_retry)' + USING ERRCODE = 'serialization_failure'; + END IF; + + journal_write_role_value := nullif( + current_setting('accounting_core.journal_write_role', true), + '' + ); + + IF period_status_value = 'soft_closed' + AND journal_write_role_value IN ('period_closing', 'adjusting', 'reversal') + AND pg_has_role(session_user, 'accounting_closing_writer', 'MEMBER') + THEN + -- Close-window journals are intentionally serialized on this revision. + -- If hard close already won the row, PostgreSQL either raises a serialization + -- failure under REPEATABLE READ or this predicate stops matching after wait. + locked_period_status_value := NULL; + UPDATE accounting_core.accounting_book_period_control + SET journal_population_revision = journal_population_revision + 1 + WHERE tenant_account_id = NEW.tenant_account_id + AND accounting_book_id = NEW.accounting_book_id + AND fiscal_period_id = NEW.fiscal_period_id + AND period_status_code = 'soft_closed' + RETURNING period_status_code + INTO locked_period_status_value; + + IF locked_period_status_value = 'soft_closed' THEN + RETURN NEW; + END IF; + + RAISE EXCEPTION + 'accounting book fiscal period changed during close-window journal admission (period_state_changed_retry)' + USING ERRCODE = 'serialization_failure'; + END IF; + + RAISE EXCEPTION + 'Accounting book fiscal period is % (period_closed). Ordinary journals cannot be inserted after close. Post the AIS closing journal before hard-close; do not insert a later ordinary or reversal journal into a locked book period.', + period_status_value + USING ERRCODE = 'check_violation'; +END; +$$; + +REVOKE ALL ON FUNCTION accounting_core.guard_period_insert() FROM PUBLIC; + +COMMIT; diff --git a/database/migrations/0033_open_period_journal_population_fence.sql b/database/migrations/0033_open_period_journal_population_fence.sql new file mode 100644 index 00000000..dcb7c9e7 --- /dev/null +++ b/database/migrations/0033_open_period_journal_population_fence.sql @@ -0,0 +1,403 @@ +BEGIN; + +-- Direct open-to-close transitions need a freshness witness for journals that +-- commit after a close transaction has established its REPEATABLE READ snapshot. +-- A single per-period revision row would serialize the high-volume posting path, +-- so ordinary open-period journals version one of 64 pre-existing fence rows. +CREATE TABLE accounting_core.period_journal_population_fence ( + tenant_account_id uuid NOT NULL, + accounting_book_id uuid NOT NULL, + fiscal_period_id uuid NOT NULL, + fence_slot smallint NOT NULL + CHECK (fence_slot >= 0 AND fence_slot < 64), + journal_population_revision bigint NOT NULL DEFAULT 0 + CHECK (journal_population_revision >= 0), + PRIMARY KEY ( + tenant_account_id, + accounting_book_id, + fiscal_period_id, + fence_slot + ), + FOREIGN KEY ( + tenant_account_id, + accounting_book_id, + fiscal_period_id + ) REFERENCES accounting_core.accounting_book_period_control ( + tenant_account_id, + accounting_book_id, + fiscal_period_id + ) +); + +REVOKE ALL ON accounting_core.period_journal_population_fence FROM PUBLIC; + +-- Fence rows must pre-date any REPEATABLE READ close snapshot. Creating a fence +-- lazily after a stale snapshot would let the close miss the new row entirely. +-- Seed the migration-owned backfill before FORCE RLS so a non-superuser schema +-- owner can initialize every tenant without borrowing one runtime tenant scope. +INSERT INTO accounting_core.period_journal_population_fence ( + tenant_account_id, + accounting_book_id, + fiscal_period_id, + fence_slot +) +SELECT period_control.tenant_account_id, + period_control.accounting_book_id, + period_control.fiscal_period_id, + generated_slot.fence_slot::smallint +FROM accounting_core.accounting_book_period_control AS period_control +CROSS JOIN generate_series(0, 63) AS generated_slot(fence_slot); + +ALTER TABLE accounting_core.period_journal_population_fence ENABLE ROW LEVEL SECURITY; +ALTER TABLE accounting_core.period_journal_population_fence FORCE ROW LEVEL SECURITY; +CREATE POLICY period_journal_population_fence_isolation + ON accounting_core.period_journal_population_fence + USING (tenant_account_id = accounting_core.current_tenant_account_id()) + WITH CHECK (tenant_account_id = accounting_core.current_tenant_account_id()); + +CREATE OR REPLACE FUNCTION accounting_core.seed_period_journal_population_fence() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, pg_temp +AS $$ +DECLARE + effective_role_bypasses_rls boolean; +BEGIN + -- SECURITY DEFINER changes current_user to the function owner. RLS bypass is + -- evaluated for that effective role, while current_tenant_account_id() uses + -- the original session_user binding. Do not make this explicit guard stricter + -- than PostgreSQL itself for superuser/BYPASSRLS migration operators. + SELECT role.rolsuper OR role.rolbypassrls + INTO effective_role_bypasses_rls + FROM pg_catalog.pg_roles AS role + WHERE role.rolname = current_user; + + -- Runtime seeding runs while the fence table is FORCE RLS protected and + -- therefore needs the same authenticated tenant identity as the control row + -- whenever the effective function owner cannot bypass RLS. Migration 0034 + -- temporarily removes FORCE RLS for its owner backfill; keep that repair path + -- distinct instead of minting a synthetic runtime tenant binding. + IF COALESCE( + ( + SELECT relation.relforcerowsecurity + FROM pg_catalog.pg_class AS relation + JOIN pg_catalog.pg_namespace AS namespace + ON namespace.oid = relation.relnamespace + WHERE namespace.nspname = 'accounting_core' + AND relation.relname = 'period_journal_population_fence' + ), + TRUE + ) + AND NOT COALESCE(effective_role_bypasses_rls, FALSE) + AND accounting_core.current_tenant_account_id() + IS DISTINCT FROM NEW.tenant_account_id + THEN + RAISE EXCEPTION + 'runtime tenant binding must match journal-population fence seed scope (period_journal_population_fence_tenant_binding_required)' + USING ERRCODE = 'check_violation'; + END IF; + + INSERT INTO accounting_core.period_journal_population_fence ( + tenant_account_id, + accounting_book_id, + fiscal_period_id, + fence_slot + ) + SELECT NEW.tenant_account_id, + NEW.accounting_book_id, + NEW.fiscal_period_id, + generated_slot.fence_slot::smallint + FROM generate_series(0, 63) AS generated_slot(fence_slot) + ON CONFLICT ( + tenant_account_id, + accounting_book_id, + fiscal_period_id, + fence_slot + ) DO NOTHING; + + RETURN NEW; +END; +$$; + +REVOKE ALL ON FUNCTION accounting_core.seed_period_journal_population_fence() + FROM PUBLIC; + +CREATE TRIGGER period_journal_population_fence_seed + AFTER INSERT + ON accounting_core.accounting_book_period_control + FOR EACH ROW + EXECUTE FUNCTION accounting_core.seed_period_journal_population_fence(); + +CREATE OR REPLACE FUNCTION accounting_core.guard_period_insert() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, pg_temp +AS $$ +DECLARE + period_status_value text; + locked_period_status_value text; + journal_write_role_value text; + fence_slot_value smallint; + affected_fence_rows integer; +BEGIN + SELECT accounting_book_period_control.period_status_code + INTO period_status_value + FROM accounting_core.accounting_book_period_control + WHERE accounting_book_period_control.tenant_account_id = NEW.tenant_account_id + AND accounting_book_period_control.accounting_book_id = NEW.accounting_book_id + AND accounting_book_period_control.fiscal_period_id = NEW.fiscal_period_id; + + IF period_status_value IS NULL THEN + RAISE EXCEPTION + 'accounting book fiscal period control is missing for this journal insert (period_control_missing)' + USING ERRCODE = 'check_violation'; + END IF; + + IF period_status_value = 'open' THEN + SELECT accounting_book_period_control.period_status_code + INTO locked_period_status_value + FROM accounting_core.accounting_book_period_control + WHERE accounting_book_period_control.tenant_account_id = NEW.tenant_account_id + AND accounting_book_period_control.accounting_book_id = NEW.accounting_book_id + AND accounting_book_period_control.fiscal_period_id = NEW.fiscal_period_id + FOR SHARE; + + IF locked_period_status_value IS DISTINCT FROM 'open' THEN + RAISE EXCEPTION + 'accounting book fiscal period changed during journal admission (period_state_changed_retry)' + USING ERRCODE = 'serialization_failure'; + END IF; + + -- UUID identity supplies a stable, caller-independent distribution key. + -- Only journals choosing the same slot contend with each other; the + -- authoritative transition later inspects every pre-existing slot. + fence_slot_value := ( + get_byte(uuid_send(NEW.general_journal_id), 15) % 64 + )::smallint; + UPDATE accounting_core.period_journal_population_fence + SET journal_population_revision = journal_population_revision + 1 + WHERE tenant_account_id = NEW.tenant_account_id + AND accounting_book_id = NEW.accounting_book_id + AND fiscal_period_id = NEW.fiscal_period_id + AND fence_slot = fence_slot_value; + GET DIAGNOSTICS affected_fence_rows = ROW_COUNT; + + IF affected_fence_rows <> 1 THEN + RAISE EXCEPTION + 'open-period journal population fence is incomplete (period_journal_population_fence_missing)' + USING ERRCODE = 'check_violation'; + END IF; + + RETURN NEW; + END IF; + + journal_write_role_value := nullif( + current_setting('accounting_core.journal_write_role', true), + '' + ); + + IF period_status_value = 'soft_closed' + AND journal_write_role_value IN ('period_closing', 'adjusting', 'reversal') + AND pg_has_role(session_user, 'accounting_closing_writer', 'MEMBER') + THEN + locked_period_status_value := NULL; + UPDATE accounting_core.accounting_book_period_control + SET journal_population_revision = journal_population_revision + 1 + WHERE tenant_account_id = NEW.tenant_account_id + AND accounting_book_id = NEW.accounting_book_id + AND fiscal_period_id = NEW.fiscal_period_id + AND period_status_code = 'soft_closed' + RETURNING period_status_code + INTO locked_period_status_value; + + IF locked_period_status_value = 'soft_closed' THEN + RETURN NEW; + END IF; + + RAISE EXCEPTION + 'accounting book fiscal period changed during close-window journal admission (period_state_changed_retry)' + USING ERRCODE = 'serialization_failure'; + END IF; + + RAISE EXCEPTION + 'Accounting book fiscal period is % (period_closed). Ordinary journals cannot be inserted after close. Post the AIS closing journal before hard-close; do not insert a later ordinary or reversal journal into a locked book period.', + period_status_value + USING ERRCODE = 'check_violation'; +END; +$$; + +REVOKE ALL ON FUNCTION accounting_core.guard_period_insert() FROM PUBLIC; + +-- A state transition must observe every stripe under FOR UPDATE. If an open +-- journal committed to any stripe after this REPEATABLE READ transaction's +-- snapshot was fixed, PostgreSQL raises SQLSTATE 40001 rather than allowing a +-- close receipt or snapshot derived from a stale journal population to commit. +CREATE OR REPLACE FUNCTION accounting_core.guard_period_state_transition_freshness() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, pg_temp +AS $$ +DECLARE + locked_fence_rows integer; +BEGIN + IF current_setting('transaction_isolation') + NOT IN ('repeatable read', 'serializable') + THEN + RAISE EXCEPTION + 'period state transition requires repeatable read or serializable isolation (period_close_isolation_required)' + USING ERRCODE = 'check_violation'; + END IF; + + PERFORM period_fence.fence_slot + FROM accounting_core.period_journal_population_fence AS period_fence + WHERE period_fence.tenant_account_id = NEW.tenant_account_id + AND period_fence.accounting_book_id = NEW.accounting_book_id + AND period_fence.fiscal_period_id = NEW.fiscal_period_id + ORDER BY period_fence.fence_slot + FOR UPDATE; + + GET DIAGNOSTICS locked_fence_rows = ROW_COUNT; + IF locked_fence_rows <> 64 THEN + RAISE EXCEPTION + 'period journal population fence is incomplete for close transition (period_journal_population_fence_missing)' + USING ERRCODE = 'check_violation'; + END IF; + + RETURN NEW; +END; +$$; + +REVOKE ALL ON FUNCTION accounting_core.guard_period_state_transition_freshness() + FROM PUBLIC; + +CREATE TRIGGER period_state_transition_population_fence + BEFORE UPDATE OF period_status_code + ON accounting_core.accounting_book_period_control + FOR EACH ROW + WHEN (OLD.period_status_code IS DISTINCT FROM NEW.period_status_code) + EXECUTE FUNCTION accounting_core.guard_period_state_transition_freshness(); + +-- Preserve the purpose-limited snapshot writer while restoring the supported +-- direct open-to-hard-close command. Both open and soft-closed snapshots require +-- the exact tenant/book/period close advisory lock. The caller-controlled +-- journal_write_role GUC remains journal-admission context only and cannot mint +-- retained close evidence by itself. +CREATE OR REPLACE FUNCTION accounting_reporting.guard_trial_balance_snapshot_insert() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, pg_temp +AS $$ +DECLARE + period_status_value text; + book_legal_entity_id uuid; + book_reporting_currency_code text; + close_command_lock_held boolean; +BEGIN + SELECT accounting_book_period_control.period_status_code + INTO period_status_value + FROM accounting_core.accounting_book_period_control + WHERE accounting_book_period_control.tenant_account_id = NEW.tenant_account_id + AND accounting_book_period_control.accounting_book_id = NEW.accounting_book_id + AND accounting_book_period_control.fiscal_period_id = NEW.fiscal_period_id + FOR UPDATE; + + IF period_status_value IS NULL THEN + RAISE EXCEPTION + 'trial balance snapshot has no matching book-period authority (trial_balance_snapshot_scope_missing)' + USING ERRCODE = 'check_violation'; + END IF; + + SELECT accounting_book.legal_entity_id, + accounting_book.reporting_currency_code + INTO book_legal_entity_id, + book_reporting_currency_code + FROM accounting_core.accounting_book + WHERE accounting_book.tenant_account_id = NEW.tenant_account_id + AND accounting_book.accounting_book_id = NEW.accounting_book_id; + + IF book_legal_entity_id IS NOT NULL + AND book_legal_entity_id IS DISTINCT FROM NEW.legal_entity_id THEN + RAISE EXCEPTION + 'trial balance snapshot legal entity must own the accounting book (trial_balance_snapshot_book_entity_mismatch)' + USING ERRCODE = 'check_violation'; + END IF; + + IF book_reporting_currency_code IS NOT NULL + AND book_reporting_currency_code IS DISTINCT FROM NEW.snapshot_currency_code THEN + RAISE EXCEPTION + 'trial balance snapshot currency must match the accounting book reporting currency (trial_balance_snapshot_currency_mismatch)' + USING ERRCODE = 'check_violation'; + END IF; + + IF period_status_value = 'hard_closed' THEN + RAISE EXCEPTION + 'hard-close trial balance evidence is immutable (trial_balance_snapshot_immutable)' + USING ERRCODE = 'check_violation'; + END IF; + + SELECT EXISTS ( + SELECT 1 + FROM pg_catalog.pg_locks AS held_lock + JOIN accounting_core.tenant_account + ON tenant_account.tenant_account_id = NEW.tenant_account_id + JOIN accounting_core.accounting_book + ON accounting_book.tenant_account_id = NEW.tenant_account_id + AND accounting_book.accounting_book_id = NEW.accounting_book_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id = NEW.tenant_account_id + AND fiscal_period.fiscal_period_id = NEW.fiscal_period_id + WHERE held_lock.locktype = 'advisory' + AND held_lock.pid = pg_backend_pid() + AND held_lock.database = ( + SELECT pg_database.oid + FROM pg_catalog.pg_database + WHERE pg_database.datname = current_database() + ) + AND held_lock.mode = 'ExclusiveLock' + AND held_lock.granted + AND held_lock.objsubid = 2 + AND held_lock.classid::bigint = ( + hashtext(tenant_account.tenant_account_code)::bigint & 4294967295 + ) + AND held_lock.objid::bigint = ( + hashtext( + 'period:' || accounting_book.accounting_book_id::text || ':' || fiscal_period.period_code + )::bigint & 4294967295 + ) + ) INTO close_command_lock_held; + + IF period_status_value NOT IN ('open', 'soft_closed') + OR NOT pg_has_role(session_user, 'accounting_closing_writer', 'MEMBER') + OR NOT close_command_lock_held + THEN + RAISE EXCEPTION + 'trial balance snapshot creation requires the purpose-limited hard-close writer (trial_balance_snapshot_authority_required)' + USING ERRCODE = 'check_violation'; + END IF; + + NEW.snapshot_generated_at := clock_timestamp(); + + IF EXISTS ( + SELECT 1 + FROM accounting_reporting.trial_balance_snapshot + WHERE trial_balance_snapshot.tenant_account_id = NEW.tenant_account_id + AND trial_balance_snapshot.accounting_book_id = NEW.accounting_book_id + AND trial_balance_snapshot.fiscal_period_id = NEW.fiscal_period_id + ) THEN + RAISE EXCEPTION + 'trial balance snapshot population already occupies this book-period (trial_balance_snapshot_population_conflict)' + USING ERRCODE = 'check_violation'; + END IF; + + RETURN NEW; +END; +$$; + +REVOKE ALL ON FUNCTION accounting_reporting.guard_trial_balance_snapshot_insert() + FROM PUBLIC; + +COMMIT; diff --git a/database/migrations/0034_book_period_control_seed.sql b/database/migrations/0034_book_period_control_seed.sql new file mode 100644 index 00000000..33dc57dd --- /dev/null +++ b/database/migrations/0034_book_period_control_seed.sql @@ -0,0 +1,227 @@ +BEGIN; + +-- Migration 0033 requires the tenant/book/period authority row and all 64 +-- journal-population fence rows to exist before ordinary posting or close +-- evaluation. 0009 backfilled only rows that existed at migration time, so +-- later fiscal periods or accounting books could otherwise reach the journal +-- guard without a materialized book-period authority. +-- +-- fiscal_period.period_status_code is only the tenant/calendar compatibility +-- projection after book-scoped close control exists. A newly created book or a +-- newly inserted non-open period has no retained per-book close evidence from +-- which soft_closed/hard_closed could be derived. Automatic seeding therefore +-- creates controls only for periods that are actually open and always starts +-- the new book-period authority as open. Missing controls for non-open periods +-- fail closed until an explicit book-period lifecycle can establish authority; +-- the compatibility projection is never copied into authoritative close state. +-- +-- The two seed directions also form one cross-product invariant. Without a +-- pre-existing common version witness, concurrent transactions can each insert +-- one side, scan before the other side commits, and leave the new book-period +-- pair absent. Both seeders therefore perform a non-key UPDATE of the existing +-- tenant row before scanning the peer population. The same book-side seeder is +-- also invoked when an already-recorded inactive book becomes active, because +-- activation into an existing open period creates the same admissible +-- book-period intersection as active-book creation. +-- +-- Under READ COMMITTED, the later seeder waits and its following statement sees +-- the peer commit. Under REPEATABLE READ/SERIALIZABLE, a transaction whose +-- snapshot predates the competing tenant-row version fails closed with +-- serialization failure and must retry from a fresh transaction. Updating only +-- created_at to its retained value changes no tenant business fact. PostgreSQL +-- acquires the weaker FOR NO KEY UPDATE row lock for this non-key update, so +-- unrelated child foreign-key checks using FOR KEY SHARE remain compatible. +CREATE OR REPLACE FUNCTION accounting_core.seed_book_period_control_for_period() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, pg_temp +AS $$ +BEGIN + IF NEW.period_status_code IS DISTINCT FROM 'open' THEN + RETURN NEW; + END IF; + + UPDATE accounting_core.tenant_account AS tenant + SET created_at = tenant.created_at + WHERE tenant.tenant_account_id = NEW.tenant_account_id; + + INSERT INTO accounting_core.accounting_book_period_control ( + tenant_account_id, + accounting_book_id, + fiscal_period_id, + period_status_code, + period_closed_at + ) + SELECT NEW.tenant_account_id, + accounting_book.accounting_book_id, + NEW.fiscal_period_id, + 'open', + NULL + FROM accounting_core.accounting_book + WHERE accounting_book.tenant_account_id = NEW.tenant_account_id + AND accounting_book.valid_to IS NULL + ON CONFLICT ( + tenant_account_id, + accounting_book_id, + fiscal_period_id + ) DO NOTHING; + + RETURN NEW; +END; +$$; + +REVOKE ALL ON FUNCTION accounting_core.seed_book_period_control_for_period() + FROM PUBLIC; + +CREATE TRIGGER book_period_control_seed_for_period + AFTER INSERT + ON accounting_core.fiscal_period + FOR EACH ROW + EXECUTE FUNCTION accounting_core.seed_book_period_control_for_period(); + +CREATE OR REPLACE FUNCTION accounting_core.seed_book_period_control_for_book() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, pg_temp +AS $$ +BEGIN + IF NEW.valid_to IS NOT NULL THEN + RETURN NEW; + END IF; + + UPDATE accounting_core.tenant_account AS tenant + SET created_at = tenant.created_at + WHERE tenant.tenant_account_id = NEW.tenant_account_id; + + INSERT INTO accounting_core.accounting_book_period_control ( + tenant_account_id, + accounting_book_id, + fiscal_period_id, + period_status_code, + period_closed_at + ) + SELECT NEW.tenant_account_id, + NEW.accounting_book_id, + fiscal_period.fiscal_period_id, + 'open', + NULL + FROM accounting_core.fiscal_period + WHERE fiscal_period.tenant_account_id = NEW.tenant_account_id + AND fiscal_period.period_status_code = 'open' + ON CONFLICT ( + tenant_account_id, + accounting_book_id, + fiscal_period_id + ) DO NOTHING; + + RETURN NEW; +END; +$$; + +REVOKE ALL ON FUNCTION accounting_core.seed_book_period_control_for_book() + FROM PUBLIC; + +CREATE TRIGGER book_period_control_seed_for_book + AFTER INSERT + ON accounting_core.accounting_book + FOR EACH ROW + EXECUTE FUNCTION accounting_core.seed_book_period_control_for_book(); + +-- An inactive book can become admissible after its original INSERT. Reuse the +-- same database-owned open-only seeder on that lifecycle edge; do not require a +-- later application write to manufacture a control row. The WHEN clause avoids +-- versioning the tenant witness for ordinary edits and for deactivation. +CREATE TRIGGER book_period_control_seed_for_book_activation + AFTER UPDATE OF valid_to + ON accounting_core.accounting_book + FOR EACH ROW + WHEN (OLD.valid_to IS NOT NULL AND NEW.valid_to IS NULL) + EXECUTE FUNCTION accounting_core.seed_book_period_control_for_book(); + +-- Repair databases that installed 0009 before later master-data rows existed. +-- accounting_book/fiscal_period as well as the control/fence targets are +-- already FORCE RLS protected at this point. An unbound NOSUPERUSER / +-- NOBYPASSRLS migration owner would otherwise see no source rows and could not +-- populate the targets. NO FORCE restores only ordinary table-owner bypass; +-- RLS remains enabled for non-owner roles. The same transaction restores FORCE +-- on every source/target table before commit. ALTER TABLE's locking also keeps +-- concurrent runtime traffic from observing a partially changed owner policy. +-- +-- This repair fills only missing open controls. A missing non-open book-period +-- pair cannot be reconstructed from fiscal_period's compatibility projection, +-- because that projection is not retained per-book close evidence. +ALTER TABLE accounting_core.accounting_book NO FORCE ROW LEVEL SECURITY; +ALTER TABLE accounting_core.fiscal_period NO FORCE ROW LEVEL SECURITY; +ALTER TABLE accounting_core.accounting_book_period_control NO FORCE ROW LEVEL SECURITY; +ALTER TABLE accounting_core.period_journal_population_fence NO FORCE ROW LEVEL SECURITY; + +INSERT INTO accounting_core.accounting_book_period_control ( + tenant_account_id, + accounting_book_id, + fiscal_period_id, + period_status_code, + period_closed_at +) +SELECT accounting_book.tenant_account_id, + accounting_book.accounting_book_id, + fiscal_period.fiscal_period_id, + 'open', + NULL +FROM accounting_core.accounting_book +JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id = accounting_book.tenant_account_id +WHERE accounting_book.valid_to IS NULL + AND fiscal_period.period_status_code = 'open' +ON CONFLICT ( + tenant_account_id, + accounting_book_id, + fiscal_period_id +) DO NOTHING; + +ALTER TABLE accounting_core.period_journal_population_fence FORCE ROW LEVEL SECURITY; +ALTER TABLE accounting_core.accounting_book_period_control FORCE ROW LEVEL SECURITY; +ALTER TABLE accounting_core.fiscal_period FORCE ROW LEVEL SECURITY; +ALTER TABLE accounting_core.accounting_book FORCE ROW LEVEL SECURITY; + +-- After the one-time repair above, new book-period authority may be created only +-- by the canonical master-data seed triggers. A direct application or SQL +-- INSERT must not reconstruct authority from fiscal_period compatibility state. +-- pg_trigger_depth() is structural rather than a caller-controlled custom GUC: +-- the control-table trigger runs at depth 2 when invoked by a canonical seeder +-- and at depth 1 for a direct control-table INSERT. Reject unsupported writes +-- explicitly so SQL/application callers cannot mistake a silently skipped row +-- for accepted close authority. This keeps the database single-writer boundary +-- intact without granting a mutable session flag that another writer could +-- spoof. +CREATE OR REPLACE FUNCTION accounting_core.guard_book_period_control_insert_authority() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, pg_temp +AS $$ +BEGIN + IF pg_trigger_depth() < 2 + OR NEW.period_status_code IS DISTINCT FROM 'open' + OR NEW.period_closed_at IS NOT NULL + THEN + RAISE EXCEPTION + 'book-period control must be created by canonical master-data seeding (book_period_control_insert_authority_required)' + USING ERRCODE = 'check_violation'; + END IF; + + RETURN NEW; +END; +$$; + +REVOKE ALL ON FUNCTION accounting_core.guard_book_period_control_insert_authority() + FROM PUBLIC; + +CREATE TRIGGER book_period_control_insert_authority_guard + BEFORE INSERT + ON accounting_core.accounting_book_period_control + FOR EACH ROW + EXECUTE FUNCTION accounting_core.guard_book_period_control_insert_authority(); + +COMMIT; diff --git a/database/migrations/0035_trial_balance_snapshot_hard_close_pair.sql b/database/migrations/0035_trial_balance_snapshot_hard_close_pair.sql new file mode 100644 index 00000000..242da675 --- /dev/null +++ b/database/migrations/0035_trial_balance_snapshot_hard_close_pair.sql @@ -0,0 +1,46 @@ +BEGIN; + +-- A retained trial-balance snapshot is created while the authoritative +-- book-period control is still soft_closed, then the canonical hard-close +-- command advances that same control to hard_closed before commit. The +-- immediate admission trigger therefore cannot prove the final pairing. +-- Enforce the invariant at transaction end so a purpose-limited closing +-- session cannot retain snapshot evidence while leaving book-period authority +-- soft-closed. This is a database consistency control, not an IFRS rule. +CREATE OR REPLACE FUNCTION accounting_reporting.require_trial_balance_snapshot_hard_close_pair() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, pg_temp +AS $$ +DECLARE + period_status_value text; +BEGIN + SELECT accounting_book_period_control.period_status_code + INTO period_status_value + FROM accounting_core.accounting_book_period_control + WHERE accounting_book_period_control.tenant_account_id = NEW.tenant_account_id + AND accounting_book_period_control.accounting_book_id = NEW.accounting_book_id + AND accounting_book_period_control.fiscal_period_id = NEW.fiscal_period_id; + + IF period_status_value IS DISTINCT FROM 'hard_closed' THEN + RAISE EXCEPTION + 'retained trial balance snapshot must commit with hard-closed book-period authority (trial_balance_snapshot_hard_close_pair_required)' + USING ERRCODE = 'check_violation'; + END IF; + + RETURN NULL; +END; +$$; + +REVOKE ALL ON FUNCTION accounting_reporting.require_trial_balance_snapshot_hard_close_pair() + FROM PUBLIC; + +CREATE CONSTRAINT TRIGGER trial_balance_snapshot_hard_close_pair_guard + AFTER INSERT + ON accounting_reporting.trial_balance_snapshot + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW + EXECUTE FUNCTION accounting_reporting.require_trial_balance_snapshot_hard_close_pair(); + +COMMIT; diff --git a/database/migrations/0036_hard_close_trial_balance_snapshot_pair.sql b/database/migrations/0036_hard_close_trial_balance_snapshot_pair.sql new file mode 100644 index 00000000..c1c65507 --- /dev/null +++ b/database/migrations/0036_hard_close_trial_balance_snapshot_pair.sql @@ -0,0 +1,104 @@ +BEGIN; + +-- A deferred trigger protects future transitions but cannot certify rows that +-- already existed before this migration. Both participating relations are FORCE +-- RLS, so give only the current migration role transaction-scoped SELECT +-- visibility for the preflight. The policies disappear before durable trigger +-- installation; an aborted migration rolls them back with the transaction. +CREATE POLICY hard_close_snapshot_pair_control_upgrade_visibility + ON accounting_core.accounting_book_period_control + FOR SELECT + TO current_user + USING (true); + +CREATE POLICY hard_close_snapshot_pair_snapshot_upgrade_visibility + ON accounting_reporting.trial_balance_snapshot + FOR SELECT + TO current_user + USING (true); + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM accounting_core.accounting_book_period_control AS period_control + WHERE period_control.period_status_code = 'hard_closed' + AND NOT EXISTS ( + SELECT 1 + FROM accounting_reporting.trial_balance_snapshot AS snapshot + WHERE snapshot.tenant_account_id = period_control.tenant_account_id + AND snapshot.accounting_book_id = period_control.accounting_book_id + AND snapshot.fiscal_period_id = period_control.fiscal_period_id + ) + ) THEN + RAISE EXCEPTION + 'pre-0036 hard-closed book-period authority has no retained trial balance; perform audited remediation before migration 0036 (hard_close_snapshot_pair_legacy_preflight)' + USING ERRCODE = '23514'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM accounting_reporting.trial_balance_snapshot AS snapshot + LEFT JOIN accounting_core.accounting_book_period_control AS period_control + ON period_control.tenant_account_id = snapshot.tenant_account_id + AND period_control.accounting_book_id = snapshot.accounting_book_id + AND period_control.fiscal_period_id = snapshot.fiscal_period_id + WHERE period_control.fiscal_period_id IS NULL + OR period_control.period_status_code IS DISTINCT FROM 'hard_closed' + ) THEN + RAISE EXCEPTION + 'pre-0036 retained trial balance lacks matching hard-closed book-period authority; perform audited remediation before migration 0036 (trial_balance_snapshot_hard_close_pair_legacy_preflight)' + USING ERRCODE = '23514'; + END IF; +END; +$$; + +DROP POLICY hard_close_snapshot_pair_snapshot_upgrade_visibility + ON accounting_reporting.trial_balance_snapshot; +DROP POLICY hard_close_snapshot_pair_control_upgrade_visibility + ON accounting_core.accounting_book_period_control; + +-- Migration 0035 proves snapshot -> hard_closed at commit. The inverse also +-- matters: accounting_book_period_control is the authoritative close fact, so a +-- hard_closed transition must not commit without the retained trial-balance +-- evidence that the supported close command promises to preserve. Keep this +-- check deferred because the canonical command inserts the snapshot before it +-- advances the book-period control in the same transaction. +CREATE OR REPLACE FUNCTION accounting_reporting.require_hard_close_trial_balance_snapshot_pair() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, pg_temp +AS $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM accounting_reporting.trial_balance_snapshot + WHERE trial_balance_snapshot.tenant_account_id = NEW.tenant_account_id + AND trial_balance_snapshot.accounting_book_id = NEW.accounting_book_id + AND trial_balance_snapshot.fiscal_period_id = NEW.fiscal_period_id + ) THEN + RAISE EXCEPTION + 'hard-closed book-period authority must commit with retained trial balance evidence (hard_close_snapshot_pair_required)' + USING ERRCODE = 'check_violation'; + END IF; + + RETURN NULL; +END; +$$; + +REVOKE ALL ON FUNCTION accounting_reporting.require_hard_close_trial_balance_snapshot_pair() + FROM PUBLIC; + +CREATE CONSTRAINT TRIGGER hard_close_trial_balance_snapshot_pair_guard + AFTER UPDATE OF period_status_code + ON accounting_core.accounting_book_period_control + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW + WHEN ( + OLD.period_status_code IS DISTINCT FROM 'hard_closed' + AND NEW.period_status_code = 'hard_closed' + ) + EXECUTE FUNCTION accounting_reporting.require_hard_close_trial_balance_snapshot_pair(); + +COMMIT; diff --git a/database/migrations/0037_soft_close_command_evidence_pair.sql b/database/migrations/0037_soft_close_command_evidence_pair.sql new file mode 100644 index 00000000..98768ab0 --- /dev/null +++ b/database/migrations/0037_soft_close_command_evidence_pair.sql @@ -0,0 +1,92 @@ +BEGIN; + +-- Migration 0010 made the three soft-close command-evidence fields internally +-- all-or-none, but it did not bind that evidence to the authoritative +-- accounting_book_period_control status. Migration 0009 can therefore leave a +-- legacy soft_closed control that was projected from fiscal_period before the +-- per-book command-evidence boundary existed. Do not fabricate evidence from a +-- later ledger state or silently reopen an authoritative close fact. Fail the +-- upgrade so remediation can be audited against the original close command. +-- +-- accounting_book_period_control is FORCE RLS. Give only the migration role a +-- transaction-scoped permissive SELECT policy for the preflight, then remove it +-- before the durable trigger is installed. This does not grant runtime DML or +-- weaken tenant isolation after commit. +CREATE POLICY soft_close_evidence_pair_upgrade_visibility + ON accounting_core.accounting_book_period_control + FOR SELECT + TO current_user + USING (true); + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM accounting_core.accounting_book_period_control AS period_control + WHERE period_control.period_status_code = 'soft_closed' + AND ( + period_control.soft_close_idempotency_key IS NULL + OR period_control.soft_close_source_payload_hash IS NULL + OR period_control.soft_close_source_journal_count IS NULL + ) + ) THEN + RAISE EXCEPTION + 'pre-0037 soft-closed book-period authority has no complete durable command evidence; perform audited remediation before migration 0037 (soft_close_command_evidence_pair_legacy_preflight)' + USING ERRCODE = '23514'; + END IF; +END; +$$; + +DROP POLICY soft_close_evidence_pair_upgrade_visibility + ON accounting_core.accounting_book_period_control; + +-- The supported soft-close command intentionally performs the status transition +-- and evidence write as two statements in one transaction. A deferred trigger +-- must therefore inspect the final retained row at commit rather than trust the +-- NEW image captured by the first UPDATE. This preserves that atomic command +-- while rejecting any transaction that commits soft_closed without all three +-- immutable evidence fields. +CREATE OR REPLACE FUNCTION accounting_core.require_soft_close_command_evidence_pair() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, pg_temp +AS $$ +DECLARE + evidence_complete boolean; +BEGIN + SELECT period_control.soft_close_idempotency_key IS NOT NULL + AND period_control.soft_close_source_payload_hash IS NOT NULL + AND period_control.soft_close_source_journal_count IS NOT NULL + INTO evidence_complete + FROM accounting_core.accounting_book_period_control AS period_control + WHERE period_control.tenant_account_id = NEW.tenant_account_id + AND period_control.accounting_book_id = NEW.accounting_book_id + AND period_control.fiscal_period_id = NEW.fiscal_period_id + AND period_control.period_status_code = 'soft_closed'; + + IF NOT COALESCE(evidence_complete, FALSE) THEN + RAISE EXCEPTION + 'soft-closed book-period authority must commit with durable command evidence (soft_close_command_evidence_pair_required)' + USING ERRCODE = 'check_violation'; + END IF; + + RETURN NULL; +END; +$$; + +REVOKE ALL ON FUNCTION accounting_core.require_soft_close_command_evidence_pair() + FROM PUBLIC; + +CREATE CONSTRAINT TRIGGER soft_close_command_evidence_pair_guard + AFTER UPDATE OF period_status_code + ON accounting_core.accounting_book_period_control + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW + WHEN ( + OLD.period_status_code IS DISTINCT FROM 'soft_closed' + AND NEW.period_status_code = 'soft_closed' + ) + EXECUTE FUNCTION accounting_core.require_soft_close_command_evidence_pair(); + +COMMIT; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 042c6a08..8c8ef347 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -67,6 +67,17 @@ The PostgreSQL 18 foundation is installed in order: 24. `database/migrations/0023_reconciliation_authority_outbox_orphan_guard.sql` — reserves reconciliation exception-resolution and run-reconciled event types for immutable command-backed authority, rejects orphan authority-shaped events at commit, and refuses installation over pre-existing orphan events after a migration-only forced-RLS visibility preflight. 25. `database/migrations/0024_reconciliation_control_recording_time_authority.sql` — makes reconciliation exception and retained review-evidence `recorded_at` database-owned at insertion while preserving their separate valid/business `effective_at`, so caller-shaped system time cannot manufacture temporal provenance used by maker-checker admission. 26. `database/migrations/0025_reconciliation_lifecycle_recording_time_authority.sql` — makes lifecycle-transition `recorded_at` database-owned at insertion and rejects a transition whose business-valid `effective_at` is later than PostgreSQL's recording time, preventing a future-effective decision from becoming current reconciliation authority. +27. `database/migrations/0026_reconciliation_lifecycle_source_payload_identity.sql` — binds reconciliation lifecycle authority to immutable source-payload identity rather than caller-shaped mutable transition evidence. +28. `database/migrations/0027_reconciliation_lifecycle_session_lock_authority.sql` — proves lifecycle snapshot freshness through the canonical session-lock lease and fresh authority transaction boundary. +29. `database/migrations/0028_reconciliation_lifecycle_capability_privileges.sql` — revokes generic PUBLIC execution of lifecycle session-lock helpers so later runtime grants can remain purpose-limited. +30. `database/migrations/0029_trial_balance_snapshot_population_unique_index.sql` — establishes one retained trial-balance snapshot population per tenant, accounting book, and fiscal period without rewriting retained evidence. +31. `database/migrations/0030_trial_balance_snapshot_immutability.sql` — freezes retained snapshot headers and lines, owns snapshot system time, and enforces book/entity/currency/account scope and hard-close admission at PostgreSQL. +32. `database/migrations/0031_trial_balance_line_conservation_validation.sql` — validates exact retained debit/credit/net conservation separately from the stronger installation lock phase. +33. `database/migrations/0032_period_close_journal_population_fence.sql` — invalidates stale `REPEATABLE READ` close attempts when purpose-limited soft-close journal population changes. +34. `database/migrations/0033_open_period_journal_population_fence.sql` — places ordinary open-period journals on a 64-stripe PostgreSQL freshness fence and makes period transitions lock the complete ordered fence set instead of serializing every post on one close-control row. +35. `database/migrations/0034_book_period_control_seed.sql` — materializes missing book-period controls and all 64 freshness rows for newly admitted active books or fiscal periods while restoring FORCE RLS before commit. +36. `database/migrations/0035_trial_balance_snapshot_hard_close_pair.sql` — defers snapshot-side validation to commit and rejects retained trial-balance evidence whose exact book-period control does not end `hard_closed`. +37. `database/migrations/0036_hard_close_trial_balance_snapshot_pair.sql` — defers the inverse status-side validation to commit and rejects a transition to `hard_closed` without the exact retained trial-balance snapshot. `0005_closed_period_guard.sql` makes `accounting_closing_writer` a `NOLOGIN` capability role. A soft-closed insert is admitted only when the session login is a member of that role **and** the transaction-local journal classification is `period_closing`, `adjusting`, or `reversal`. The GUC alone is not authority. Hard-closed periods reject every later journal insert. @@ -74,6 +85,8 @@ Deferred constraint triggers recompute persisted journal lines at commit. A dura `0020_reconciliation_exception_resolution_command.sql` keeps exception review inside the reconciliation aggregate. A direct `open -> resolved/superseded` row update is rejected unless a matching immutable command exists in the same transaction; the command and terminal status must commit as a pair, and terminal exception evidence freezes afterward. `0021_reconciliation_exception_resolution_outbox_pair.sql` extends that pair to the matching accounting outbox event and adds only the child resolution-evidence overlay. `0022_reconciliation_authority_outbox_retention.sql` then protects the committed authority/event relationship from later detachment or ambiguity: the sole matching event cannot be deleted or re-keyed away, a duplicate exact event cannot be inserted, and an unrelated event cannot be re-keyed into the same authority identity. `0023_reconciliation_authority_outbox_orphan_guard.sql` closes the inverse admission gap: the reserved `reconciliation_exception_resolved`, `reconciliation_exception_superseded`, and `reconciliation_run_reconciled` event types cannot exist without the exact immutable command whose tenant, aggregate reference, payload reference and command hash they claim to publish. Its upgrade preflight uses temporary current-user SELECT policies only to inspect forced-RLS history and removes them before the durable runtime guard is installed. `0024_reconciliation_control_recording_time_authority.sql` closes the temporal provenance gap on the maker and retained review artifact themselves: `recorded_at` is overwritten by PostgreSQL at INSERT while `effective_at` remains the business-valid-time fact. `0025_reconciliation_lifecycle_recording_time_authority.sql` applies the same database-clock boundary to the run-finalization command and fails closed when its valid time lies after the recording instant; caller-supplied lifecycle system time is not authority. Publication may still update `published_at`. The parent `accounting_reconciliation_transition_database_authority_guard` derives the bridge and all three transition identities first; child `accounting_reconciliation_transition_evidence_snapshot_guard` composes immutable resolution commands second; `accounting_reconciliation_transition_hash_guard` binds the final snapshot and population identities; the later recording-time trigger assigns only database system time and validates temporal causality. Final reconciliation accepts a terminal exception only when its target status agrees with the retained command. Exception resolution does not post a journal: any correcting journal remains a separate General Ledger command. +Migrations 0029–0036 keep Period Close evidence inside the `close_control` / `trial_balance` boundary. A retained snapshot cannot be relabelled across legal entity, accounting book, reporting currency, period, or chart-account scope, and its exact monetary population cannot be mutated after hard close. Journal-population freshness is a PostgreSQL concurrency control, not an alternate accounting authority: a stale close fails closed and the complete command must retry from a fresh transaction with the same immutable source identity/idempotency key. At commit, the retained snapshot and exact `hard_closed` book-period state are a bidirectional pair: neither side may persist without the other. + ## Runtime identity boundary The application runtime database login is separate from the migration owner and from administrative / break-glass identities. Tenant-scoped tables use RLS and the runtime path is tested with a non-owner, non-superuser, non-`BYPASSRLS` login. Purpose-limited soft-close exceptions use explicit role membership; ordinary runtime identities do not inherit `accounting_closing_writer`. @@ -82,20 +95,23 @@ The HTTP surface currently binds tenant identity through the configured AIS tena ## Posting transaction -A proposal follows one authoritative transaction boundary: +An ordinary proposal follows one authoritative transaction boundary: ```text validate published proposal -> bind tenant / entity / book / open period -> resolve semantic roles to chart accounts - -> acquire tenant/command transaction lock and shared fiscal-period advisory lock + -> acquire tenant/proposal/idempotency command locks -> persist immutable proposal evidence + -> admit journal through PostgreSQL book-period guard and 64-stripe freshness fence -> persist balanced journal header and lines -> persist authoritative posting receipt -> persist transactional outbox evidence -> COMMIT ``` +Ordinary open-period posting does **not** acquire the canonical period-close advisory mutex. PostgreSQL owns journal-versus-period-transition ordering: open-period journal admission uses the book-period guard plus a deterministic stripe, while a period transition locks the complete ordered fence population. The close command retains its canonical tenant/resolved-book-id/period advisory authority. This avoids turning an open book-period into one application-level posting mutex without weakening fail-closed close semantics. + Exact replay returns the original receipt. Reuse of an idempotency key with changed immutable evidence fails closed. Posted journal facts are append-only; corrections use reversal and, when required, a separately posted replacement. ## Reversal boundary @@ -108,7 +124,7 @@ A reversal accounting date may not precede the original accounting date. Soft-cl Soft-close changes the fiscal-period state but writes no hard-close snapshot. Ordinary posting is blocked; purpose-limited adjusting / closing / reversal paths may remain available under database authorization. -Hard-close loads one repeatable-read close package, posts the AIS-owned period-closing journal when required, stores the hard-close trial-balance snapshot and locks the period. Later ordinary or reversal inserts into that period are rejected. Close replay is idempotent and does not create a second snapshot or closing journal. +Hard-close loads one repeatable-read close package, posts the AIS-owned period-closing journal when required, stores the hard-close trial-balance snapshot and locks the period. A journal population change that races a stale close must invalidate that close rather than allow retained evidence to omit an admitted journal. The snapshot and exact book-period `hard_closed` state must survive commit together; either one-sided terminal state aborts the transaction. Later ordinary or reversal inserts into the hard-closed period are rejected. Close replay is idempotent and does not create a second snapshot or closing journal. ## Read models diff --git a/docs/OPERABILITY.md b/docs/OPERABILITY.md index 27bdadeb..209f12a7 100644 --- a/docs/OPERABILITY.md +++ b/docs/OPERABILITY.md @@ -2,7 +2,7 @@ ## Deployment preconditions -Use PostgreSQL 18 and keep the migration owner, application runtime login and administrative / break-glass identities separate. Apply migrations in the checked-in authority order through `0025_reconciliation_lifecycle_recording_time_authority.sql` before starting the service. Do not run the application with a table-owner, superuser or `BYPASSRLS` login. +Use PostgreSQL 18 and keep the migration owner, application runtime login and administrative / break-glass identities separate. Apply the complete checked-in authority chain through `0037_soft_close_command_evidence_pair.sql` before starting the service. Do not run the application with a table-owner, superuser or `BYPASSRLS` login. Required environment values are deployment-specific. At minimum, configure the accounting database URL and bind this AIS process to exactly one tenant reference. Secrets belong in an approved secret store; do not place database passwords, NTS credentials, bearer tokens or provider secrets in journal payloads, logs or outbox events. @@ -39,6 +39,18 @@ database/migrations/0022_reconciliation_authority_outbox_retention.sql database/migrations/0023_reconciliation_authority_outbox_orphan_guard.sql database/migrations/0024_reconciliation_control_recording_time_authority.sql database/migrations/0025_reconciliation_lifecycle_recording_time_authority.sql +database/migrations/0026_reconciliation_lifecycle_source_payload_identity.sql +database/migrations/0027_reconciliation_lifecycle_session_lock_authority.sql +database/migrations/0028_reconciliation_lifecycle_capability_privileges.sql +database/migrations/0029_trial_balance_snapshot_population_unique_index.sql +database/migrations/0030_trial_balance_snapshot_immutability.sql +database/migrations/0031_trial_balance_line_conservation_validation.sql +database/migrations/0032_period_close_journal_population_fence.sql +database/migrations/0033_open_period_journal_population_fence.sql +database/migrations/0034_book_period_control_seed.sql +database/migrations/0035_trial_balance_snapshot_hard_close_pair.sql +database/migrations/0036_hard_close_trial_balance_snapshot_pair.sql +database/migrations/0037_soft_close_command_evidence_pair.sql ``` Migration `0015_reconciliation_multi_match_conservation.sql` replaces the run-wide single-approved-match shortcut from `0014` with tenant/run-scoped match identity plus exact statement/journal allocation conservation. It permits multiple independently approved matches, including split and aggregate allocation populations, only when no authoritative source amount is over-consumed and grants no journal-posting authority. @@ -51,7 +63,7 @@ Migration `0018_bank_statement_balance_evidence.sql` preserves the exact numeric Migration `0019_reconciliation_run_command_evidence.sql` records the immutable command identity that opens a reconciliation run from one persisted bank statement and active bank-account assignment, then adds the evidence-derived run-finalization command and shared reconciliation command-identity namespace. The tenant-scoped idempotency key, command hash, source hash, and object-store reference are forced-RLS evidence; new runs exclude source facts recorded after `knowledge_cutoff_at`, and a deferred database guard requires one command per run with statement-to-assignment bank-account provenance. The public run API opens only `evaluating` scope and cannot post journals or close periods. -The unreleased lifecycle-parent overlay `0019_reconciliation_run_database_snapshot_authority.sql` must run after the base 0019 migration and before migrations 0020/0021/0022/0023/0024/0025. It defines `accounting_core.reconciliation_run_database_snapshot_authority`, independently reconstructs the exact bank-statement and assigned cash-book populations and bridge, and replaces caller-selected transition snapshot, statement-population, and book-population identities. The supported installer treats this overlay as part of the complete chain even though its filename shares the base numeric prefix; do not sort migrations by filename alone and accidentally omit it. +The unreleased lifecycle-parent overlay `0019_reconciliation_run_database_snapshot_authority.sql` must run after the base 0019 migration and before migrations 0020 through 0037. It defines `accounting_core.reconciliation_run_database_snapshot_authority`, independently reconstructs the exact bank-statement and assigned cash-book populations and bridge, and replaces caller-selected transition snapshot, statement-population, and book-population identities. The supported installer treats this overlay as part of the complete chain even though its filename shares the base numeric prefix; do not sort migrations by filename alone and accidentally omit it. Migration `0020_reconciliation_exception_resolution_command.sql` makes exception resolution a named maker-checker command rather than a mutable status shortcut. The database requires an active reviewable run, one open tenant/run/exception, a reviewer distinct from the exception owner, retained evidence reference/hash, temporal causality, the shared reconciliation idempotency namespace, and a database-derived command hash. A raw terminal status update is rejected unless its matching command already exists in the transaction; a deferred pair guard requires command and terminal status to commit together, then terminal exception evidence is immutable. Run finalization accepts an exception only when its terminal status and retained resolution command agree. This command does not post a journal; any correcting journal is a separate authorized General Ledger command. @@ -65,9 +77,23 @@ Migration `0024_reconciliation_control_recording_time_authority.sql` makes `reco Migration `0025_reconciliation_lifecycle_recording_time_authority.sql` has a stricter upgrade contract because a lifecycle transition already makes a run `reconciled` and can support close evidence. Migration 0019 allowed privileged callers to supply transition `recorded_at`, so a pre-0025 transition timestamp cannot be proven retroactively. Before any durable 0025 schema change, a temporary `FOR SELECT TO current_user USING (true)` policy exposes the forced-RLS transition history to the migration owner; if any transition row exists, the migration aborts with `reconciliation_lifecycle_legacy_recording_time_preflight`. Do not delete, rewrite, relabel, or invent system-time provenance to make this pass. Keep the prior release or execute a separately reviewed audited remediation backed by the original transition/status/outbox evidence. Creating a new run alone does not remove the old immutable transition and cannot satisfy the preflight. On databases that pass, new transition rows carry explicit `recording_time_authority_code = 'database_clock'`; PostgreSQL overwrites any caller-shaped `recorded_at` with `clock_timestamp()` and rejects `effective_at > recorded_at` using `reconciliation_lifecycle_future_time`. A rejected future-effective transition leaves command, run status, and outbox authority rolled back together. This migration does not post/reverse journals, close fiscal periods, or alter accounting policy. +Migrations `0026_reconciliation_lifecycle_source_payload_identity.sql` through `0028_reconciliation_lifecycle_capability_privileges.sql` finish the reconciliation lifecycle authority boundary. Source-payload identity is immutable and database-bound, lifecycle snapshot freshness is established through the canonical session-lock lease plus a fresh authority transaction, and generic PUBLIC execution of the lifecycle session-lock helpers is revoked. A consumer must use these installed controls rather than reconstruct reconciliation authority from caller payloads or mutable application state. + +Migrations `0029_trial_balance_snapshot_population_unique_index.sql` through `0031_trial_balance_line_conservation_validation.sql` establish one retained trial-balance population per tenant/book/period, freeze retained headers and lines, derive system time and aggregate scope at PostgreSQL, and validate exact `numeric(38,6)` debit/credit/net conservation. Migration 0029 uses a concurrent unique-index build and therefore has a distinct failed-install recovery state: an invalid concurrent index may remain and must be inspected/dropped or rebuilt before retrying the supported chain; do not rewrite retained evidence to make the index succeed. + +Migrations `0032_period_close_journal_population_fence.sql` and `0033_open_period_journal_population_fence.sql` protect hard-close freshness under `REPEATABLE READ`. Purpose-limited soft-close journals invalidate a stale close through the exact book-period control row. Ordinary open-period journals update one of 64 pre-existing fence rows, while a period transition locks all 64 in deterministic order. SQLSTATE `40001` is coordination failure: the whole command must roll back and retry from a fresh transaction with the same immutable command identity. Do not retry only the failed statement or retain a partial close artifact. + +Migration `0034_book_period_control_seed.sql` owns post-install book-period authority materialization. New open periods, new active books, and inactive-to-active book transitions seed only literal `open` controls with `period_closed_at = NULL` plus the complete 64-row fence. A later-created book does not inherit `soft_closed` or `hard_closed` from the shared fiscal-period compatibility projection. The migration temporarily uses owner-only `NO FORCE ROW LEVEL SECURITY` on its participating relations while RLS remains enabled, then restores FORCE before commit. A missing non-open book-period control is a fail-closed lifecycle gap, not permission to synthesize authority manually. + +Migrations `0035_trial_balance_snapshot_hard_close_pair.sql` and `0036_hard_close_trial_balance_snapshot_pair.sql` make the retained snapshot and exact book-period `hard_closed` state a bidirectional commit pair. Migration 0035 rejects retained snapshot evidence unless the exact control ends hard-closed. Migration 0036 rejects a transition to hard-closed unless the exact retained snapshot exists. Both future-write guards are deferred so the supported snapshot-first/status-second hard-close transaction remains valid. + +Migration 0036 also certifies pre-existing pair state before installing its future-write guard. It creates transaction-scoped `FOR SELECT TO current_user USING (true)` policies on the two FORCE-RLS pair relations, rejects a pre-existing hard-closed control without an exact snapshot using `hard_close_snapshot_pair_legacy_preflight`, and rejects a retained snapshot without an exact hard-closed control using `trial_balance_snapshot_hard_close_pair_legacy_preflight`. It drops both temporary policies before installing the durable trigger. If either marker appears, stop the upgrade; do not manufacture the missing status or snapshot. Retain the prior release or execute a separately reviewed audited remediation that can prove the original close command, journal population, scope/currency and retained numerical evidence. + +Migration `0037_soft_close_command_evidence_pair.sql` binds `soft_closed` book-period authority to the complete original soft-close command-evidence triplet: idempotency key, source-payload hash and source-journal count. The durable guard is `DEFERRABLE INITIALLY DEFERRED`, so the supported status update and evidence write may occur as separate statements in one transaction but must be complete at commit. `soft_close_command_evidence_pair_required` aborts the complete transaction; roll it back and retry the supported command from a fresh transaction with the same immutable command identity. `soft_close_command_evidence_pair_legacy_preflight` means a pre-0037 soft-closed control cannot be certified from retained original evidence. Stop the upgrade and preserve the prior database state. Do not fabricate the missing hash/count from later ledger state and do not change the authoritative period state to `open` merely to satisfy the migration. Any remediation must be separately reviewed and auditable against the original close command. + Migration `0007_runtime_tenant_binding.sql` replaces caller-selected tenant authority with owner-controlled runtime-login binding. Migration `0008_fiscal_period_open_command.sql` adds forced-RLS, append-only command evidence so fiscal-period-open retries are bound to the original tenant key and source hash. Both must be installed before runtime database privileges are treated as production-ready. -After installation, prove with the actual runtime login that supported reads and writes work for its tenant, another tenant is inaccessible, the login is not a migration owner / superuser / `BYPASSRLS`, and direct SQL cannot bypass journal immutability, period controls, reconciliation lifecycle controls, exception-resolution authority, reconciliation authority-event admission, or reconciliation recording-time provenance. +After installation, prove with the actual runtime login that supported reads and writes work for its tenant, another tenant is inaccessible, the login is not a migration owner / superuser / `BYPASSRLS`, and direct SQL cannot bypass journal immutability, period controls, soft-close command-evidence pairing, reconciliation lifecycle controls, exception-resolution authority, reconciliation authority-event admission, reconciliation recording-time provenance, or hard-close/snapshot pairing. ## Concurrency and hot-write operations @@ -153,11 +179,17 @@ Current PostgreSQL integration tests prove exact reversal replay is bound to ten ### Soft-close -Soft-close changes the period to `soft_closed`, writes no hard-close trial-balance snapshot and blocks ordinary posting. Authorized closing, adjusting and reversal paths require both the transaction classification and `accounting_closing_writer` membership. +Soft-close changes the period to `soft_closed`, writes no hard-close trial-balance snapshot and blocks ordinary posting. Authorized closing, adjusting and reversal paths require both the transaction classification and `accounting_closing_writer` membership. Migration 0037 additionally requires the committed `soft_closed` state and the complete original soft-close command-evidence triplet to exist together at transaction commit. `soft_close_command_evidence_pair_required` rolls the entire transaction back; retry the supported soft-close command from a fresh transaction with the same immutable command identity rather than patching evidence or period state manually. + +During upgrade, `soft_close_command_evidence_pair_legacy_preflight` means a pre-0037 soft-closed row has no complete durable original command evidence. Preserve the database and stop the upgrade. Do not derive a replacement payload hash or journal count from later ledger state, and do not reopen the period to make the preflight pass. Use only a separately reviewed audited remediation if the original command evidence can actually be proven. ### Hard-close -Hard-close loads the close binder in one repeatable-read view, posts the AIS period-closing journal when required, stores the hard-close snapshot and changes the period to `hard_closed`. Hard-close is irreversible in this foundation. Open a later period for subsequent activity. +Hard-close loads the close binder in one repeatable-read view, posts the AIS period-closing journal when required, stores the hard-close snapshot and changes the exact book-period control to `hard_closed`. Hard-close is irreversible in this foundation. Open a later period for subsequent activity. + +The retained snapshot and exact `hard_closed` control are a commit-time pair. `trial_balance_snapshot_hard_close_pair_required` means a snapshot tried to commit without matching hard-close authority. `hard_close_snapshot_pair_required` means hard-close authority tried to commit without the matching retained snapshot. Either error aborts the complete transaction; retry the supported close command from a fresh transaction with the same immutable idempotency/source identity. Do not patch either side manually. + +During upgrade, `hard_close_snapshot_pair_legacy_preflight` or `trial_balance_snapshot_hard_close_pair_legacy_preflight` means pre-existing evidence cannot be certified as a complete pair. Stop before migration 0036, preserve the database, inventory the exact control/snapshot/line and original close evidence, and use only a separately reviewed audited remediation if the missing provenance can actually be proven. Do not insert a synthetic snapshot or flip a status to satisfy the preflight. If close fails, inspect the first causal missing catalog / mapping / balance / period error. Do not invent a snapshot or mark a period closed manually. @@ -213,7 +245,7 @@ Do not transfer a success from a predecessor SHA or synthetic merge ref to the c ## Backup, restore and recovery -Before release, rehearse clean install, forward migration, rollback strategy, backup restore and point-in-time recovery with production-like data volumes and the non-owner runtime identity. Restoration must preserve immutable journal / receipt / outbox lineage and tenant isolation. +Before release, rehearse clean install, forward migration, rollback strategy, backup restore and point-in-time recovery with production-like data volumes and the non-owner runtime identity. Restoration must preserve immutable journal / receipt / outbox lineage, retained trial-balance evidence, hard-close/snapshot pairing, soft-close command evidence and tenant isolation. Forward-upgrade rehearsal must include the 0036 one-sided hard-close/snapshot pair preflight and the 0037 legacy soft-close command-evidence preflight, and prove that an aborted preflight leaves neither temporary migration policy nor partial durable trigger state. Recovery from an accounting error is not database row editing. Restore infrastructure only for infrastructure loss; correct economic facts with reversal / reposting according to accounting policy. diff --git a/docs/adr/0006-fiscal-period-close-snapshot.md b/docs/adr/0006-fiscal-period-close-snapshot.md index 031e28fa..2411eada 100644 --- a/docs/adr/0006-fiscal-period-close-snapshot.md +++ b/docs/adr/0006-fiscal-period-close-snapshot.md @@ -2,20 +2,149 @@ **Status:** Superseded in part by ADR 0023 +## Problem + +Hard close certifies a retained trial-balance population as Accounting Information Platform evidence. That population must not be caller-shaped, mutable after close, cross an accounting-book aggregate boundary, drift arithmetically, or omit a journal that was validly admitted before close authority won. + +The implementation also has to preserve posting throughput. `accounting_book_period_control` is one row per tenant/accounting-book/fiscal-period. Updating that row for every ordinary open-period journal would turn a correctness fence into a hot exclusive-write point and serialize otherwise independent postings in the busiest lifecycle state. A shared lock on that row alone is also insufficient: it prevents a period transition from overtaking a still-running journal, but it does not prove that a journal committed after a close transaction established its `REPEATABLE READ` snapshot. + ## Decision -`PostgresPostingLedger.close_fiscal_period` is the first-class close command. The current authoritative sequence is the two-step rule defined by ADR 0023. A `soft_closed` command changes the fiscal-period status only: it writes no `trial_balance_snapshot`, no `trial_balance_line`, and no period-closing journal. A later `hard_closed` command posts the AIS period-closing journal first, computes the live trial balance for the tenant, legal entity, and accounting book through the period end date, persists exactly one `trial_balance_snapshot` population, and then hard-closes the period in the same governed transaction. Posted journals are never rewritten. +`PostgresPostingLedger.close_fiscal_period` remains the first-class close command. ADR 0023 owns the lifecycle: `soft_closed` changes period state only and creates no retained trial-balance population. `hard_closed` acquires tenant/resolved-book/period command authority, writes a period-closing journal when required, derives the live trial balance from AIS-owned PostgreSQL facts through the period end, persists one retained snapshot population, and hard-closes the period in one governed transaction. Direct `open` → `hard_closed` remains supported; `open` → `soft_closed` → `hard_closed` is also supported. Posted journals are never rewritten. + +An exact hard-close replay returns the retained result and creates no second snapshot, journal, or close event. `hard_closed` cannot transition back to `soft_closed`. Soft-close replay remains snapshot-free. + +Commit-time pairing is bidirectional. Migration `0035_trial_balance_snapshot_hard_close_pair.sql` rejects a retained snapshot that would commit without matching `hard_closed` book-period authority. Migration `0036_hard_close_trial_balance_snapshot_pair.sql` rejects an `accounting_book_period_control` transition to `hard_closed` that would commit without the matching retained snapshot. Both guards are `DEFERRABLE INITIALLY DEFERRED`, so the canonical snapshot-first/status-second hard-close ordering remains valid while either one-sided terminal state fails closed at commit. The unique tenant/book/period snapshot identity makes the retained counterpart unambiguous. This is an AIP database consistency invariant, not an IFRS-prescribed PostgreSQL mechanism. + +### Retained population identity and immutability + +Migration `0029_trial_balance_snapshot_population_unique_index.sql` builds the unique `(tenant_account_id, accounting_book_id, fiscal_period_id)` population identity with `CREATE UNIQUE INDEX CONCURRENTLY` outside a transaction block. Migration `0030_trial_balance_snapshot_immutability.sql` attaches that index as `trial_balance_snapshot_one_population_per_book_period`, installs header/line mutation guards, and serializes snapshot and line admission on the exact `accounting_book_period_control` row. + +Snapshot and line UPDATE/DELETE fail with `trial_balance_snapshot_immutable`. New population after `hard_closed` is rejected. A visible competing population fails with `trial_balance_snapshot_population_conflict`; the unique constraint independently closes the stale-snapshot race when a `REPEATABLE READ` trigger query cannot see a concurrently committed population. + +Every retained line must satisfy the exact PostgreSQL `numeric(38, 6)` invariant + +`net_balance_amount = debit_total_amount - credit_total_amount`. + +Migration 0030 adds `trial_balance_line_net_balance_conservation` as `NOT VALID`; migration `0031_trial_balance_line_conservation_validation.sql` validates inherited rows after 0030 has committed so the stronger ADD-CONSTRAINT lock is not retained through the validation scan. + +### Aggregate and authority scope + +A retained snapshot header must use the legal entity that owns the selected accounting book, and `snapshot_currency_code` must equal that book's `reporting_currency_code`. Every retained `trial_balance_line.chart_account_id` must belong to the same accounting book. Tenant-scoped identifiers that are independently valid cannot be recombined across those aggregate boundaries. + +The canonical hard-close advisory key is `hashtext(tenant_reference)` plus `hashtext('period:' || accounting_book_id::text || ':' || period_code)`. PostgreSQL reconstructs the resolved accounting-book identity rather than trusting caller-facing `book_name`. `snapshot_generated_at` is database-owned system time and is replaced with `clock_timestamp()` even for an otherwise authorized closing writer. + +Migration `0033_open_period_journal_population_fence.sql` preserves the purpose-limited snapshot writer while restoring the supported direct `open` → `hard_closed` path. A snapshot created while the book-period is still `open` requires `accounting_closing_writer` capability **and** the exact tenant/resolved-book/period close advisory lock; a bare role plus `accounting_core.journal_write_role` cannot pre-populate open-period retained evidence. While `soft_closed`, the existing purpose-limited `period_closing` command context or the canonical close lock remains sufficient together with role membership. + +### Journal-population freshness without a single database-row hotspot + +`close_fiscal_period` uses `REPEATABLE READ`. A close transaction can therefore hold an MVCC snapshot that predates a journal committed while close is waiting on period authority. Waiting for a lock does not refresh that snapshot. + +Migration `0032_period_close_journal_population_fence.sql` first split the database control profile: + +- an `open` journal takes `SELECT ... FOR SHARE` on the exact book-period control row so a state transition cannot overtake a journal already admitted as open; +- a purpose-limited `soft_closed` period-closing/adjusting/reversal journal increments `journal_population_revision` on that control row, so a stale hard close that later locks the row fails with SQLSTATE `40001`. + +Review then exposed a direct-open race for journal commands that do not share the close advisory mutex. The direct-open regression uses the AIS adjusting-journal path: it has its own idempotency mutex and may legitimately write while the book-period is `open`, but it does not acquire the ordinary Billing proposal's tenant/book/period advisory lock. A close can therefore establish an older repeatable-read snapshot before that journal commits. + +Migration `0033_open_period_journal_population_fence.sql` adds a bounded row-version witness: + +- every book-period owns exactly 64 pre-existing `period_journal_population_fence` rows; migration backfill creates them before FORCE RLS is enabled, and an AFTER INSERT trigger seeds future book-period controls; +- after confirming `open` under `FOR SHARE`, each journal increments exactly one fence row selected from its database journal UUID; +- before `period_status_code` changes, a transition trigger locks all 64 rows in deterministic slot order with `FOR UPDATE` and requires the complete population; +- if any fence row was committed after the close transaction's repeatable-read snapshot, PostgreSQL raises serialization failure instead of allowing the stale transition to commit; +- if the transition owns the control row first, a later journal cannot retain stale open-state admission after waiting. + +The 64-slot count is an engineering hypothesis, not accounting policy and not an IFRS requirement. Exact-head load tests must measure slot collisions, row-lock waits, WAL/write cost, retry rate, and buyer-path p95. A future slot-count change requires measured evidence and a migration-compatible design. + +A serialization failure is not accounting evidence. The entire transaction rolls back and the command retries from the beginning with the same immutable source-payload identity and idempotency key. No failed close may leave a retained snapshot, period-closing journal, close event, or authoritative period transition. + +### Master-data seeding under forced tenant RLS + +Migration `0034_book_period_control_seed.sql` keeps the post-install book-period authority lifecycle fail closed without treating `fiscal_period.period_status_code` as per-book close evidence. `fiscal_period` retains tenant/calendar dates and an aggregate compatibility projection; `accounting_book_period_control` is the authoritative tenant/book/period close-state intersection. A newly inserted book therefore cannot inherit `soft_closed`, `hard_closed`, or `period_closed_at` merely because the shared period projection has that value. + +Automatic seeding is limited to actually open periods. A newly inserted `fiscal_period` seeds active books only when its initial status is `open`, and those new controls start as literal `open` with no close timestamp. A newly inserted active `accounting_book` scans only fiscal periods whose compatibility status is currently `open`, again creating literal-open controls. The migration repair backfill follows the same rule for missing rows. A non-open period without an existing book-period control remains absent, so journal and close admission fail with missing-authority errors rather than fabricating a close state. A future command that intentionally makes an existing closed period applicable to a later-created book must define explicit book-period lifecycle/reopen evidence; this migration does not synthesize that authority. + +Migration 0009 is different: it converts the pre-book-scoped schema into the first book-period control population, so the then-existing fiscal-period state is the legacy source being migrated. That one-time conversion is not a precedent for copying the compatibility projection into post-install book authority. + +The two 0034 insert triggers still need a complete cross-product for open periods. Both acquire the same pre-existing tenant-row MVCC version witness before scanning the peer population. At Read Committed, the later writer waits and then observes the peer commit. At Repeatable Read or Serializable, a stale snapshot fails with SQLSTATE `40001` and the complete master-data command must retry from a fresh transaction. This prevents concurrent new-book/new-open-period transactions from each missing the other's row. + +PostgreSQL normally lets a table owner bypass row security, but `FORCE ROW LEVEL SECURITY` makes that owner subject to the policy; `SUPERUSER` and `BYPASSRLS` remain separate exceptional capabilities. A production migration owner is not required to hold either capability or a runtime tenant binding. Migration 0005 has already forced the `accounting_book` and `fiscal_period` seed sources through tenant RLS by the time migration 0009 runs, so target ordering alone is insufficient: an unbound owner can otherwise see no source rows. + +Migration 0009 therefore restores normal owner visibility with `NO FORCE ROW LEVEL SECURITY` on `accounting_book` and `fiscal_period`, performs the legacy all-tenant conversion, restores FORCE on both sources, and only then enables/forces RLS on the new control target. Migration 0034 keeps RLS enabled but temporarily applies `NO FORCE ROW LEVEL SECURITY` to all four participating tables—`accounting_book`, `fiscal_period`, `accounting_book_period_control`, and `period_journal_population_fence`—for the owner-only repair window, then restores FORCE on all four before the migration transaction commits. This is not a runtime gate weakening: non-owner roles remain subject to RLS throughout and the committed schema remains FORCE RLS protected. + +The migration must run as owner of the participating relations; it must not solve installation by granting `BYPASSRLS`, impersonating a tenant, disabling row security, or copying tenant truth into an installer-side cache. `tests/test_book_period_control_seed_contract.py` pins source and target ordering, open-only authority derivation, and the prohibition on `DISABLE ROW LEVEL SECURITY`. `tests/test_book_period_control_seed.py` exercises the real PostgreSQL master-data lifecycle, including the requirement that a later-created book does not inherit a shared non-open period projection. Exact release acceptance still needs a real PostgreSQL migration run with a production-like unbound `NOSUPERUSER`/`NOBYPASSRLS` owner; static ordering evidence alone is not execution GREEN. + +### Application advisory-lock repair + +The striped database fence initially was not an end-to-end posting-concurrency repair. `PostgresPostingLedger._require_open_book_period_bounds()` acquired the same exclusive tenant/resolved-book/period advisory lock used by `close_fiscal_period()` for every ordinary Billing proposal. That made unrelated ordinary postings for one open book-period queue before either reached the striped database boundary. + +This application mutex is rejected as ordinary journal-versus-transition authority. Real-PostgreSQL RED `1683fd5f8e21e907a187bea7c239e3d30f8d0bdb` pauses one ordinary proposal after period admission but before journal persistence and requires another ordinary proposal to complete before the first resumes. Static causal RED `839e930a4f24eda1083742578894479a8ed968bf` pinned removal of the close-command advisory acquisition from `_require_open_book_period_bounds()`. + +Production repair `430f4dde6757c8bf09243a00787dabcfa97ab49c` removes only the `period:{book_id}:{period_code}` advisory acquisition from the ordinary helper. Proposal/idempotency locks remain. The helper retains explicit fail-closed application validation when the resolved book-period is not `open`. Close commands retain the canonical period advisory lock. PostgreSQL `FOR SHARE` plus the pre-existing striped witness remains the authoritative journal-versus-state-transition fence. + +Successor `70c07aba7c51391b9ee965fe3948b23c9546642d` strengthens `tests/test_open_period_application_lock_contract.py`: any command-lock acquisition inside the ordinary helper now fails the source ratchet, and the test requires the explicit non-open-period validation to remain. It deliberately removes an earlier `period_status_code` token-count assertion that overstated a before/after application check not present in the implementation. + +The causal source defect is repaired, but exact-head execution evidence is still required before the branch claims end-to-end GREEN or the p95 target. `tests/test_postgres_open_period_journal_fence.py::test_open_period_postings_do_not_serialize_on_application_period_lock` is the real PostgreSQL overlap acceptance, and the same unchanged exact head must also pass the complete Accounting Foundation/security/SAST/dependency/release evidence chain. + +`tests/test_postgres_period_close_journal_serialization_red.py` exercises a `soft_closed` adjustment racing hard close. `tests/test_postgres_open_period_close_serialization_red.py` exercises an open-period adjusting journal committed after a direct-close snapshot, then requires stale-close rollback and exact-key retry to retain the live population. The latter remains a valid correctness case because the adjusting command does not take the ordinary proposal's book-period advisory mutex. + +## Alternatives considered + +Updating one `journal_population_revision` for every admitted journal was rejected because it makes one book-period row the exclusive write point for all ordinary posting. + +Using only the control-row `FOR SHARE`/`FOR UPDATE` protocol was rejected because it orders transaction completion around the state change but supplies no row version proving that a journal committed after a pre-existing repeatable-read snapshot. + +Using the canonical close advisory mutex for every ordinary proposal is rejected as a throughput strategy. It serializes independent postings at application scope and duplicates ordering already owned by the database admission/transition fence. The mutex remains appropriate for close-command serialization and close-snapshot authority. + +Relying only on advisory-lock wait for freshness was rejected because a waiting repeatable-read transaction retains the snapshot established before lock grant. Advisory-lock ownership and MVCC freshness are separate facts. + +A session-lock-before-snapshot protocol was considered because reconciliation lifecycle already uses a committed session-lease pattern. It was not selected here because journal admission coordination can remain a database-owned book-period invariant without extending application-session lock lifetime across transaction boundaries. This can be revisited if measured stripe contention or transition fan-out is unacceptable. + +Lazy creation of a fence row during journal admission was rejected because a repeatable-read close whose snapshot predates that INSERT can fail to see the new row. The complete fence population must exist before any journal/close race. + +Using only a trigger-side existence query for retained snapshots was rejected because a fixed MVCC snapshot cannot observe a competing population committed after that snapshot. Physical unique population identity remains required. + +Copying `fiscal_period.period_status_code` or `period_closed_at` into a control for a later-created book was rejected because those fields are only the shared compatibility projection after book-scoped close authority exists. Doing so would manufacture `soft_closed` or `hard_closed` without that book's close command, maker-checker evidence, retained snapshot, or close lineage. Open-only automatic seeding is the conservative post-install behavior; non-open applicability requires an explicit future lifecycle command. + +Allowing caller-provided close timestamps, currencies, aggregate identifiers, or retained arithmetic was rejected because those values are accounting evidence and must be derived or verified at the authoritative database boundary. + +Granting the migration role `BYPASSRLS`, disabling RLS for the backfill, or binding an infrastructure role to a fabricated runtime tenant was rejected. Those approaches turn an installation concern into standing or misleading runtime authority. Owner-only `NO FORCE` inside the uncommitted migration preserves RLS for runtime roles and restores FORCE before any new schema state commits. + +## Consequences and operational evidence + +At the database boundary, open-period posting performs a shared control-row lock plus one striped revision UPDATE rather than one exclusive UPDATE on the common book-period row. The application no longer adds the close-command period advisory mutex to ordinary proposals. This removes the two deliberate common serialization points identified by the RED lineage, but it is not itself a latency result. + +PostgreSQL row locking and same-slot collisions still have measurable cost. Release evidence must use realistic concurrent posting and transition workloads and report advisory-lock waits, row-lock waits, stripe distribution, failures, retry rates, WAL/write cost, and tail latency without sample reduction, excluded failures, or artificial cache warm-up. + +Migration 0033 creates a new tenant-scoped table under RLS/FORCE RLS. Its initial cross-tenant fence backfill occurs before FORCE RLS is enabled. Migrations 0009/0034 now also account for the already forced source tables: the migration owner gets only a transactional owner-bypass window on the exact participating relations, while RLS remains enabled for non-owner roles and FORCE is restored before commit. Migration 0034 seeds future and repair rows only where the shared period is open; missing non-open book-period authority remains fail closed rather than being inferred. Future created controls are seeded from the book-period-control INSERT transaction and stay in that tenant scope. Guard functions are `SECURITY DEFINER`, use `search_path = pg_catalog, pg_temp`, and revoke PUBLIC execute. + +The migration chain has distinct recovery states. A failed 0029 concurrent index build can leave an invalid index that operators must remove or rebuild before retry. A failed 0031 validation leaves the constraint enforced for subsequent writes but inherited rows uncertified. Failed 0033 or 0034 migration transactions roll back their tables/triggers/functions/policies or owner-force toggles and seed population together. A failed 0035 or 0036 installation rolls back its deferred trigger/function pair. A runtime SQLSTATE `40001` leaves no authoritative close result and requires a whole-command retry. A `hard_close_snapshot_pair_required` or `trial_balance_snapshot_hard_close_pair_required` failure rolls back the whole transaction; operators must retry the supported close command rather than patch either side of the pair. Recovery must never normalize or rewrite posted journals, reconciliation evidence, or retained close facts. + +Future reopen/correction is not implemented here. Any later reopen policy must preserve the prior hard-close population through explicit successor lineage and a replacement population identity/version invariant rather than mutating retained evidence or weakening uniqueness. + +## Exact soft-close replay + +Migration `0010_soft_close_command_evidence.sql` stores the original tenant-scoped soft-close idempotency key, source-journal count, and canonical close-source SHA-256 on the book-period control row in the same transaction as the state transition and outbox event. Exact replay returns those stored facts and never recomputes historical evidence from later ledger state. A different key for an already soft-closed book-period is an idempotency conflict. + +`snapshot_currency_code` remains required on soft close because it participates in the canonical close-source digest even though soft close creates no retained trial-balance snapshot. + +## References + +PostgreSQL Global Development Group. (2026a). *PostgreSQL 18 documentation: Transaction isolation*. https://www.postgresql.org/docs/18/transaction-iso.html + +PostgreSQL Global Development Group. (2026b). *PostgreSQL 18 documentation: Explicit locking*. https://www.postgresql.org/docs/18/explicit-locking.html -An exact hard-close replay returns the existing snapshot and writes no second snapshot, closing journal, or close event. A `hard_closed` period cannot transition back to `soft_closed`. Soft-close replay remains snapshot-free. +PostgreSQL Global Development Group. (2026c). *PostgreSQL 18 documentation: Serialization failure handling*. https://www.postgresql.org/docs/18/mvcc-serialization-failure-handling.html -## Consequences +PostgreSQL Global Development Group. (2026d). *PostgreSQL 18 documentation: CREATE INDEX*. https://www.postgresql.org/docs/18/sql-createindex.html -Controllers close books through the posting adapter instead of a raw status update. Ordinary posting is rejected for every non-open period. The database insert guard in `0005_closed_period_guard.sql` permits only purpose-limited AIS close/adjust/reversal writes while a period is `soft_closed`; every insert is rejected once the period is `hard_closed`. The caller-controlled `accounting_core.journal_write_role` GUC is classification metadata, not sufficient authorization by itself. +PostgreSQL Global Development Group. (2026e). *PostgreSQL 18 documentation: ALTER TABLE*. https://www.postgresql.org/docs/18/sql-altertable.html -The former snapshot-on-soft-close and snapshot-reuse-on-upgrade wording in this ADR is superseded by ADR 0023. Operational and reporting code must therefore treat the hard-close snapshot as the only persisted post-close trial-balance population and must never infer that a soft-close created one. +PostgreSQL Global Development Group. (2026f). *PostgreSQL 18 documentation: Unique indexes*. https://www.postgresql.org/docs/18/indexes-unique.html -## Exact soft-close command replay +PostgreSQL Global Development Group. (2026g). *PostgreSQL 18 documentation: pg_locks*. https://www.postgresql.org/docs/18/view-pg-locks.html -Soft-close deliberately stores no trial-balance snapshot, but it is still an authoritative state-changing command. Migration `0010_soft_close_command_evidence.sql` records the original tenant-scoped soft-close idempotency key, source-journal count and canonical close-source SHA-256 on the book-period control row in the same transaction as the state transition and outbox event. Exact replay returns those stored facts and never recomputes historical evidence from later ledger state. A different key for an already soft-closed book-period is an idempotency conflict, and database trigger protection prevents rewriting evidence once recorded. +PostgreSQL Global Development Group. (2026h). *PostgreSQL 18 documentation: Advisory lock functions*. https://www.postgresql.org/docs/18/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS -`snapshot_currency_code` remains required on soft-close because it participates in the canonical close-source digest even though no hard-close snapshot row is created. This makes the command evidence exact without representing soft-close as a trial-balance snapshot. +PostgreSQL Global Development Group. (2026i). *PostgreSQL 18 documentation: Row security policies*. https://www.postgresql.org/docs/18/ddl-rowsecurity.html \ No newline at end of file diff --git a/docs/adr/0024-hard-close-retained-earnings.md b/docs/adr/0024-hard-close-retained-earnings.md index 96363ad8..e153dd81 100644 --- a/docs/adr/0024-hard-close-retained-earnings.md +++ b/docs/adr/0024-hard-close-retained-earnings.md @@ -8,12 +8,28 @@ The catalog seed adds chart account `310100` (`account_class_code=equity`, `normal_balance_code=credit`) and `account_role_code=retained_earnings` → `310100`, the same mapping pattern as `tax_payable` → `210100`. Ordinary Billing proposals may not use `retained_earnings`; that role is reserved for this close journal. The published Billing proposal schema forbids `account_role_code=retained_earnings` (`not: {const: "retained_earnings"}`) so a commercial payload is schema-invalid before ingest. `ingest_journal_proposal` and `POST /journal-proposals` still reject that role as HTTP 422 before a journal is written. Period-close retained-earnings journals stay AIS-authored (`POST /period-closes` is the only writer of 310100). Catalog roles stay the existing seven; this decision does not invent a withholding role. -The closing journal zeros catalog `usage_revenue` 410100 and `write_off_expense` 510100 only: debit 410100 / credit `retained_earnings` 310100 for net revenue, and debit 310100 / credit 510100 for net expense. The exact remainder lands on 310100 so post-close trial-balance equity ties. AIS does not invent another chart account. If those income-statement roles are already net zero, AIS writes zero closing journals. If the two nets offset to zero income, AIS still posts the clearing lines and omits the 310100 plug. Hard-close loads the period-close package in the same REPEATABLE READ transaction first and fails closed when that binder cannot be loaded or the trial balance does not balance. Leftover cash on 210200 may stay non-zero. Re-hard-close of the same period-close `idempotency_key` replays the existing close receipt and does not post a second closing journal. A different key on an already-locked period fails closed. +The closing journal zeros catalog `usage_revenue` 410100 and `write_off_expense` 510100 only: debit 410100 / credit `retained_earnings` 310100 for net revenue, and debit 310100 / credit 510100 for net expense. The exact remainder lands on 310100 so post-close trial-balance equity ties. AIS does not invent another chart account. If those income-statement roles are already net zero, AIS writes zero closing journals. If the two nets offset to zero income, AIS still posts the clearing lines and omits the 310100 plug. Hard-close currently loads the period-close package in the same REPEATABLE READ transaction first and fails closed when that binder cannot be loaded or the trial balance does not balance; the Proposed PR #53 amendment below narrows which part of that package may authorize close. Leftover cash on 210200 may stay non-zero. Re-hard-close of the same period-close `idempotency_key` replays the existing close receipt and does not post a second closing journal. A different key on an already-locked period fails closed. `GET /financial-statements?statement_type_code=income_statement` excludes AIS closing journals (`journal_reference` prefix `urn:cwl:accounting:general_journal:period_closing:`) so period earnings match the pre-close profit or loss. `GET /financial-statements?statement_type_code=balance_sheet` after hard-close includes `310100` and returns `net_income_amount` `0` because those earnings now sit in equity. `GET /trial-balances` after hard-close is the snapshot that includes the closing journal. -IAS 1 requires a statement of financial position that presents equity separately from profit or loss for the period (IFRS Foundation, 2022). The closing process transfers that period result into equity so the next period’s sheet does not carry a floating earnings plug. +IAS 1 requires a statement of financial position and a statement presenting profit or loss and other comprehensive income as part of a complete set of financial statements. IFRS 18, issued in April 2024, replaces IAS 1 for annual reporting periods beginning on or after 1 January 2027, with earlier application permitted. The repository must therefore treat IAS 1 versus early/mandatory IFRS 18 presentation as a versioned reporting-standard profile rather than making this database close mechanism depend on an IAS 1-only assumption. The closing process here transfers period result into equity; neither IAS 1 nor IFRS 18 prescribes the PostgreSQL mechanism used to do so. + +## Proposed amendment on PR #53: posted temporal authority + +The accepted retained-earnings design does not authorize a later Accounting Policy or chart-of-accounts catalog change to rewrite an already-posted journal fact. For historical P&L source classification at hard close, `journal_entry_line.account_role_code` is the authority because it was persisted with the immutable posted line. For the source account being cleared, the immutable `journal_entry_line.chart_account_id` is the Entity identity; a later active row that happens to reuse the same `chart_account_code` is not the same account. `account_role_mapping` remains the effective-dated policy authority while a new proposal is resolved, and the current `retained_earnings` mapping remains the close-time authority for the destination of the newly created AIS closing journal. + +Accordingly, `_post_closing_journal()` must select and group the historical source population using the persisted journal-line role and exact posted chart-account identity. It must not join the current effective role mapping merely to reconstruct historical classification or resolve a historical source offset by today's active chart-account code. Expiring or superseding a mapping or chart account after posting must neither suppress the posted P&L population, reclassify it, strand hard close, nor redirect the clearing entry to a different account Entity. + +Ordinary `_insert_journal()` admission still requires an active chart account. The historical-account exception is purpose-limited to the AIS-owned period-closing contra line needed to zero an already-posted temporary account; it is not a general permission to post new business activity to expired accounts. The retained-earnings destination continues to resolve current close-time policy because it is a new accounting decision made by the close command. + +The close command must also keep its authority boundary distinct from buyer-facing Reporting-Export projections. A financial-statement projection may report that current catalog metadata is incomplete, but successful construction of that mutable projection must not become the authority that decides whether an otherwise valid immutable ledger population can hard-close. Trial-balance balance, journal population, close-control state, exact posted identities and the purpose-limited retained-earnings destination remain the relevant close facts. + +The posted-role executable acceptance is `tests/test_postgres_period_close_posted_role_stability_red.py` plus `tests/test_period_close_posted_role_source_contract.py`. Posted-account acceptance adds `tests/test_postgres_period_close_posted_account_identity_red.py` and `tests/test_period_close_posted_account_identity_source_contract.py`. Detailed RED/candidate lineage, alternatives, scope-preservation evidence and rollback boundaries are maintained in `docs/doctoring/PERIOD_CLOSE_POSTED_ROLE_TRACEABILITY.md` and `docs/doctoring/PERIOD_CLOSE_POSTED_ACCOUNT_IDENTITY_TRACEABILITY.md`. + +This temporal-authority split is an AIP DDD/audit-evidence control. IAS 1 and IFRS 18 do not prescribe PostgreSQL identifiers, joins, triggers, effective-dated implementation, or the separation between the close command and buyer-report projection. ## Consequences Controllers can hard-close once and read a balance sheet that ties without `net_income_amount` as a plug. Income-statement inquiry still shows the period’s revenue and expense activity. Soft-close remains the adjusting window from ADR 0023 and does not park earnings. + +For the Proposed PR #53 amendment, controllers can change future account-role or chart-account policy without changing or stranding the close semantics of immutable historical journal lines. Reporting projections that infer historical semantics only from current mappings remain a separate Reporting-Export repair and must not create a second Period Close authority. diff --git a/docs/adr/0049-runtime-tenant-database-binding.md b/docs/adr/0049-runtime-tenant-database-binding.md index 2c6d5d3f..36940b8d 100644 --- a/docs/adr/0049-runtime-tenant-database-binding.md +++ b/docs/adr/0049-runtime-tenant-database-binding.md @@ -12,13 +12,15 @@ Forced PostgreSQL row-level security is only a tenant boundary if the value used `accounting_core.current_tenant_account_id()` is a `STABLE SECURITY DEFINER` SQL function with `search_path = pg_catalog, accounting_core`. It takes no caller argument and derives the active tenant by joining the immutable session login (`session_user`) to the admin-owned binding and `pg_catalog.pg_roles`. Existing forced-RLS policies continue to call this function. The legacy `app.tenant_account_id` custom setting is no longer an authorization input; changing it cannot rebind a runtime session. -`PostgresPostingLedger` still receives an explicit tenant reference from the authenticated application boundary. It resolves that reference to the accounting tenant row and requires it to equal the database login binding. A bound mismatch or an unbound ordinary runtime identity fails closed before accounting work. Migration/superuser or `BYPASSRLS` identities are treated as administrative break-glass paths for migration/testing only and are not normal application credentials. +`PostgresPostingLedger` still receives an explicit tenant reference from the authenticated application boundary. It resolves that reference to the accounting tenant row and requires it to equal the database login binding. A bound mismatch or an unbound ordinary runtime identity fails closed before accounting work. + +A normal schema/migration owner is an infrastructure identity, not a runtime tenant. It may remain unbound, `NOSUPERUSER`, and `NOBYPASSRLS`. Cross-tenant data-shape migrations must therefore arrange owner-safe migration ordering explicitly rather than borrowing a tenant identity or requiring standing RLS-bypass authority. For the book-period authority repair, migrations 0009/0034 seed while the table owner is not forced through the runtime tenant policy and restore committed `FORCE ROW LEVEL SECURITY` before runtime use; RLS is not disabled for non-owner roles. `SUPERUSER` or `BYPASSRLS` credentials are administrative break-glass/testing paths only, not a prerequisite or normal deployment credential. Provisioning or rotating an application DB login therefore requires an owner-controlled insert of its current PostgreSQL role OID, role name, and tenant into `runtime_tenant_binding`. Tenant reassignment closes the old binding and creates a new one; runtime credentials never mutate this table. ## Consequences -A compromised ordinary database credential cannot cross tenant scope merely by changing a request field, a session GUC, or `SET ROLE`. The database credential itself becomes purpose- and tenant-bound, complementing rather than replacing HTTP/OIDC authorization. Operators must provision the binding before switching traffic to a new runtime login, and backup/restore or role recreation must re-establish the current role OID deliberately. This is defense-in-depth evidence readiness, not a claim of SOC 2, CSAP, or jurisdictional certification. +A compromised ordinary database credential cannot cross tenant scope merely by changing a request field, a session GUC, or `SET ROLE`. The database credential itself becomes purpose- and tenant-bound, complementing rather than replacing HTTP/OIDC authorization. Operators must provision the binding before switching traffic to a new runtime login, and backup/restore or role recreation must re-establish the current role OID deliberately. Migration tooling must preserve table ownership and the documented owner-only RLS transition when cross-tenant upgrade backfill is required; a failure is repaired by retrying the migration, not by granting runtime tenant authority or weakening the committed policy. This is defense-in-depth evidence readiness, not a claim of SOC 2, CSAP, or jurisdictional certification. ## References @@ -27,3 +29,5 @@ PostgreSQL Global Development Group. (2026e). *PostgreSQL 18 documentation: Syst PostgreSQL Global Development Group. (2026f). *PostgreSQL 18 documentation: Function security*. https://www.postgresql.org/docs/18/perm-functions.html PostgreSQL Global Development Group. (2026g). *PostgreSQL 18 documentation: CREATE POLICY*. https://www.postgresql.org/docs/18/sql-createpolicy.html + +PostgreSQL Global Development Group. (2026h). *PostgreSQL 18 documentation: Row security policies*. https://www.postgresql.org/docs/18/ddl-rowsecurity.html diff --git a/docs/doctoring/BOOK_ACTIVATION_PERIOD_AUTHORITY_TRACEABILITY.md b/docs/doctoring/BOOK_ACTIVATION_PERIOD_AUTHORITY_TRACEABILITY.md new file mode 100644 index 00000000..9ebc34da --- /dev/null +++ b/docs/doctoring/BOOK_ACTIVATION_PERIOD_AUTHORITY_TRACEABILITY.md @@ -0,0 +1,40 @@ +# Book activation / open-period authority traceability + +## Scope + +This note records the post-install lifecycle edge where an already-recorded inactive `accounting_book` becomes active while one or more fiscal periods are open. It is a database master-data invariant, not an IFRS rule and not permission for application code, Reporting, Billing, or manual SQL to manufacture book-period close authority. + +## Finding + +Migration `0034_book_period_control_seed.sql` originally seeded `accounting_book_period_control` only from two creation events: insertion of an open `fiscal_period`, and insertion of an already-active `accounting_book`. That left a third supported state transition uncovered. A book inserted with `valid_to IS NOT NULL` can later become active through `UPDATE ... SET valid_to = NULL`; if the matching fiscal period already exists and is open, neither INSERT trigger runs. The result is an active-book/open-period intersection with no authoritative control row and therefore no 64-row `period_journal_population_fence` population. + +Journal and close admission correctly fail closed when that population is missing, but permanent failure of a legitimate activation is not the intended invariant. The database owner of the book-period intersection must materialize the open authority at the activation boundary rather than waiting for a later posting/close helper to synthesize it. + +## Selected control + +Migration 0034 reuses `accounting_core.seed_book_period_control_for_book()` from a narrow lifecycle trigger: + +```sql +AFTER UPDATE OF valid_to +ON accounting_core.accounting_book +FOR EACH ROW +WHEN (OLD.valid_to IS NOT NULL AND NEW.valid_to IS NULL) +``` + +The existing seeder remains open-only. It scans only `fiscal_period.period_status_code = 'open'`, inserts literal `period_status_code = 'open'` with `period_closed_at = NULL`, and the migration-0033 control-row trigger synchronously creates all 64 freshness-fence rows. Activation into an already non-open shared period therefore remains absent/fail-closed; no `soft_closed` or `hard_closed` state is inferred from the tenant/calendar compatibility projection. + +Reusing the same seeder also preserves the tenant-row MVCC witness used by new-book/new-period concurrency repair. The `WHEN` predicate prevents unrelated book updates and deactivation from taking that low-frequency master-data serialization point. + +## TDD / implementation evidence + +- RED `795b3efef7d0d521719bb60b1de7d937232221e7` adds `tests/test_postgres_book_activation_seed_red.py`. It inserts an inactive book while period `2026-08` is open, verifies zero control/fence rows, activates the book, then requires exactly one literal-open/no-close-timestamp control and exactly 64 fence rows. +- GREEN candidate `ca98183ec03836bfefeeaa8524f42e037957c3a2` adds the activation trigger to migration 0034 and reuses the canonical open-only seeder. +- Static ratchet `ce73c8c8c64cf4aa59b8f8692ff487650501cfb8` requires the exact update edge and same seeder, while retaining the existing open-only projection, FORCE-RLS, hardened `SECURITY DEFINER`, installer, and tenant-MVCC contracts. + +These SHAs are development evidence. The RED is realistic by source inspection, but it is not called runner-observed until the corresponding head actually executes. The candidate is not GREEN until one unchanged exact head passes the real PostgreSQL regression and the complete applicable Accounting Foundation/security/review gates. + +## Recovery and ownership + +A serialization failure during activation rolls back the activation and seed side effects together; retry the complete master-data command from a fresh transaction. Do not insert a control manually, copy shared fiscal-period close state, weaken RLS, or rewrite posted/reconciled/retained accounting evidence. + +`accounting_book_period_control` remains the authoritative tenant/book/period close-state intersection. `fiscal_period.period_status_code` remains a compatibility projection after book-scoped authority exists. `PostgresPostingLedger._lock_book_period()`, `_load_book_period_state()`, and `_require_open_book_period_bounds()` must ultimately be read/lock/fail-closed consumers of that authority and must not recreate or fall back to shared close state. diff --git a/docs/doctoring/BOOK_PERIOD_CONTROL_INSERT_AUTHORITY_TRACEABILITY.md b/docs/doctoring/BOOK_PERIOD_CONTROL_INSERT_AUTHORITY_TRACEABILITY.md new file mode 100644 index 00000000..d10c971f --- /dev/null +++ b/docs/doctoring/BOOK_PERIOD_CONTROL_INSERT_AUTHORITY_TRACEABILITY.md @@ -0,0 +1,64 @@ +# Book-period control insert-authority traceability + +## Finding + +`accounting_book_period_control` is the authoritative tenant/accounting-book/fiscal-period close-state intersection. Migration 0034 already limits automatic post-install materialization to open periods and deliberately leaves missing non-open pairs absent. Fresh application review found a competing writer in `PostgresPostingLedger._lock_book_period()`: before locking the requested control row, it attempted `INSERT ... SELECT` from the shared `fiscal_period` row and copied `fiscal_period.period_status_code` and `period_closed_at` into book-scoped authority. + +That path could synthesize `soft_closed` or `hard_closed` authority for a later-created book without that book's close command, maker-checker evidence, retained trial-balance snapshot, close event, or chronology. It also selected every active book for the tenant rather than only the requested book. The resulting state contradicted migration 0034's open-only master-data lifecycle and the domain model's single-writer boundary. + +The same authority leak remained on the read side: `_load_book_period_state()` and `_require_open_book_period_bounds()` used an outer join plus `COALESCE` to inherit the shared `fiscal_period.period_status_code` whenever the book-owned control was absent. Database containment therefore prevented a bad control write but did not make the application authority model conceptually correct. + +A later review found a separate observability defect in the database containment itself. The direct-write branch of `guard_book_period_control_insert_authority()` returned `NULL`. PostgreSQL correctly skipped the row, but a raw SQL or application caller could observe a non-error command completion and mistake the attempt for accepted authority unless it separately checked affected-row count or re-read the control. An authoritative close-state write must fail explicitly at the boundary that rejects it; silent no-op semantics are not acceptable evidence. + +This is a DDD/data-authority defect, not an IFRS interpretation. IFRS does not prescribe PostgreSQL trigger nesting or book-period control rows. + +## Selected control + +Migration `0034_book_period_control_seed.sql` makes the database relation itself reject direct post-install control creation. The one-time migration repair runs first. After that repair, `book_period_control_insert_authority_guard` is installed as a row-level `BEFORE INSERT` trigger on `accounting_book_period_control`. + +The guard admits a new row only when both conditions hold: + +- the control INSERT is nested under one of migration 0034's canonical master-data seed triggers (`pg_trigger_depth() >= 2`); and +- the new control is literal `open` with `period_closed_at IS NULL`. + +A direct runtime/application INSERT now raises PostgreSQL `check_violation` with stable marker `book_period_control_insert_authority_required`; it does not return a silently skipped command as if authority had been accepted. The application matches that database single-writer boundary instead of relying on the guard as a compensating control. `_lock_book_period()` performs a diagnostic fiscal-period existence lookup, then reads and locks only the requested `accounting_book_period_control` row with `FOR UPDATE OF accounting_book_period_control`; it no longer creates controls. `_load_book_period_state()` and `_require_open_book_period_bounds()` use an inner join to the same book-owned control and read its `period_status_code` directly. A missing control therefore fails closed instead of inheriting the shared calendar projection. + +A caller-controlled custom GUC was rejected as an authority marker because an arbitrary session setting would itself become a spoofable mutable capability. PostgreSQL's trigger-depth signal is structural: PostgreSQL 18.6 documents `pg_trigger_depth()` as the current trigger nesting level, returning zero outside trigger execution. PostgreSQL also permits a row-level `BEFORE` trigger to raise an exception before the row is written; the explicit `check_violation` is used here so rejected authority writes are transaction-visible failures rather than silent skips. + +The guard function remains `SECURITY DEFINER`, fixes its `search_path` to `pg_catalog, pg_temp`, and revokes PUBLIC execute. This does not grant accounting authority to Billing or another foreign context and does not alter posted facts, financial amounts, period transitions, retained snapshots, or reconciliation evidence. + +## TDD and implementation evidence + +Real-PostgreSQL RED `614d1164f3abf1f7bab3fe77d520e5b7108e4c69` creates a tenant period whose shared compatibility projection is `soft_closed`, inserts a later active accounting book, confirms that migration 0034 creates no control/fence for that non-open pair, then calls `_lock_book_period()`. The acceptance requires the call to fail with the existing `AccountingValidationError` and requires both control and fence populations to remain absent. + +Production candidate `610d77082eb01c80d2e9e74521e48a3b06e1375a` installs the post-repair direct-insert guard in migration 0034. Static ratchet `ee233b5c40c942008c7ec034917fd49f1fcf9976` pins the structural nesting check, open/NULL-only invariant, `BEFORE INSERT` placement, and migration-repair ordering. + +Application-source RED `tests/test_book_period_application_authority_contract.py` separately requires the persistence adapter to remove the stale `INSERT ... SELECT` writer, replace both shared-state `LEFT JOIN`/`COALESCE` fallbacks with an exact `accounting_book_period_control` join, lock the authoritative control row with `FOR UPDATE OF accounting_book_period_control`, and preserve unrelated reporting/integration surfaces in the large adapter. Real-PostgreSQL `tests/test_postgres_book_period_control_no_projection_red.py` supplies the buyer-relevant missing-non-open-control case. + +Production source repair `048671fe7243b6bf8c730c349b46d4f3bfc79dde` is a normal descendant of `9086422c2cd801c3be76069114ee0e6753c47f92`. Exact commit comparison reports one modified path, `src/accounting_information_platform/persistence.py`, with 9 additions and 40 deletions. The patch is limited to the three authority helpers: removing the runtime control INSERT, replacing the two shared-state fallbacks with inner book-control joins, and updating helper documentation. It does not delete or rewrite the unrelated reporting, reconciliation, HomeTax, VAT, or ledger surfaces that the preservation contract protects. + +Explicit-rejection RED `344180cf932a6d278f1b28a88d9b7b3a2714232e` strengthens the static contract and adds `tests/test_postgres_book_period_control_insert_authority_red.py`. The real PostgreSQL case creates a legitimate missing non-open book-period pair, attempts a raw literal-open control INSERT, and requires `psycopg.errors.CheckViolation` carrying `book_period_control_insert_authority_required`, followed by zero retained control rows. Production repair `b33b1879600ba088d2f4f7481c547ec99372456b` changes only the guard's rejected branch from silent `RETURN NULL` to that stable exception; canonical nested open-only seeding still returns `NEW` unchanged. + +These SHAs are development lineage, not release evidence. The REDs were authored before their causal repairs, but queued or cancelled GitHub evidence does not become observed RED/GREEN by assertion. Exact-head real-PostgreSQL execution, security/SAST/dependency evidence, independent review, protected-stack prerequisites, migration/recovery evidence, and immutable release evidence remain separate gates. + +## Scope-preservation repair + +A source-cleanup candidate at `952bb1b2a014db823f8ee452ebdfb9bc3980e733` attempted to replace the three stale helper paths together. Exact-blob verification immediately found that the replacement did not preserve unrelated methods in the large persistence adapter, so that candidate is invalid development evidence and must not be used as a GREEN or release input. + +Normal descendant `a8c5abe7520cb0a50708127726bcf0dfb420dc60` restored `src/accounting_information_platform/persistence.py` byte-for-byte to prior complete blob `1d27c2399b0adca1aead3a2f3a141a8eb6a95435`. No force-push, reset, destructive rebase, or selective loss of concurrent delta was used. Scope-preservation ratchet `9086422c2cd801c3be76069114ee0e6753c47f92` then fixed the acceptance before another production edit was attempted. + +The successful source repair at `048671fe7243b6bf8c730c349b46d4f3bfc79dde` was applied against that exact restored blob. A post-write compare against `9086422c2cd801c3be76069114ee0e6753c47f92` shows `ahead_by=1`, `behind_by=0`, a single modified file, and 49 changed lines. This scope check is part of the verification record: changing an authority boundary is not acceptable if the patch silently deletes unrelated accounting behavior. + +## Recovery and follow-up + +A rejected direct control INSERT raises `check_violation`, writes no authoritative row, and therefore seeds no 64-row journal-population fence. The transaction is aborted until the caller rolls it back, preventing a caller from treating a rejected authority mutation as successful work. Operators must not repair a missing non-open pair by copying `fiscal_period` status or by manual SQL. If a later-created book must become applicable to an already non-open period, that requires an explicit book-period lifecycle/adoption/reopen command with authenticated capability, idempotency, maker-checker policy where applicable, and retained chronology. + +With `048671fe7243b6bf8c730c349b46d4f3bfc79dde` and `b33b1879600ba088d2f4f7481c547ec99372456b`, the application and database share the same writer model: master-data seed triggers may create literal-open controls, while posting/adjusting/close helpers only consume existing book-period authority and rejected raw control writers receive an explicit database error. The source cleanup is not considered execution-GREEN until an unchanged exact head runs the static authority contract and real-PostgreSQL regressions successfully. Missing-control diagnostic wording for an adjusting journal remains a possible buyer-facing refinement, but it must not reintroduce authority synthesis. + +## References + +PostgreSQL Global Development Group. (2026a). *PostgreSQL 18.6 documentation: System information functions and operators*. https://www.postgresql.org/docs/18/functions-info.html + +PostgreSQL Global Development Group. (2026b). *PostgreSQL 18.6 documentation: CREATE TRIGGER*. https://www.postgresql.org/docs/18/sql-createtrigger.html + +PostgreSQL Global Development Group. (2026c). *PostgreSQL 18.6 documentation: Overview of trigger behavior*. https://www.postgresql.org/docs/18/trigger-definition.html diff --git a/docs/doctoring/BOOK_PERIOD_CONTROL_RLS_MIGRATION_TRACEABILITY.md b/docs/doctoring/BOOK_PERIOD_CONTROL_RLS_MIGRATION_TRACEABILITY.md new file mode 100644 index 00000000..9aa9b795 --- /dev/null +++ b/docs/doctoring/BOOK_PERIOD_CONTROL_RLS_MIGRATION_TRACEABILITY.md @@ -0,0 +1,102 @@ +# Book-period control RLS migration traceability + +## Scope + +This note records installation, upgrade, and post-install master-data controls for the authoritative `accounting_book_period_control` and `period_journal_population_fence` relations and their `accounting_book` / `fiscal_period` seed sources. It does not grant a runtime tenant, change accounting policy, weaken posted-journal immutability, or transfer accounting authority to Billing or another source system. + +## Migration-owner RLS finding + +`accounting_core.current_tenant_account_id()` resolves a runtime tenant from `session_user`. A production schema/migration owner is not required to have such a runtime binding and should remain `NOSUPERUSER` / `NOBYPASSRLS`. + +PostgreSQL 18 documents three distinct facts that matter here: ordinary table owners normally bypass row-level security; `FORCE ROW LEVEL SECURITY` makes the table owner subject to its policies; and `SUPERUSER` / `BYPASSRLS` are exceptional capabilities that bypass those policies. Therefore an all-tenant migration backfill cannot assume that the forced-RLS table owner can either read seed rows or write target rows when the policy derives a runtime tenant from an unbound `session_user`. + +The first repair pass covered only target visibility. Fresh review then found the deeper source-side defect: migration 0005 had already forced `accounting_book` and `fiscal_period` through tenant RLS. Moving migration 0009's target backfill before FORCE on the newly created control table was still insufficient because the same unbound table owner could see no source books or periods. Likewise, migration 0034's owner-only window on the control/fence targets could not repair rows that its all-tenant `SELECT` could not see. Migration 0033's initial fence seed had already encoded the correct target-side principle by seeding before FORCE RLS, but it did not solve later source visibility. + +There are no GitHub tags or releases for this repository at this repair point, so migration 0009 is not an immutable released artifact. The branch therefore repairs the unreleased migration order rather than adding a later migration that could not rescue an upgrade already failing at 0009. + +## Post-install concurrent-seeding finding + +Migration 0034 originally installed one `AFTER INSERT` trigger on `fiscal_period` and one on active `accounting_book`. Each trigger scanned the opposite population and inserted any missing book-period controls. That covered both sequential creation orders but did not cover the concurrent cross-product race. + +Transaction A could insert a new active book and run its trigger while transaction B's new fiscal period was still uncommitted. Transaction B could independently insert that period and run its trigger while A's book was still uncommitted. Under MVCC neither scan is allowed to read the peer's uncommitted row. If both transactions then committed, the active `(tenant, book, period)` pair existed without its required control row or 64-row journal-population fence. The later journal guard would correctly fail closed, but a legitimate post-install book-period would be unusable until manually repaired. A fail-closed symptom is not a substitute for maintaining the authoritative master-data invariant. + +A shared lock with no row-version change is also insufficient at fixed-snapshot isolation. PostgreSQL 18 Repeatable Read sees only data committed before the transaction snapshot and requires whole-transaction retry when an updater reaches a row that another transaction actually updated after that snapshot. Therefore the seeders need both ordering for Read Committed and a pre-existing MVCC version witness for Repeatable Read/Serializable. + +## Post-install close-authority projection finding + +`docs/DATA_MODEL.md` defines `accounting_book_period_control` as the authoritative close-state intersection for one tenant accounting book and fiscal period. The same document explicitly describes `fiscal_period.period_status_code` as an aggregate compatibility projection that must not be used to infer that every sibling book has the same close state. + +Migration 0034 nevertheless copied `fiscal_period.period_status_code` and `fiscal_period.period_closed_at` into a control created for a later-inserted accounting book. That is an authority inversion: a new book could appear `soft_closed` or `hard_closed` even though no close command, maker-checker evidence, retained snapshot, close event, or book-specific chronology existed for that book. The same defect existed in the repair backfill for missing post-install pairs. A direct INSERT of a non-open fiscal period could also have projected that state across every active book. + +This is not an IFRS interpretation. IFRS does not prescribe PostgreSQL aggregate ownership or row-level close-state derivation. It is a DDD/data-authority control required so AIS does not manufacture its own accounting evidence from a compatibility projection. + +## Selected controls + +Migration 0009 temporarily applies `NO FORCE ROW LEVEL SECURITY` to the already forced `accounting_book` and `fiscal_period` source tables, performs the existing all-tenant control-row seed while the new target table is not yet exposed through runtime policy, restores FORCE on both sources, and only then enables/forces RLS on `accounting_book_period_control`. This is a one-time conversion from the pre-book-scoped close model; the then-existing fiscal-period status is legacy source state being migrated and is not precedent for post-install projection copying. + +Migration 0034 keeps RLS **enabled** everywhere. For its owner-only repair phase it applies `NO FORCE ROW LEVEL SECURITY` to all four participating relations: `accounting_book`, `fiscal_period`, `accounting_book_period_control`, and `period_journal_population_fence`. It performs the cross-tenant backfill, then restores `FORCE ROW LEVEL SECURITY` on every source and target before `COMMIT`. `NO FORCE` restores normal table-owner bypass; it does not disable RLS for non-owner runtime roles. The fence table participates because every inserted control synchronously invokes migration 0033's `SECURITY DEFINER` 64-stripe seeder. + +Post-install automatic authority creation is now open-only. `seed_book_period_control_for_period()` returns without creating book controls when the inserted fiscal period is not `open`; an open period creates literal `open` controls with `period_closed_at = NULL`. `seed_book_period_control_for_book()` scans only shared fiscal periods whose compatibility projection is currently `open`, and again creates literal `open` controls with no close timestamp. Migration 0034's repair backfill fills only missing open pairs. Missing non-open book-period authority remains absent, causing journal/close admission to fail closed instead of inventing close history. + +This deliberately does not define a reopen/adoption policy for a later-created book and an already non-open period. If that buyer use case is required, it needs an explicit book-period lifecycle command with authenticated capability, maker-checker separation where required, immutable command identity/evidence, and retained successor chronology. Copying `fiscal_period` state or using a manual SQL repair is not an acceptable substitute. + +For post-install open book/period creation, both migration-0034 trigger functions update the same pre-existing `tenant_account` row before they scan the opposite master-data population. The statement sets `created_at` to its retained value, so it changes no tenant business fact, but it performs a real non-key row update and therefore provides the common MVCC version witness. + +At PostgreSQL's default Read Committed isolation, a later updater waits for the competing tenant-row update and the following trigger statement receives a new command snapshot that can see the peer commit. At Repeatable Read/Serializable, a seeder whose fixed snapshot predates the competing tenant-row version fails with serialization error rather than committing a peer-blind pair; the complete master-data command must retry from a fresh transaction. Because `created_at` is not a key column, PostgreSQL uses the weaker `FOR NO KEY UPDATE` row-lock class for this update. PostgreSQL documents that `FOR NO KEY UPDATE` does not conflict with `FOR KEY SHARE`, preserving ordinary foreign-key checks by unrelated child inserts while still self-conflicting with the other master-data seeder. + +The tenant-level write is intentionally limited to this low-frequency master-data boundary. It is not used by ordinary journal posting, does not contain a financial amount, and does not replace the per-book-period 64-stripe runtime freshness fence. + +These `ALTER TABLE` operations and row updates remain transactional. Runtime traffic is not allowed to observe a committed half-state in which one of these relations permanently loses FORCE. The migration role must own all participating relations. Installation must not be made to work by granting `BYPASSRLS`, using a superuser as the normal deployment identity, assigning a fabricated runtime tenant to the migration role, treating `row_security=off` as a bypass, or executing `DISABLE ROW LEVEL SECURITY`. + +## Alternatives considered + +A trigger-side scan with no common lock was rejected because opposite-side inserts can each miss the other's uncommitted row and both commit. + +A common `SELECT ... FOR NO KEY UPDATE` lock without changing a pre-existing row version was rejected as insufficient for Repeatable Read: after waiting, the transaction still has its original fixed snapshot and can remain unable to see the peer row. + +A tenant-level `FOR UPDATE` fence was rejected as stronger than necessary because PostgreSQL documents that `FOR UPDATE` conflicts with `FOR KEY SHARE`, which would block unrelated foreign-key checks on the tenant row. The chosen non-key update obtains the weaker `FOR NO KEY UPDATE` class. + +A table-level lock was rejected because it would serialize unrelated tenants and widen a low-frequency same-tenant master-data invariant into a global bottleneck. + +A hash-based advisory lock was not selected because the canonical tenant row already provides a collision-free, pre-existing database identity and the fixed-snapshot case still needs a row-version change or an explicit serialization failure mechanism. + +Copying `fiscal_period.period_status_code` or `period_closed_at` into a later-created book was rejected because the shared fields are compatibility state after book-scoped authority exists. This would manufacture closure without book-specific evidence. Initializing every historical/non-open pair as `open` was also rejected because that would silently reopen periods and admit backposting. The selected open-only materialization creates authority only where the shared period is already open and leaves non-open applicability fail closed. + +## TDD and exact implementation evidence + +- First static RED `800716a2b44370e41b0a5e65d86d4e30d1008765` required owner-safe target backfill without RLS disablement. +- Initial target-side repair: 0009 `92789123dca0a119e17df3f4b1d994c780f80264`; 0034 `a19be19b059834e04e965463230d56b3fa9c8aa7`. +- Fresh source-visibility RED `29808a77426e403d2c0277264ef6d2217f0e52d1` extends `tests/test_book_period_control_seed_contract.py` to require the owner-only window on `accounting_book` and `fiscal_period` as well as the control/fence targets. +- 0009 source-side repair `f5a28af32f70de66be5d702c5cf404b735546699` restores owner visibility on the forced-RLS seed sources and restores FORCE before target policy activation. +- 0034 full source/target repair `027fae479a7ae52b1db3119acd6549f50aa6dba2` surrounds the repair backfill with owner-only visibility on all four participating tables and restores FORCE on all four before commit. +- ADR 0006 alignment `4a87f3522e7a2f7f8dc1faa4cde6e0d6f3ebb3dd`; runtime-identity clarification `e040f3447700abfa5291237fa094c88019068e9f` makes an ordinary unbound `NOSUPERUSER`/`NOBYPASSRLS` migration owner distinct from runtime tenant and break-glass identities. +- Real-PostgreSQL runtime-state acceptance `cca0f5a7f9b1b4450933aea389e035863021503a` verifies through `pg_catalog.pg_class` that `accounting_book`, `fiscal_period`, `accounting_book_period_control`, and `period_journal_population_fence` all finish the installed migration chain with both RLS enabled and FORCE restored. This verifies the committed-state half of the owner-window contract; production-like execution as an unbound non-bypass owner remains separate release evidence. +- Concurrent cross-product RED `6cbcf0e334aab201a35cce2df1f2e887271cefad` adds a real PostgreSQL case where one transaction holds a newly inserted active book uncommitted while another creates the matching fiscal period. The period side must not commit before the book transaction resolves, and the final pair must own one control plus all 64 fences. +- Initial common-row lock repair `a038f8726ed0ef6f88a4ee7ea4920e6873e032f1` serializes the two trigger scans, with static ratchet `1d286cdd0d641b3723d90468a62cbe7da41a156f`. +- Fixed-snapshot RED `58b5f57b3bc7ae677544f90d0bfaf0c325045629` proves that a lock-only repair is not enough: a concurrent Repeatable Read period transaction must fail with PostgreSQL `SerializationFailure` and succeed only when the complete insert is retried from a fresh transaction. +- Fixed-snapshot causal repair `521104564345d096836da145140315f5e23fb1df` turns the tenant coordination point into a non-key UPDATE version witness without changing tenant business data; static contract `aa97d22b6f01b111ec3e701e4db229471276b807` pins that write before either peer scan and rejects a caller-shaped replacement timestamp. +- Book-state authority static RED `7592a3114f565f65c704b5b5f9f5eca39e420b75` rejects selecting shared period status/timestamp as values for a newly inserted book control. +- Real-PostgreSQL RED `083488b52f0ef4b9cf0fa21c87abea5159f9039f` requires a later-created active book to receive no control or fence population from a non-open shared period projection. +- Static authority ratchet `ac38a098c703d8ea72ba72fd1e7c9dc1a3814227` extends open-only derivation to new-period seeding and the repair backfill. +- Production repair `797cf556d8cf69e959b02cfd62cf4f30685e9658` implements open-only automatic control creation and repair backfill without changing migration 0009's one-time legacy conversion. +- ADR 0006 alignment is `1efd6e7f8c16caba1ff90129fe462759ca93aae7`; this traceability note is its normal fast-forward descendant. + +These commits are development evidence only. The RED commits preceded their corresponding causal repairs, but no runner-observed RED or exact-head GREEN claim is valid until GitHub Actions executes the corresponding heads. The final release candidate still requires the real PostgreSQL migration chain with a production-like unbound non-bypass owner, tenant-isolation acceptance, security/SAST/dependency checks, migration/recovery evidence, independent review, and protected integration gates. + +## Recovery and security effect + +The SQL repairs are transactional. A failed migration must roll back every owner-force toggle and all inserted control/fence rows together. A runtime master-data serialization failure rolls back the book or period insert and its seed side effects together. Callers must retry the complete immutable master-data command from a fresh transaction; they must not fabricate book-period controls, delete posted journals, rewrite retained trial-balance evidence, or weaken tenant policies to make the operation appear successful. + +The committed runtime state remains `ENABLE ROW LEVEL SECURITY` + `FORCE ROW LEVEL SECURITY` on the tenant-scoped source and authority relations. This repair changes migration-owner visibility, master-data coordination, and post-install authority derivation only. It does not alter the runtime single-writer boundary, tenant policy expression, close command, maker-checker rules, journal amounts, or financial values. + +## References + +PostgreSQL Global Development Group. (2026a). *PostgreSQL 18 documentation: Row security policies*. https://www.postgresql.org/docs/18/ddl-rowsecurity.html + +PostgreSQL Global Development Group. (2026b). *PostgreSQL 18 documentation: ALTER TABLE*. https://www.postgresql.org/docs/18/sql-altertable.html + +PostgreSQL Global Development Group. (2026c). *PostgreSQL 18 documentation: CREATE ROLE*. https://www.postgresql.org/docs/18/sql-createrole.html + +PostgreSQL Global Development Group. (2026d). *PostgreSQL 18 documentation: Explicit locking*. https://www.postgresql.org/docs/18/explicit-locking.html + +PostgreSQL Global Development Group. (2026e). *PostgreSQL 18 documentation: Transaction isolation*. https://www.postgresql.org/docs/18/transaction-iso.html diff --git a/docs/doctoring/HARD_CLOSE_SNAPSHOT_PAIR_TRACEABILITY.md b/docs/doctoring/HARD_CLOSE_SNAPSHOT_PAIR_TRACEABILITY.md new file mode 100644 index 00000000..c1450436 --- /dev/null +++ b/docs/doctoring/HARD_CLOSE_SNAPSHOT_PAIR_TRACEABILITY.md @@ -0,0 +1,55 @@ +# Hard-close / retained-snapshot pair traceability + +## Finding + +`accounting_book_period_control` is the authoritative tenant/book/period close-state fact. Migration 0035 already prevented the retained `trial_balance_snapshot` side of a hard close from committing unless the matching control ended `hard_closed`, but the inverse was not enforced: a writer could update an existing control to `hard_closed` and commit without a retained snapshot. The application later detected that damaged state in `_replay_close_receipt()`, but detection after commit is weaker than preventing an impossible accounting-control state. + +A second review found an upgrade boundary behind the future-write trigger. A deferred trigger cannot retroactively certify rows that already existed before migration 0036. Installing the trigger over a pre-existing one-sided pair would therefore make the schema look protected while silently grandfathering uncertified close authority or retained evidence. + +This is an Accounting Information Platform database/DDD invariant. IFRS does not prescribe PostgreSQL trigger timing or this physical pairing mechanism. + +## Test-first lineage + +- `bea21ed65d9c9e8a79cb48a102f7688032baae0a` adds `tests/test_postgres_hard_close_snapshot_pair_red.py`. Starting from a legitimate `soft_closed` book-period with no snapshot, it attempts a direct `hard_closed` transition and requires commit to fail with `hard_close_snapshot_pair_required`; after rollback the authoritative status must remain `soft_closed` and snapshot count must remain zero. +- `26e71eb4e5a8450159f5ced482de43176c80e0f6` adds migration `0036_hard_close_trial_balance_snapshot_pair.sql`. +- `a3c2b250f95bc1232edb305c9fa03904ad22332c` puts migration 0036 in the canonical foundation installer. +- `b7056cd969693f9ccdd71f9e2958eedb1d9b133a` extends the static contract so every supported install requires both directions of the pair. +- `80a719fe85239b864993023d4de8aa4e5765b397` records the bidirectional commit invariant and recovery semantics in ADR 0006. +- `f1935c8515a74967355cfc5ffd96ef4c134500a2` adds a real-PostgreSQL upgrade RED: after reproducing a pre-0036 `hard_closed` control with no snapshot, reapplying migration 0036 must fail rather than grandfather the one-sided fact. The test restores the schema trigger and tenant fixture in `finally` so failure does not become persistent test-environment state. +- `08c7b0f25bcdae7e5b223108eb68d06a6cf3a29a` adds the migration preflight for both one-sided legacy populations. +- `4536a84c35371926fb6e128fa92e5c3d109d7e07` ratchets the preflight markers, temporary visibility policies, policy cleanup, and prohibition on disabling RLS. + +The RED commits are source REDs by construction; they are not called runner-observed RED until the exact commit or a descendant containing those unchanged tests executes them in PostgreSQL. Likewise, the migration remains a production candidate until one unchanged exact head produces terminal GREEN evidence. + +## Selected control + +Migration 0036 first certifies the already-stored pair population. `accounting_book_period_control` and `trial_balance_snapshot` are FORCE-RLS relations, so the migration creates two transaction-scoped `FOR SELECT TO current_user USING (true)` policies only for its owner preflight. It rejects either of these states with SQLSTATE `23514` before installing durable behavior: + +- a `hard_closed` tenant/book/period control with no exact retained snapshot (`hard_close_snapshot_pair_legacy_preflight`); +- a retained snapshot whose exact control is missing or is not `hard_closed` (`trial_balance_snapshot_hard_close_pair_legacy_preflight`). + +The temporary policies are dropped before the future-write trigger is installed. RLS remains enabled throughout; no `BYPASSRLS`, superuser requirement, fabricated tenant binding, or `DISABLE ROW LEVEL SECURITY` path is introduced. An aborted migration rolls the temporary policies back with the transaction. + +After the preflight, migration 0036 installs an `AFTER UPDATE OF period_status_code` constraint trigger on `accounting_core.accounting_book_period_control`. Only a transition from a non-`hard_closed` state to `hard_closed` is queued. At deferred execution, the trigger requires a `trial_balance_snapshot` with the same tenant, accounting book, and fiscal period. Missing evidence raises `check_violation` with the stable marker `hard_close_snapshot_pair_required`. + +The trigger is `DEFERRABLE INITIALLY DEFERRED`, `FOR EACH ROW`, and uses a `SECURITY DEFINER` function with `search_path = pg_catalog, pg_temp`; PUBLIC execute is revoked. PostgreSQL 18 documents that constraint triggers are `AFTER ROW` triggers whose firing can be deferred to transaction end, and that a constraint-trigger `WHEN` expression is evaluated immediately after the row update before a matching firing is queued. That is the required timing here: the canonical hard-close command inserts its snapshot first and advances the book-period control later in the same transaction, while an unsupported hard-close-only write cannot survive commit. + +Migration 0035 and 0036 therefore enforce both implications at commit: + +`retained snapshot => hard_closed authority` + +`transition to hard_closed authority => retained snapshot` + +The existing unique `(tenant_account_id, accounting_book_id, fiscal_period_id)` snapshot population identity supplies at most one retained counterpart. No second close writer, foreign billing truth, mutable session flag, cross-service SQL, or application-side status synthesis is introduced. + +## Rejected alternatives + +Application-only replay validation was rejected because it allows corrupt `hard_closed` authority to commit and pushes recovery onto the next reader. An immediate trigger was rejected because it would couple correctness to statement order and would conflict with the existing snapshot-first/status-second command transaction. Adding another snapshot writer or synthesizing retained evidence when the status changes was rejected because it would bypass close-package validation, journal-population freshness, exact currency/scope checks, and purpose-limited close authority. Silently grandfathering pre-0036 one-sided rows was rejected because later operators could mistake trigger presence for certification of historical close evidence. + +## Recovery and operability + +A runtime pairing violation aborts the whole transaction. Operators must retry the supported close command from a clean transaction with the original immutable command identity; they must not patch the status or insert retained evidence independently. + +If migration 0036 reports either legacy-preflight marker, stop the upgrade and inventory the exact tenant/book/period control plus retained snapshot/line evidence. Do not synthesize the missing side to make the migration pass. Retain the prior release or use a separately reviewed audited remediation that can prove the original close command, journal population, scope/currency, maker-checker decision and retained numerical evidence. A failed migration rolls back its temporary visibility policies and any trigger/function changes made in that transaction. + +PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: CREATE TRIGGER*. https://www.postgresql.org/docs/18/sql-createtrigger.html diff --git a/docs/doctoring/PERIOD_CLOSE_JOURNAL_FENCE_TRACEABILITY.md b/docs/doctoring/PERIOD_CLOSE_JOURNAL_FENCE_TRACEABILITY.md new file mode 100644 index 00000000..a78dfa5c --- /dev/null +++ b/docs/doctoring/PERIOD_CLOSE_JOURNAL_FENCE_TRACEABILITY.md @@ -0,0 +1,88 @@ +# Period Close journal-population fence traceability + +## Decision scope + +This note traces the concurrency control used when an authoritative journal population approaches fiscal-period close. It does not create accounting policy, reopen a closed period, grant Billing posting authority, or make PostgreSQL locking semantics an IFRS requirement. + +IAS 10 distinguishes adjusting events that provide evidence about conditions existing at the reporting-period end from non-adjusting events arising later. The product-level implication already adopted by ADR 0006/ADR 0023 is narrower: a governed close must not certify retained balances while a journal that is validly admissible for that period is concurrently being committed. IAS 10 does **not** prescribe PostgreSQL row locks, advisory locks, MVCC isolation, retry codes, revision counters, or striped fence rows; those are AIP implementation controls. + +## Authoritative technical basis + +PostgreSQL 18 documents that `FOR SHARE` is compatible with another `FOR SHARE`/`FOR KEY SHARE`, while blocking `UPDATE`, `DELETE`, `FOR UPDATE`, and `FOR NO KEY UPDATE` on the same row. Row locks are held through transaction end. PostgreSQL also states that a row-lock request in `REPEATABLE READ` or `SERIALIZABLE` errors when the row to be locked changed after the transaction began. Serialization failures use SQLSTATE `40001`, and the complete transaction must be retried from the beginning rather than resumed after the failed statement. + +The current PostgreSQL documentation line is PostgreSQL 18; PostgreSQL 18.6 was released on 2026-08-13. Minor-version operability evidence remains owned by the PostgreSQL runtime-baseline lane. This control relies on documented PostgreSQL 18 concurrency semantics and does not treat a clean CI image as evidence that an existing production cluster was upgraded safely. + +## Control mapping + +| Concern | Chosen / required control | Rejected alternative or repaired finding | Executable evidence | +|---|---|---|---| +| Database admission for ordinary `open` journals | `guard_period_insert()` takes `FOR SHARE` on the exact book-period control row, then increments one of 64 pre-existing journal-population fence rows selected from the journal UUID | Increment one shared book-period revision for every journal; that serializes all ordinary posting on one row | `tests/test_postgres_open_period_journal_fence.py`; migration `0033_open_period_journal_population_fence.sql` | +| Application-path ordinary posting concurrency | Ordinary Billing-owned proposals do not acquire the exclusive tenant/book/period advisory mutex used to serialize close commands; proposal/idempotency coordination remains application-owned and the database fence is the final journal/transition authority | Repaired defect: `_require_open_book_period_bounds()` previously acquired `_acquire_command_lock(connection, f"period:{book_id}:{period_code}")`, serializing unrelated ordinary postings before the striped database boundary | `tests/test_postgres_open_period_journal_fence.py::test_open_period_postings_do_not_serialize_on_application_period_lock`; `tests/test_open_period_application_lock_contract.py`; production repair `430f4dde6757c8bf09243a00787dabcfa97ab49c` | +| Direct `open` → close freshness | Before a period status transition commits, PostgreSQL locks all 64 pre-existing fence rows in slot order. A fence modified after the close transaction's `REPEATABLE READ` snapshot causes SQLSTATE `40001`, so stale close evidence rolls back | A shared control-row lock alone; it prevents transition overtaking an in-flight journal but carries no row version showing that a journal committed after the close snapshot | `tests/test_postgres_open_period_close_serialization_red.py` | +| Compatible database open-path coordination | Open journals share the control-row lock; only journals landing on the same stripe compete for the stripe UPDATE | Treating database lock compatibility, or removal of the application period mutex, as a performance result without exact-head concurrent measurement | `tests/test_postgres_open_period_journal_fence.py::test_open_period_posting_can_progress_while_peer_holds_share_fence` | +| State changes while an open journal waits | Fail with SQLSTATE `40001` and retry the whole journal command from a fresh transaction | Continue using stale open-state authority after waiting | migrations `0032`/`0033`; `tests/test_trial_balance_snapshot_immutability_contract.py` | +| `soft_closed` adjusting/reversal/closing journal freshness | Purpose-limited close-window journals increment `accounting_book_period_control.journal_population_revision` in the journal transaction | Advisory-lock wait only; a pre-existing repeatable-read snapshot remains stale after lock grant | `tests/test_postgres_period_close_journal_serialization_red.py` | +| Direct hard-close snapshot authority | A snapshot while the book-period is still `open` is admitted only when the session has `accounting_closing_writer` capability **and** holds the canonical tenant/resolved-book-id/period close advisory lock; a bare GUC/role cannot pre-populate open-period evidence | Require `soft_closed` for every snapshot, which breaks the published direct `open` → `hard_closed` command; or accept a bare role/GUC, which permits pre-population | migration `0033`; `tests/test_postgres_open_period_close_serialization_red.py` | +| Book-period authority existence | Every active book-period pair must have one `accounting_book_period_control` row before posting or close evaluation. Migration 0034 backfills missing pairs and seeds them transactionally when either a new fiscal period or a new active accounting book is inserted | Treat migration 0009's one-time backfill as sufficient. It leaves post-install master data without a control row, so migration 0033 correctly fails closed but ordinary posting becomes unavailable | migration `0034_book_period_control_seed.sql`; `tests/test_book_period_control_seed.py` | +| Fence existence | Migration 0033 seeds 64 rows for every control row, and migration 0034 guarantees that future active book-period pairs create that control row before journal admission | Lazy per-journal fence creation; a stale repeatable-read snapshot could fail to see a newly created row and therefore fail to detect the concurrent journal | migrations `0033`/`0034`; `tests/test_book_period_control_seed.py`; installer/static/PostgreSQL contracts | +| Capability/security | Fence and guard functions are `SECURITY DEFINER`, fix `search_path = pg_catalog, pg_temp`, revoke PUBLIC execute, and the fence table is tenant RLS/FORCE RLS protected | Caller GUC alone as authorization or an unscoped shared fence | migration `0033`; static contract | + +## Why migration 0032 alone was insufficient + +Migration 0032 corrected the first stale-close race for `soft_closed` journals without preserving the rejected all-journal single-row UPDATE. Its `open` path deliberately left `journal_population_revision` unchanged and held only `FOR SHARE` on `accounting_book_period_control`. + +That row lock is not itself a freshness witness. A journal path that does not share the close advisory mutex can commit after a close transaction establishes its `REPEATABLE READ` snapshot. The close may then continue with an older journal population unless a pre-existing row version exposes that commit to PostgreSQL's repeatable-read conflict detection. The direct-open regression uses the AIS adjusting-journal path for exactly this reason: that command has its own idempotency mutex and can legitimately write while the book-period is `open`; it does **not** acquire the ordinary Billing proposal's tenant/book/period advisory lock. Migration 0033 supplies a bounded, pre-existing row-version witness for that database-authority race. + +The 64-slot count is an engineering trade-off, not an accounting standard and not a performance result. It bounds fence-row fan-out during the low-frequency state transition while reducing expected ordinary-post collisions relative to one shared revision row. Exact-head load testing still has to measure collision rate, lock waits, WAL/write cost, and buyer-path p95; the slot count must be changed only from measured evidence. + +## Why migration 0034 is required + +Migration 0009 created `accounting_book_period_control` and backfilled only the accounting books and fiscal periods that existed at installation time. Migration 0033 correctly requires that control row and its complete 64-row fence population to pre-exist before journal admission; it must not lazily create a witness after a close transaction may already have established its snapshot. + +That combination exposed a lifecycle gap for master data created after migration installation. A later `fiscal_period` could be opened while active books already existed, or a later active `accounting_book` could be created after fiscal periods existed. In either order there was no database command that materialized the Cartesian book-period control pair before posting. The journal guard would therefore fail closed with missing period-control authority even though the business period was legitimately open. + +Migration 0034 keeps ownership in the database control boundary. An `AFTER INSERT` trigger on `fiscal_period` creates controls for all active books of the same tenant; a complementary `AFTER INSERT` trigger on `accounting_book` creates controls for all existing periods when the new book is active. Both use conflict-safe inserts. Each newly inserted control row synchronously invokes migration 0033's fence seeder, so all 64 stripes exist in the same transaction before the new master-data row becomes visible. The migration also backfills any active book-period pairs missed by earlier installation order. No journal amount, accounting policy, or foreign commercial truth is derived by these seeders. + +## Application serialization repair + +The database striping repair originally left a separate application bottleneck: `PostgresPostingLedger._require_open_book_period_bounds()` acquired the same canonical exclusive advisory identity `period:{book_id}:{period_code}` that `close_fiscal_period()` uses for close-command serialization. Two unrelated Billing proposals for one open book-period therefore queued before either reached the 64-stripe database fence. + +Real-PostgreSQL RED `1683fd5f8e21e907a187bea7c239e3d30f8d0bdb` pauses one ordinary proposal after open-period admission but before journal persistence, then requires a second ordinary proposal to complete before the first is released. Static RED `839e930a4f24eda1083742578894479a8ed968bf` pinned removal of the close-command advisory acquisition from the ordinary helper. + +Production repair `430f4dde6757c8bf09243a00787dabcfa97ab49c` removes only that period-close advisory acquisition from `_require_open_book_period_bounds()`. It does not weaken proposal/idempotency locks, the explicit application `open` check, the PostgreSQL period guard, snapshot authority, role checks, or the transition fence. Close commands continue to own their canonical period advisory mutex. Successor `70c07aba7c51391b9ee965fe3948b23c9546642d` strengthens the static contract so **any** command-lock acquisition inside the ordinary helper fails the ratchet; it also removes the earlier token-count assertion that incorrectly described two application-level status checks when the implementation has one explicit post-query `open` validation. + +The source repair closes the causal application mutex defect, but it is not promoted to execution GREEN merely because the diff is correct. The exact unchanged successor head still has to execute the real PostgreSQL overlap regression and the complete Accounting Foundation/security/dependency/SAST gate set. Performance acceptance additionally requires measured lock waits, stripe collisions, retry denominator, WAL/write cost, and buyer-path p95. + +## TDD and repair lineage + +- Soft-close journal / stale hard-close RED: `306f4c14212a0dfbb89a6934bbb493b1e179479e`. +- First freshness candidate through `84f5666aa48ba565fb2e4ff763bb0b3ee27fe857` updated the control-row revision for every journal. +- Single-row database hotspot RED: `6fe1fdeb1050111b26e557810dbf66b05f75871a`. +- Split control-row repair: `7a979845896869ef0e7fabab710c7a4f3a9863de`; compatible-row-lock regression `2bdb09a6e7da1444a2356d94ae7fde16d9d40686`. +- Direct open-period stale-close RED using the adjusting-journal path: `70a9b196da23fc0cbedd9ceafa806710794f13e3`. +- Initial 64-stripe database repair: `ac3a2a7eac2929e3ff76908d9bb64a4a38acb7dd`; canonical installer inclusion: `7cafddd91affeb0166956a96c260a8c17f06ac42`; static contract: `7279c9a45cb6e515a9e88b0171fb8390c823ccba`. +- Migration self-review moved the cross-tenant backfill before FORCE RLS: `a6d32fc35f6f0f48fbcec6b08fa16b1a89eb5f80`. +- Direct-open authority/fence-completeness PostgreSQL cases: `35c76f4dfda3b2d299b82eb28e06a9c2a9a6ba49`. +- End-to-end application advisory serialization RED: `1683fd5f8e21e907a187bea7c239e3d30f8d0bdb`; causal source ratchet: `839e930a4f24eda1083742578894479a8ed968bf`. +- Open-period application mutex production repair: `430f4dde6757c8bf09243a00787dabcfa97ab49c`; stronger static no-command-lock ratchet: `70c07aba7c51391b9ee965fe3948b23c9546642d`. +- Post-install master-data control/fence RED: `e5f40ca368a60394d0975d75baf249edfe876552`; database-owned dual-side seeding repair: `e22c2a6d9ad945eba986ec81f599cbd7dea60392`; canonical installer inclusion: `0d619c27cc2a0f56d501ed33fe50ed8746f4f2e9`. + +These commits are development evidence, not protected-head release evidence. The REDs were committed before their respective causal repairs. Exact-head PostgreSQL CI, security/SAST/dependency evidence, independent review, central workflow gates, package/SBOM/provenance, migration/recovery verification, and measured performance must be reacquired after every head change. + +## Residual risk and release acceptance + +Migrations 0033/0034 remove the deliberate database single-row revision hotspot, and the application repair removes the separate close-command period mutex from ordinary posting. Neither fact proves buyer-path latency. Same-slot journals can still serialize on a selected fence row, open journals still hold the book-period `FOR SHARE` lock through transaction end, and a period transition deliberately locks all 64 rows. PostgreSQL notes that row locking can cause writes and blocking; the selected stripe count remains a measured engineering parameter rather than a correctness theorem. + +Release acceptance therefore requires measured concurrent posting plus period-transition load at the exact candidate head, reporting advisory-lock waits, row-lock waits, stripe collision distribution, retries and failure denominator, WAL/write cost, and tail latency rather than hiding them with cache warm-up, reduced samples, or excluded failures. + +A `40001` result is not accounting evidence. The caller must retry the complete command with the same immutable source-payload identity/idempotency contract. A failed stale close must leave no retained snapshot, closing journal, close event, or authoritative state transition. Recovery must never rewrite posted journals, retained trial-balance evidence, or reconciliation authority rows to make a failed close appear successful. + +## References + +IFRS Foundation. (n.d.). *IAS 10 Events after the Reporting Period*. https://www.ifrs.org/issued-standards/list-of-standards/ias-10-events-after-the-reporting-period.html/content/ + +PostgreSQL Global Development Group. (2026a). *PostgreSQL 18 documentation: Explicit locking*. https://www.postgresql.org/docs/18/explicit-locking.html + +PostgreSQL Global Development Group. (2026b). *PostgreSQL 18 documentation: Transaction isolation*. https://www.postgresql.org/docs/18/transaction-iso.html + +PostgreSQL Global Development Group. (2026c). *PostgreSQL 18 documentation: Serialization failure handling*. https://www.postgresql.org/docs/18/mvcc-serialization-failure-handling.html diff --git a/docs/doctoring/PERIOD_CLOSE_POSTED_ACCOUNT_IDENTITY_TRACEABILITY.md b/docs/doctoring/PERIOD_CLOSE_POSTED_ACCOUNT_IDENTITY_TRACEABILITY.md new file mode 100644 index 00000000..b00af64b --- /dev/null +++ b/docs/doctoring/PERIOD_CLOSE_POSTED_ACCOUNT_IDENTITY_TRACEABILITY.md @@ -0,0 +1,67 @@ +# Period Close posted chart-account identity traceability + +Status: Proposed on PR #53. This note records a live repair finding and does not claim protected integration, runner GREEN, release readiness, audit assurance, or an IFRS-prescribed PostgreSQL design. + +## Problem + +A posted journal line retains both `journal_entry_line.chart_account_id` and `journal_entry_line.account_role_code` as immutable accounting facts. Effective-dated chart-account and policy catalogs may legitimately change after posting. Hard close must therefore preserve the identity of the historical P&L account that actually owns the posted balance. + +PR #53 already stopped `_post_closing_journal()` from reclassifying historical P&L through the current `account_role_mapping`, but the current implementation still selects only `chart_account_code` plus the posted role. `_insert_journal()` then resolves that code again through `chart_account.valid_to IS NULL`. If the source chart account expires after posting but before hard close, the closing command can be stranded. If a later catalog version reuses the same code, resolving only today's code can redirect the closing offset to a different `chart_account_id`; the old posted account would not be zeroed on its own identity. + +The retained snapshot hash has the same identity blind spot in a more explicit form. `_canonical_snapshot_hash()` accepts each line as `(chart_account_id, chart_account_code, debit, credit)` but currently omits `chart_account_id` from the serialized hash payload. Two different effective-dated account Entities with the same code and amounts therefore produce the same retained-evidence digest even though `trial_balance_line.chart_account_id` distinguishes them. Provenance must bind the exact Entity that owns the balance, not merely its reusable display/business code. + +The hard-close preflight also assembles buyer-facing financial-statement projections before persistence. Those projections still use current effective chart-account / role mappings. A mutable Reporting-Export projection must not become a prerequisite authority that prevents Period Close from freezing otherwise valid immutable ledger facts. + +## Constraints + +- Posted journal headers and lines remain append-only; correction uses reversal/replacement rather than mutation. +- Ordinary new postings continue to require an active chart account. The repair must not globally relax `_insert_journal()` or allow callers to target expired accounts. +- The system-generated period-closing journal is purpose-limited. Its source-side contra lines exist to zero the exact historical temporary-account balances being closed. +- The retained-earnings destination is a new close-time accounting decision and therefore continues to use the current effective `retained_earnings` mapping. +- Retained snapshot provenance must remain deterministic for exact replay but change when the authoritative account Entity changes even if code and monetary values are identical. +- Billing or another foreign system gains no chart-account, posting, or close authority. + +## Alternatives + +1. Forbid chart-account expiry until every affected period hard-closes. Rejected: this couples master-data lifecycle to close timing and turns a reporting/catalog condition into ledger availability. +2. Auto-create an active alias or successor row for an expired code. Rejected: code equality does not prove account identity and may leave the historical account balance unclosed. +3. Route closing offsets to whichever active account currently has the same code. Rejected: a successor `chart_account_id` is a different Entity; posting the contra there does not zero the immutable source account. +4. Carry exact posted `chart_account_id` through the close source and use a purpose-limited historical-account insertion path only for the system closing journal. Selected direction. Ordinary posting keeps current-catalog admission, while retained earnings keeps current close-time policy resolution. + +The closeability check should depend on authoritative ledger/trial-balance facts rather than on successful construction of mutable buyer-report projections. Reporting can expose its own catalog-completeness error without acquiring veto authority over the close aggregate. + +## Executable evidence + +Test-first real PostgreSQL RED `6faea7dc50cb2421604daf7c10f7ad3aeadfd4cb` posts `usage_revenue`, records its immutable source `chart_account_id`, expires that chart account, and requires hard close to finish with the source-side closing line bound to the same account identity and the retained snapshot zeroed on that identity. + +The exact RED head `06342053f2937e94748b40ed9182b20cfbf0ef74` subsequently reached a real hosted runner. Accounting Foundation run `33979966465`, job `101343324928`, completed `failure` in the behavior/repository-test step while its exact-head dependency-diff, SAST, and security sibling jobs completed successfully. Raw step logs are not available through the current repository connector, so this note does not attribute that failure to one assertion beyond the checked-in RED contracts. It is nevertheless runner-observed failure evidence for the unchanged RED head, not a queued or synthetic result. + +Successor RED `c5266ce29c181474331e8a4b035f6d57d185ed2d` strengthens the temporal Entity boundary with an actual code-reuse transition. It posts revenue to one `chart_account_id`, ends that account and its role mapping, creates a successor account that legally reuses `410100` with a later `valid_from`, installs the successor mapping, and then requires hard close to put the historical contra on the original account rather than the successor. This separates code equality from Entity identity and prevents a repair that merely makes the current code lookup succeed again. + +Hash-provenance RED `ae2aaa1ad9c75068e5db4dc9850f40ebba991137` isolates the retained-evidence digest. It supplies identical tenant/entity/book/period/currency/journal-count/code/amount inputs with two different deterministic `chart_account_id` values. Exact replay of one Entity must hash identically, while substituting only the account Entity must change the digest. The current implementation fails that contract because it discards the UUID while serializing the line payload. + +Static RED `a612539a92bfd171b7037273858c7263e4eabc9e` simultaneously preserves the opposite boundary: ordinary `_insert_journal()` must continue requiring `valid_to IS NULL`, while `_post_closing_journal()` must carry and group the exact posted `journal_entry_line.chart_account_id` instead of reconstructing source identity from a current code lookup. + +Static authority-separation RED `0d3caba036c370f93deacc0e8208314cd9df9731` adds the second causal boundary exposed by the same PostgreSQL scenario: `close_fiscal_period()` must not require `_assemble_period_close_package()` before the authoritative hard-close write. The supported close still has to reach `_persist_period_close()` and preserve ledger/trial-balance invariants; buyer Reporting-Export projection completeness is not close authorization. + +Production candidate `3832cf72110ebc39d3978135400e0fb9378c34ac` applies the selected boundary in the canonical writer file. `_post_closing_journal()` now selects and groups the immutable posted `journal_entry_line.chart_account_id`, carries a line-number-to-Entity mapping, and gives that mapping only to the system closing-journal insertion. `_insert_journal()` retains its `valid_to IS NULL` lookup for every ordinary line and validates a supplied historical identity against the same tenant, book, and code without requiring the source Entity to remain active. The retained-earnings line has no historical override and therefore still resolves current close-time policy. `close_fiscal_period()` now checks balance directly from the locked ledger population instead of assembling mutable buyer-report projections, and `_canonical_snapshot_hash()` serializes the exact account UUID. + +This is a production candidate, not GREEN or protected integration. The current successor must independently pass the realistic expiry and code-reuse PostgreSQL cases, snapshot-hash identity test, static separation contracts, complete Foundation/security/SAST/CodeQL/package evidence, and current-head review on one unchanged exact head. Predecessor runner evidence does not transfer. + +## Standards boundary + +This repair implements AIP's DDD aggregate/entity identity, temporal accounting-evidence, append-only ledger, and auditability controls. IFRS Accounting Standards do not prescribe PostgreSQL identifiers, joins, triggers, hashes, or this insertion shape. Standards traceability should cite financial-reporting requirements only for the accounting outcome they support and keep implementation controls explicitly repository-owned. + +## Recovery and rollout + +No migration or historical journal rewrite is authorized by this finding. If a deployment encounters a period whose current catalog no longer exposes a historical P&L account, do not reactivate or remap the account merely to make close pass. Preserve the posted identity, apply the verified close repair through the normal release path, and rerun the same immutable close command identity from a fresh transaction. + +A future hash fix changes newly produced retained-evidence digests and therefore requires explicit compatibility/recovery evidence before release. Existing committed hard-close evidence must not be silently rehashed in place. If historical digest versioning is required, introduce it as a versioned evidence contract with migration/verification rules rather than mutating already-retained facts. + +Rollback of a future candidate is safe only before it produces new hard-close evidence under that candidate. Once a hard-close snapshot and closing journal have committed, accounting correction follows the repository's reversal/correction and audited migration rules; source facts are not rewritten to emulate a code rollback. + +## Downstream handoff + +PR #37 owns the canonical CHANGELOG, standards table, and `docs/product-technical-gap-baseline.md`. At integration time it should reconstruct the durable invariant: effective-dated chart-account or role changes cannot retrospectively change or strand the immutable account identity used by Period Close, and retained evidence hashes must bind that exact Entity identity. + +PR #52 / Reporting-Export must consume retained account identity from integrated AIP evidence. It must not infer historical account identity solely from the current chart-account or role catalog, and reporting catalog incompleteness must not manufacture a second Period Close authority. diff --git a/docs/doctoring/PERIOD_CLOSE_POSTED_ROLE_TRACEABILITY.md b/docs/doctoring/PERIOD_CLOSE_POSTED_ROLE_TRACEABILITY.md new file mode 100644 index 00000000..bd6f0fdb --- /dev/null +++ b/docs/doctoring/PERIOD_CLOSE_POSTED_ROLE_TRACEABILITY.md @@ -0,0 +1,46 @@ +# Period Close Posted-Role Traceability + +Status: production candidate / exact-head execution pending + +## Problem + +`journal_entry_line.account_role_code` is persisted with every posted journal line and is part of the immutable posted accounting fact. Before this repair, `PostgresPostingLedger._post_closing_journal()` ignored that historical role when selecting revenue and expense lines for the AIS-owned closing journal. Instead, it joined `account_role_mapping` and required the mapping to be current (`valid_to IS NULL`). + +A role mapping may legitimately expire or be superseded after a journal was posted and before the fiscal period is hard-closed. In that state the posted journal still contains the original role, but the hard-close query could omit or reclassify the historical P&L population. That could suppress the closing journal and retained-earnings transfer even though the journal and its role evidence were unchanged. + +## Authority and invariant + +The historical classification of an already-posted journal line is `accounting_core.journal_entry_line.account_role_code`. The effective-dated `account_role_mapping` is an Accounting Policy catalog used when a proposal is resolved for posting. It is not an authority for retrospectively reclassifying immutable posted facts. + +Period Close therefore classifies historical source P&L directly from the persisted journal-line role. The close-time lookup of the destination `retained_earnings` account remains a separate current-policy decision and is not changed by this repair. + +This is an AIP DDD, temporal-data, and audit-evidence control. It is not a claim that IFRS prescribes a PostgreSQL column, join shape, or implementation mechanism. + +## Test-first evidence + +Real PostgreSQL RED `ba6be58c3ce4f2dfbe3e6b27f2f3418cd0f71548` posts a `usage_revenue` line, expires the catalog mapping after posting, verifies the immutable journal line still carries `usage_revenue`, then hard-closes the period. The acceptance requires one closing journal and exact transfer from `410100` into retained earnings `310100`. + +Static RED `8dabaee5b43c24810f54479d5180df54220abb0d` pins the causal source boundary: `_post_closing_journal()` must select/filter/group by `journal_entry_line.account_role_code` and must not join `account_role_mapping` for historical P&L classification. + +Production candidate `2851c6aed7941363c3b7a570c7d2a2b4683c61a7` implements only that source-query repair. Relative to its exact parent `3dd278e2f8cbd1987062a9b337ac7ef944772d26`, the commit changes only `src/accounting_information_platform/persistence.py` with four additions and seven deletions. This scope check is necessary because the adapter contains unrelated Posting, Reporting-Export, VAT, HomeTax, reconciliation, and read-model behavior that must not be damaged by a focused close repair. + +The candidate is not GREEN evidence by itself. Exact-head PostgreSQL/Accounting Foundation execution is required, and predecessor runs do not transfer. + +## Selected repair + +Within `_post_closing_journal()` only: + +- select `journal_entry_line.account_role_code`; +- filter `journal_entry_line.account_role_code IN ('usage_revenue', 'write_off_expense')`; +- group by `chart_account.chart_account_code, journal_entry_line.account_role_code`; +- remove the `account_role_mapping` join from that historical source query. + +Do not change the posting-time effective-dated resolver, the immutable journal-line schema, reversal role preservation, or the close-time retained-earnings destination lookup. + +## Rejected alternatives + +Keeping the current catalog join is rejected because later master-data changes can rewrite hard-close semantics without changing the posted journal. Keeping expired mappings artificially current is rejected because it corrupts effective-dated policy resolution and can introduce multiple simultaneously effective mappings. Reconstructing the historical role from the present chart of accounts is rejected because the posted line already contains the authoritative fact. Weakening close or snapshot invariants is rejected because it would hide the misclassification rather than remove it. + +## Follow-up boundary + +Reporting paths that project historical journal semantics from current `account_role_mapping` are a separate Reporting-Export repair lane. They must consume immutable posted roles or immutable close snapshots rather than importing the Period Close fix as a second authority. Canonical product-gap, CHANGELOG, and standard-traceability wording remains the PR #37 single-writer responsibility. diff --git a/docs/doctoring/PERIOD_CLOSE_TRANSITION_AUTHORITY_TRACEABILITY.md b/docs/doctoring/PERIOD_CLOSE_TRANSITION_AUTHORITY_TRACEABILITY.md new file mode 100644 index 00000000..3559cb92 --- /dev/null +++ b/docs/doctoring/PERIOD_CLOSE_TRANSITION_AUTHORITY_TRACEABILITY.md @@ -0,0 +1,56 @@ +# Period Close transition authority traceability + +## Decision scope + +This note traces two database-authority repairs on the Period Close path: transaction-isolation admission for book-period state transitions and tenant-bound runtime creation of the pre-existing journal-population fence. These are Accounting Information Platform implementation controls. They do not define IFRS accounting policy, grant Billing authority over accounting facts, or make PostgreSQL locking/RLS primitives accounting standards. + +The authoritative accounting objects remain `accounting_book_period_control`, the immutable journal population, and retained close evidence. `fiscal_period` remains shared calendar/master data rather than a substitute book-level close authority. + +## Problem and constraints + +A close transition derives evidence under a transaction snapshot and then changes `accounting_book_period_control.period_status_code`. Migration 0033 already locks all 64 pre-existing `period_journal_population_fence` rows before a state change, but that mechanism depends on a snapshot isolation level that can detect a row version committed after the close snapshot. A raw `READ COMMITTED` state update can instead wait for the newest fence row and continue without the `40001` retry boundary expected by the supported close command. + +Separately, runtime insertion of a new book-period control synchronously invokes `seed_period_journal_population_fence()`. The target fence table is `FORCE ROW LEVEL SECURITY`. Its policy is tenant-scoped through `accounting_core.current_tenant_account_id()`, which resolves the original `session_user` through `runtime_tenant_binding`. Runtime callers whose effective database role is actually subject to RLS therefore need a matching tenant binding before target DML. Migration 0034's one-time owner repair is intentionally different: it temporarily removes FORCE RLS while backfilling canonical open book-period intersections and restores FORCE RLS afterward. + +`seed_period_journal_population_fence()` is `SECURITY DEFINER`, so PostgreSQL evaluates effective-role privileges with the function owner's `current_user`, while the tenant identity function deliberately derives its tenant from `session_user`. The explicit precondition must preserve PostgreSQL's own superuser/BYPASSRLS semantics; otherwise a diagnostic guard becomes stricter than the database operation it is meant to explain. + +## Alternatives and decisions + +| Concern | Selected control | Rejected alternative | Reason | +|---|---|---|---| +| Period transition isolation | Allow only `REPEATABLE READ` or `SERIALIZABLE` before any fence locking/state transition | Reject only the literal `read committed` string | PostgreSQL accepts `READ UNCOMMITTED` syntax but provides READ COMMITTED semantics; an allow-list fails closed for every weaker/unknown isolation level | +| Freshness witness | Preserve deterministic `FOR UPDATE` over all 64 pre-existing fence rows and the exact 64-row count check | Replace the fence with one shared revision row or remove it after adding the isolation check | Isolation and a pre-existing version witness solve different parts of the stale-close race; one shared row would reintroduce a posting hotspot | +| Runtime tenant precondition | When FORCE RLS is active and the effective function owner cannot bypass RLS, require `current_tenant_account_id()` to equal `NEW.tenant_account_id` before fence INSERT | Let an unbound RLS-subject runtime caller fall through to an opaque `WITH CHECK` failure | The explicit failure identifies the missing authority without weakening tenant isolation or synthesizing identity | +| Privileged/migration execution | Preserve superuser/BYPASSRLS effective-role behavior and migration 0034's temporary NO-FORCE owner backfill | Require runtime binding even when PostgreSQL itself would bypass RLS | A diagnostic precondition must not silently redefine PostgreSQL privilege semantics or break canonical install/test operators | +| Tenant identity | Continue using database-owned `runtime_tenant_binding`; do not revive caller-set tenant GUCs as authority | Mint an `app.tenant_account_id` or similar GUC inside the seeder | Caller-controlled context must not become accounting tenant authority | + +## TDD and repair lineage + +- `c5adb003590880730d5e67a528312a05f6ce15fb`: corrected the snapshot concurrency RED to acquire the actual tenant/book/period close advisory lock rather than a non-authoritative GUC. +- `a1d96d91546b82fef1593b3e8026dd5d301b169e`, `e91f783f7665acf7737c65e3dedee4431e3ecf0c`, `91ae0327379822c10421768546a7e722529b5978`: applied the same real snapshot-authority setup to currency, scope, and pre-close immutability REDs. +- `9aa4e02dbb7e27ba5beb943270da0c1b9dc8c113`: real PostgreSQL RED for a raw weak-isolation book-period state transition. +- `ab5cd11798d06f5b769dd41d23ea7e12e5cef42c`: first causal isolation repair, rejecting `READ COMMITTED` before fence locking. +- `4f146ad9ab67cf449acd3460b69e5a347d4dbcdd`: added the PostgreSQL `READ UNCOMMITTED` alias edge case, making the literal-only guard RED again. +- `0d0077bbec4afc9d54bb8b4838e5cf84dd9f4473`: replaced the blacklist with the fail-closed `REPEATABLE READ`/`SERIALIZABLE` allow-list. +- `23e6441e691c1e44c64aecac56f96fdf0bd93ecc`: static RED for an explicit runtime tenant-binding precondition before FORCE-RLS fence DML. +- `d1a294aa34b99bdcb71a4796c36dc74be6664502`: initial explicit binding guard. +- Source review then found that the initial guard was stricter than PostgreSQL for an effective superuser/BYPASSRLS `SECURITY DEFINER` owner. `bd1772bb06d380a3a623596e880105234cf7fb1c` and `66867a847ca499721f5a979251026a55359f7244` ratcheted the required effective-role distinction without rewriting history. +- `ec4d2c3583988b5bcb2458cd2d27f4050f2d1f0f`: current causal repair. It checks `pg_roles.rolsuper/rolbypassrls` for `current_user`, preserves PostgreSQL's effective-role RLS bypass semantics, and requires the `session_user` tenant binding only when the effective role is actually subject to FORCE RLS. + +These SHAs are development lineage, not release evidence. A successor head does not inherit GREEN from a predecessor. + +## Remaining acceptance and risk + +The exact current descendant must still run the real PostgreSQL behavior suite, complete statement/branch coverage, security/SAST/dependency checks, and central required workflows. A queued or runner-less run is not GREEN. + +This repair also does not close the separate historical-account-identity RED in `_post_closing_journal()`, the Reporting-package veto coupling on hard close, or the retained snapshot hash identity gap. Those findings remain independent and must not be hidden by declaring Period Close complete. + +If test execution is later parallelized, any migration-upgrade test that temporarily removes a database trigger must move to a dedicated database or an explicitly serialized lane. The current canonical Accounting Foundation workflow executes `unittest discover` serially against a job-local PostgreSQL service, so that future condition is not currently satisfied. + +## References + +PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: Row security policies*. https://www.postgresql.org/docs/18/ddl-rowsecurity.html + +PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: Transaction isolation*. https://www.postgresql.org/docs/18/transaction-iso.html + +PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: Explicit locking*. https://www.postgresql.org/docs/18/explicit-locking.html diff --git a/docs/doctoring/SOFT_CLOSE_COMMAND_EVIDENCE_PAIR_TRACEABILITY.md b/docs/doctoring/SOFT_CLOSE_COMMAND_EVIDENCE_PAIR_TRACEABILITY.md new file mode 100644 index 00000000..11b57a9f --- /dev/null +++ b/docs/doctoring/SOFT_CLOSE_COMMAND_EVIDENCE_PAIR_TRACEABILITY.md @@ -0,0 +1,46 @@ +# Soft-close command-evidence pair traceability + +## Problem + +`accounting_book_period_control` is the authoritative per-book period state. Migration 0010 made the three soft-close evidence fields internally all-or-none, but it did not require a row whose `period_status_code` is `soft_closed` to retain that evidence. Migration 0009 can also predate that evidence model and copy a shared fiscal-period `soft_closed` projection into a book-period control row. The result is a one-sided authoritative close fact that the application later refuses to replay because the original command identity, source hash, and source journal count are absent. + +This is an Accounting Information Platform DDD/database integrity control. IFRS Accounting Standards do not prescribe this PostgreSQL trigger design. + +## Constraints + +The supported soft-close command changes the exact book-period status and writes `soft_close_idempotency_key`, `soft_close_source_payload_hash`, and `soft_close_source_journal_count` as two statements inside one transaction. A valid repair therefore cannot require all evidence on the first statement image. It must inspect the final retained row at commit. + +Historical evidence must not be invented from the current ledger population. An unverifiable pre-migration `soft_closed` row must not be silently reopened either; that would rewrite an authoritative accounting-control fact without proving the original decision. + +The repair must preserve tenant FORCE RLS, the existing 64-row journal-population freshness fence, exact hard-close/snapshot pairing, ordinary journal admission, and the application-owned idempotent soft-close command. + +## Alternatives considered + +- **Reset legacy incomplete rows to `open`.** Rejected because it rewrites an existing close state without proving that reopening was authorized. +- **Derive missing evidence from the later journal population.** Rejected because later state cannot prove the original command identity or source population observed when soft-close occurred. +- **Immediate `BEFORE`/`AFTER` validation of the transition row image.** Rejected because the canonical command intentionally writes state and evidence in separate statements of one transaction. +- **Caller-controlled GUC or application-only validation.** Rejected because it would weaken the database single-writer/fail-closed boundary and leave direct SQL capable of retaining an incomplete authority fact. + +## Decision + +Migration `0037_soft_close_command_evidence_pair.sql` adds two controls: + +1. An upgrade preflight temporarily grants only the migration role all-tenant SELECT visibility on the FORCE-RLS control relation and fails with `soft_close_command_evidence_pair_legacy_preflight` when any pre-existing `soft_closed` row lacks one of the three durable evidence values. The temporary policy is removed before durable trigger installation. +2. A `DEFERRABLE INITIALLY DEFERRED` constraint trigger fires on transitions into `soft_closed`. At commit it re-reads the exact tenant/book/period row and requires the final retained state to contain the complete command-evidence triplet. Incomplete state fails with `soft_close_command_evidence_pair_required` and rolls the transaction back. + +The trigger function is `SECURITY DEFINER` with `search_path = pg_catalog, pg_temp`; PUBLIC execute is revoked. It grants no posting, reopening, hard-close, reporting, Billing, or policy authority. + +## RED → repair → ratchet + +- RED `efc191611edc8f450ac7a58023d10075d8542f93` adds a real PostgreSQL case for raw `open -> soft_closed` without command evidence and an upgrade case for a pre-existing one-sided soft-close fact. +- Repair `7279f05a6c1d067c25b78b8df10e5b7b99acad0e` adds migration 0037 with the legacy preflight and deferred final-row pair guard. +- Installer `ce91cc19339fb8bfba5fd5b9698b321c59cf5c9e` appends 0037 after the existing 0036 hard-close/snapshot pair migration. +- Static ratchet `c594c78bcfaeca5c2029a21e3ef0b581d6e15fa5` requires the fail-closed markers, deferred semantics, temporary-policy cleanup, PUBLIC revoke, and exact installer order. + +Hosted exact-head execution evidence is separate from this source lineage. A predecessor run does not prove the current head. + +## Recovery and release effect + +If upgrade preflight finds a legacy one-sided soft-close row, stop the migration and retain the prior release/database. Inventory the exact tenant/book/period control and original command/audit/outbox evidence. Proceed only through a separately reviewed audited remediation that can prove the original soft-close command identity and source population. Do not synthesize the hash/count from current balances and do not flip the row to `open` merely to pass migration 0037. + +Release evidence must show migration 0037 installs under the production-like non-superuser/non-`BYPASSRLS` migration owner, the canonical soft-close command still commits state and evidence atomically, direct incomplete soft-close rolls back, the legacy preflight is atomic, and no temporary migration policy survives either successful or aborted installation. diff --git a/docs/doctoring/TRIAL_BALANCE_CONSERVATION_TRACEABILITY.md b/docs/doctoring/TRIAL_BALANCE_CONSERVATION_TRACEABILITY.md new file mode 100644 index 00000000..226ed6be --- /dev/null +++ b/docs/doctoring/TRIAL_BALANCE_CONSERVATION_TRACEABILITY.md @@ -0,0 +1,50 @@ +# Trial-balance conservation traceability + +## Control statement + +A retained hard-close trial-balance line is one accounting fact. Its three stored monetary fields are not independent caller assertions: `net_balance_amount` must equal `debit_total_amount - credit_total_amount` exactly in PostgreSQL `numeric(38, 6)`. + +The database previously constrained debit and credit to non-negative fixed-scale values but did not relate either amount to `net_balance_amount`. The supported close implementation calculates the net from debit and credit before persistence, yet a direct or legacy writer could persist a different net while still satisfying every row constraint. Because the retained population is later consumed as Period Close and reporting evidence, the invariant belongs at the authoritative PostgreSQL boundary rather than only in the application calculation. + +## Standard and platform basis + +The IFRS Conceptual Framework describes useful financial information as faithfully representing the economic phenomenon it purports to represent and identifies completeness, neutrality, and freedom from error as characteristics of a faithful representation. This does **not** prescribe a SQL formula or a trial-balance table design. AIS uses it only as the financial-reporting quality rationale for refusing internally contradictory retained monetary evidence. + +PostgreSQL 18 `CHECK` constraints are the direct enforcement mechanism because the relation is immutable and row-local. PostgreSQL documents that a `CHECK` added `NOT VALID` is enforced for subsequent inserts and updates while skipping the initial table scan, and that `VALIDATE CONSTRAINT` later scans inherited rows with a less restrictive validation lock. PostgreSQL transaction locks are held until transaction end, so placing both operations in migration 0030 would still retain the stronger `ADD CONSTRAINT` lock throughout validation. The repaired chain therefore commits migration 0030 after adding the `NOT VALID` check and runs migration 0031 as a separate autocommit validation statement. An inherited inconsistency blocks completion of the migration chain; no historical amount is rewritten. + +## Executable trace + +| Layer | Exact control | +|---|---| +| Authoritative table | `accounting_reporting.trial_balance_line` | +| Monetary invariant | `net_balance_amount = debit_total_amount - credit_total_amount` | +| Constraint | `trial_balance_line_net_balance_conservation` | +| Constraint install | `database/migrations/0030_trial_balance_snapshot_immutability.sql` | +| Historical validation | `database/migrations/0031_trial_balance_line_conservation_validation.sql` | +| Canonical installer | `src/accounting_information_platform/migration_install.py` | +| Real PostgreSQL regression | `tests/test_postgres_trial_balance_snapshot_scope_red.py::TrialBalanceSnapshotScopePostgresTests::test_snapshot_line_rejects_nonconserving_net_balance` | +| Migration lock contract | `tests/test_trial_balance_snapshot_immutability_contract.py::TrialBalanceSnapshotImmutabilityContractTests::test_line_conservation_validation_uses_a_separate_autocommit_migration` | +| Arithmetic RED | `03eb7112ff7a6ce67b6fd4d6b0c99f00d3d93aae` | +| Initial arithmetic implementation | `06d365b13510735c792a8625b4f6d9011d1f6525` | +| Lock-separation RED | `d6b75eb42b2cd7cccb0d4adbb1fbc16a295c328d` | +| Lock-separation repair | `605ffe0ef2a4c075f46521f63724e910f030c3d5`, `e6a3ec183b78061ec33b5b364c46991a3f8ceb7c`, `abdd2df05935c50c7764356ef626ae365338bdfe` | +| Decision record | `docs/adr/0006-fiscal-period-close-snapshot.md` | +| Owning PR | `#53` | + +The arithmetic regression uses a valid tenant, legal entity, accounting book, fiscal period, snapshot, and same-book chart account, then attempts to retain debit `10.250000`, credit `3.125000`, and net `999.000000`. The expected PostgreSQL failure names `trial_balance_line_net_balance_conservation`. The case isolates arithmetic conservation from tenant, book-scope, close-authority, and immutability failures. + +The migration-lock contract prevents a future refactor from moving `VALIDATE CONSTRAINT` back into 0030. The canonical installer must require and execute 0031 after 0030 on its autocommit connection; absence of either file fails the supported installation boundary closed. + +## Authority and non-claims + +This control does not make a report IFRS-compliant, audited, assured, approved, or filing-ready. It does not decide accounting policy, select chart accounts, post or reverse journals, close a period, approve reconciliation, or write Billing-owned commercial truth. Reporting and export projections may consume the retained values only after this accounting-owned invariant has been satisfied; they must not create a second monetary authority by overriding the retained debit, credit, or net amounts. + +A later reopen or correction policy must preserve the old retained population through explicit successor lineage. It must not repair a historical inconsistency by silently editing an immutable hard-close row. + +## References + +IFRS Foundation. (2022). *Conceptual framework for financial reporting*. https://www.ifrs.org/content/dam/ifrs/publications/pdf-standards/english/2022/issued/part-a/conceptual-framework-for-financial-reporting.pdf + +PostgreSQL Global Development Group. (2026a). *PostgreSQL 18 documentation: Constraints*. https://www.postgresql.org/docs/18/ddl-constraints.html + +PostgreSQL Global Development Group. (2026b). *PostgreSQL 18 documentation: ALTER TABLE*. https://www.postgresql.org/docs/18/sql-altertable.html diff --git a/docs/doctoring/TRIAL_BALANCE_SNAPSHOT_AUTHORITY_TRACEABILITY.md b/docs/doctoring/TRIAL_BALANCE_SNAPSHOT_AUTHORITY_TRACEABILITY.md new file mode 100644 index 00000000..6e96987c --- /dev/null +++ b/docs/doctoring/TRIAL_BALANCE_SNAPSHOT_AUTHORITY_TRACEABILITY.md @@ -0,0 +1,85 @@ +# Trial-balance snapshot authority traceability + +## Problem + +A retained `trial_balance_snapshot` is Period Close evidence. Before this repair, the final migration-0033 snapshot guard admitted a direct SQL insert in a `soft_closed` book-period when the session had `accounting_closing_writer` membership and set the caller-controlled `accounting_core.journal_write_role` custom setting to `period_closing`, even when the canonical tenant/book/period close advisory lock was absent. That made mutable session context sufficient to create retained close evidence outside the governed hard-close command. + +This is an Accounting Information Platform authority defect, not an IFRS requirement. IFRS does not prescribe PostgreSQL advisory-lock or session-setting mechanics. + +## Constraints + +- `trial_balance_snapshot` remains owned by the Period Close bounded context. +- A caller-controlled setting may describe journal-admission context but cannot mint retained close authority. +- Direct `open -> hard_closed` remains supported. +- A zero-net-income hard close may create a snapshot without emitting a period-closing journal, so snapshot authority cannot depend on a journal side effect. +- The database capability and exact tenant/book/period close lock are both required; neither replaces tenant scope, immutable source derivation, idempotency, snapshot immutability, or the journal-population freshness fences. +- No posting, Billing, Reporting, LLM, or generic runtime path gains snapshot authority. + +## Alternatives considered + +1. Keep `journal_write_role = period_closing` as an alternate proof. Rejected because PostgreSQL session settings are mutable session state. PostgreSQL documents `set_config` as the SQL-level mechanism for changing run-time settings, so the value is not an unforgeable command receipt. +2. Require a period-closing journal row before snapshot insertion. Rejected because a valid zero-net-income hard close may need no closing journal. +3. Require the exact close advisory lock plus `accounting_closing_writer`. Selected because the canonical hard-close command already holds that lock before deriving retained evidence, including the zero-closing-journal case, and the database can verify the lock on the current backend and exact two-key identity through `pg_locks`. + +## Selected control + +Migration 0030 now requires all of the following for its `soft_closed` snapshot admission boundary: + +- authoritative `accounting_book_period_control.period_status_code = 'soft_closed'`; +- `pg_has_role(session_user, 'accounting_closing_writer', 'MEMBER')`; +- the exact tenant/book/period exclusive advisory lock held by `pg_backend_pid()`. + +Migration 0033 replaces that guard for the final supported chain and applies the same lock requirement to both `open` and `soft_closed` hard-close snapshot creation. Its snapshot guard no longer reads `accounting_core.journal_write_role`. + +The journal-admission guard still uses `journal_write_role` to distinguish purpose-limited soft-close journal kinds. That is a separate control: retained snapshot authority no longer treats the setting as proof of a hard-close command. + +## RED -> repair evidence + +- RED `ef2f9dc5dd0644826905f053c0a791579b9c4000`: replaces the former direct-GUC chronology probe with a real PostgreSQL regression that sets `accounting_core.journal_write_role = period_closing` and requires the forged snapshot insert to fail with `trial_balance_snapshot_authority_required`. +- Final-chain repair `e2a01fc98737b767b2cab3db80d8cf69b09b48af`: migration 0033 requires the exact close lock for both open and soft-closed snapshot admission and removes GUC reliance from the snapshot guard. +- Upgrade-window repair `b790b5d10e870d770d48ccb93485192c11d9466b`: migration 0030 also requires the exact close lock, so a database between 0030 and 0033 does not expose the earlier GUC-only snapshot path. +- Static ratchet `a677226426d4daff6f4b3dbcb6110eabe050f6c7`: repository contracts require both snapshot-guard versions to remain lock-bound and reject reintroduction of `journal_write_role_value` into snapshot authority. + +These commits are development lineage only. They are not release evidence until one unchanged exact head passes the real PostgreSQL suite, current security/SAST/dependency gates, required review, stack integration, and release controls. + +## Verification and recovery + +The governed hard-close regressions must still prove that: + +- ordinary and zero-closing-journal hard close each create exactly one retained snapshot; +- GUC-only direct SQL cannot create a snapshot; +- a missing or wrong advisory lock fails before retained evidence persists; +- failed close transactions leave no snapshot, closing journal, close event, or hard-close transition; +- retries start from a fresh transaction with the same immutable command/source identity. + +If lock-identity logic changes, update the application lock acquisition, both migration guards, PostgreSQL acceptance tests, ADR 0006, and this trace together. Do not recover by manually inserting or relabeling retained snapshots. + +## Buyer read authority follow-up + +Snapshot admission and snapshot selection are separate authority boundaries. A retained snapshot can be correctly created and immutable while a buyer-facing read still bypasses it. + +`accounting_book_period_control` is the authoritative tenant/book/period close state. `fiscal_period.period_status_code` is only the aggregate compatibility projection across active books. When a statutory book is hard-closed while a management sibling remains open, the aggregate calendar projection is intentionally still `open`. A default read for the hard-closed statutory book must nevertheless return its retained snapshot; otherwise a closed-book financial report can be rebuilt from mutable live journals. + +Static RED `3ff7ac365bf2e8d15c44f6a47a5f6b568906874b` therefore requires `PostgresPostingLedger.load_period_trial_balance()` to consume `_load_book_period_state()` and rejects `_require_fiscal_period()` as the source of close-state selection. Real-PostgreSQL RED `a26e35a7a5782ba1d3401f274e61f8ac168da0d6` hard-closes one statutory book while an active sibling remains open, then requires the default statutory read to report `period_status_code=hard_closed`, `balance_source_code=snapshot`, and the exact retained `snapshot_record_id`. + +Production repair `9af6fe8aa534195ca040cfc3f1b5d7c85612650a` resolves the requested book first, loads its exact `accounting_book_period_control` state, fails closed when that control is absent, and uses that selected-book status to choose retained snapshot versus live aggregation. It does not change aggregate calendar semantics, synthesize missing control rows, add a Reporting-owned close-state copy, or weaken explicit `unadjusted`/`adjusted` worksheet semantics. + +## Commit-time snapshot/status pairing + +Insert-time snapshot admission and commit-time Period Close authority are different checks. The canonical hard-close transaction inserts the retained snapshot while the selected `accounting_book_period_control` is still `soft_closed`, writes the retained lines, and only then advances that exact control to `hard_closed`. Consequently an immediate insert trigger cannot require `hard_closed` without breaking the valid command, but the previous database contract also allowed a purpose-limited closing session holding the exact close lock to insert a snapshot and commit while leaving the book-period `soft_closed`. + +Real-PostgreSQL RED `1c1360ebf9d0ab0ece0237b820567ff834999abe` reproduces that boundary: it acquires the exact tenant/book/period close lock, inserts a valid retained snapshot into a soft-closed book-period, and requires transaction commit to fail rather than retain unpaired close evidence. Migration `0035_trial_balance_snapshot_hard_close_pair.sql` in repair `9c810ea3f96fe0a79c94128a8569e0c7472be665` adds an `AFTER INSERT` constraint trigger declared `DEFERRABLE INITIALLY DEFERRED`. At deferred execution it resolves the exact tenant/book/period control and raises `trial_balance_snapshot_hard_close_pair_required` unless the final status is `hard_closed`. Installer commit `1a9f28c56102f1eda617a49e9880d31025f3caca` makes 0035 part of every supported foundation install, and static ratchet `9c17dab313870e851c484a4f214d7609364e6c30` pins the deferred timing, hardened function boundary, diagnostic, and installer membership. + +PostgreSQL 18 explicitly permits constraint triggers to run at the end of the containing transaction and requires them to be `AFTER ROW` triggers; `DEFERRABLE INITIALLY DEFERRED` therefore matches the transaction shape rather than inventing an application-side second authority. If the deferred check fails, PostgreSQL aborts the transaction, so the snapshot, its lines, the later status change, and close outbox work do not become a partially committed accounting fact. Recovery is a whole-command retry after the defect is corrected; operators must not relabel the book-period or manually insert/delete retained evidence to satisfy the guard. + +This commit-pair invariant is an AIP DDD/database consistency decision. IFRS does not prescribe PostgreSQL constraint-trigger timing. ADR 0006 already defines hard close as one snapshot-and-status transaction; migration 0035 makes that existing decision enforceable at the commit boundary. + +## References + +PostgreSQL Global Development Group. (2026a). *PostgreSQL 18 documentation: System administration functions*. https://www.postgresql.org/docs/18/functions-admin.html + +PostgreSQL Global Development Group. (2026b). *PostgreSQL 18 documentation: pg_locks*. https://www.postgresql.org/docs/18/view-pg-locks.html + +PostgreSQL Global Development Group. (2026c). *PostgreSQL 18 documentation: Setting parameters*. https://www.postgresql.org/docs/18/config-setting.html + +PostgreSQL Global Development Group. (2026d). *PostgreSQL 18 documentation: CREATE TRIGGER*. https://www.postgresql.org/docs/18/sql-createtrigger.html diff --git a/src/accounting_information_platform/migration_install.py b/src/accounting_information_platform/migration_install.py index d1adcc03..7fd3fd54 100644 --- a/src/accounting_information_platform/migration_install.py +++ b/src/accounting_information_platform/migration_install.py @@ -63,6 +63,15 @@ def apply_foundation_migration(database_url: str, migration_path: Path) -> None: migration_path.parent / "0026_reconciliation_lifecycle_source_payload_identity.sql", migration_path.parent / "0027_reconciliation_lifecycle_session_lock_authority.sql", migration_path.parent / "0028_reconciliation_lifecycle_capability_privileges.sql", + migration_path.parent / "0029_trial_balance_snapshot_population_unique_index.sql", + migration_path.parent / "0030_trial_balance_snapshot_immutability.sql", + migration_path.parent / "0031_trial_balance_line_conservation_validation.sql", + migration_path.parent / "0032_period_close_journal_population_fence.sql", + migration_path.parent / "0033_open_period_journal_population_fence.sql", + migration_path.parent / "0034_book_period_control_seed.sql", + migration_path.parent / "0035_trial_balance_snapshot_hard_close_pair.sql", + migration_path.parent / "0036_hard_close_trial_balance_snapshot_pair.sql", + migration_path.parent / "0037_soft_close_command_evidence_pair.sql", ) for forward_migration_path in forward_migration_paths: if not forward_migration_path.is_file(): diff --git a/src/accounting_information_platform/persistence.py b/src/accounting_information_platform/persistence.py index 27cb0cfb..737dc6f6 100644 --- a/src/accounting_information_platform/persistence.py +++ b/src/accounting_information_platform/persistence.py @@ -1075,9 +1075,6 @@ def close_fiscal_period( self._active_connection = connection try: tenant_id = self._require_tenant(connection) - self._acquire_command_lock( - connection, f"period:{accounting_book_reference}:{period_code}" - ) legal_entity_id = self._require_legal_entity( connection, tenant_id, @@ -1087,6 +1084,9 @@ def close_fiscal_period( book_id, reporting_currency_code = self._require_book_for_close( connection, tenant_id, legal_entity_id, accounting_book_reference ) + self._acquire_command_lock( + connection, f"period:{book_id}:{period_code}" + ) if snapshot_currency_code != reporting_currency_code: raise AccountingValidationError( f"snapshot currency {snapshot_currency_code} does not match book reporting " @@ -1144,12 +1144,23 @@ def close_fiscal_period( accounting_book_reference=accounting_book_reference, idempotency_key=close_idempotency_key, ) - package = self._assemble_period_close_package( - legal_entity_reference, - accounting_book_reference, - period_code, + close_lines = self._aggregate_trial_balance( + connection, tenant_id, legal_entity_id, book_id, period_end_date + ) + debit_total = sum( + (line[2] for line in close_lines), + Decimal("0"), ) - self._require_closeable_package(package) + credit_total = sum( + (line[3] for line in close_lines), + Decimal("0"), + ) + if debit_total != credit_total: + raise AccountingValidationError( + "trial balance does not balance. " + "Correct the posted journals so debit totals equal credit totals, " + "then retry the close." + ) return self._persist_period_close( connection, tenant_id=tenant_id, @@ -3225,12 +3236,15 @@ def load_period_trial_balance( accounting_book_reference, next_action="the trial-balance read", ) - period_id, period_status_code, period_end_date = self._require_fiscal_period( - connection, - tenant_id, - period_code, - next_action="the trial-balance read", + period_state = self._load_book_period_state( + connection, tenant_id, book_id, period_code ) + if period_state is None: + raise AccountingValidationError( + f"Fiscal period {period_code} has no control row for this accounting book. " + "Repair the fiscal-period control data for this book, then retry the trial-balance read." + ) + period_id, period_status_code, _period_start_date, period_end_date = period_state snapshot_record_id = None if balance_basis_code == "post_close": snapshot = self._latest_close_snapshot( @@ -4426,14 +4440,11 @@ def _require_open_book_period_bounds( """ SELECT fiscal_period.fiscal_period_id, fiscal_period.period_code, - COALESCE( - accounting_book_period_control.period_status_code, - fiscal_period.period_status_code - ), + accounting_book_period_control.period_status_code, fiscal_period.period_start_date, fiscal_period.period_end_date FROM accounting_core.fiscal_period - LEFT JOIN accounting_core.accounting_book_period_control + JOIN accounting_core.accounting_book_period_control ON accounting_book_period_control.tenant_account_id = fiscal_period.tenant_account_id AND accounting_book_period_control.fiscal_period_id @@ -4451,19 +4462,15 @@ def _require_open_book_period_bounds( "Create an open fiscal period on the tenant calendar, then retry posting." ) period_id, period_code = row[0], row[1] - self._acquire_command_lock(connection, f"period:{book_id}:{period_code}") row = connection.execute( """ SELECT fiscal_period.fiscal_period_id, fiscal_period.period_code, - COALESCE( - accounting_book_period_control.period_status_code, - fiscal_period.period_status_code - ), + accounting_book_period_control.period_status_code, fiscal_period.period_start_date, fiscal_period.period_end_date FROM accounting_core.fiscal_period - LEFT JOIN accounting_core.accounting_book_period_control + JOIN accounting_core.accounting_book_period_control ON accounting_book_period_control.tenant_account_id = fiscal_period.tenant_account_id AND accounting_book_period_control.fiscal_period_id @@ -4735,10 +4742,10 @@ def _lock_book_period( book_id: UUID, period_code: str, ) -> tuple[UUID, str, date]: - """Materialize and lock close state independently for one accounting book.""" + """Lock the authoritative close state for one accounting book, failing closed if absent.""" period_row = connection.execute( """ - SELECT fiscal_period_id, period_status_code, period_closed_at + SELECT fiscal_period_id FROM accounting_core.fiscal_period WHERE tenant_account_id = %s AND period_code = %s """, @@ -4750,28 +4757,6 @@ def _lock_book_period( "Create the fiscal_period row, then retry the close." ) period_id = period_row[0] - connection.execute( - """ - INSERT INTO accounting_core.accounting_book_period_control ( - tenant_account_id, accounting_book_id, fiscal_period_id, - period_status_code, period_closed_at - ) - SELECT accounting_book.tenant_account_id, - accounting_book.accounting_book_id, - fiscal_period.fiscal_period_id, - fiscal_period.period_status_code, - fiscal_period.period_closed_at - FROM accounting_core.accounting_book - JOIN accounting_core.fiscal_period - ON fiscal_period.tenant_account_id = accounting_book.tenant_account_id - WHERE accounting_book.tenant_account_id = %s - AND accounting_book.valid_to IS NULL - AND fiscal_period.fiscal_period_id = %s - ON CONFLICT (tenant_account_id, accounting_book_id, fiscal_period_id) - DO NOTHING - """, - (tenant_id, period_id), - ) row = connection.execute( """ SELECT fiscal_period.fiscal_period_id, @@ -4804,18 +4789,15 @@ def _load_book_period_state( book_id: UUID, period_code: str, ) -> tuple[UUID, str, date, date] | None: - """Return the selected book's period state, falling back to legacy calendar state.""" + """Return the selected book's authoritative period-control state when recorded.""" row = connection.execute( """ SELECT fiscal_period.fiscal_period_id, - COALESCE( - accounting_book_period_control.period_status_code, - fiscal_period.period_status_code - ), + accounting_book_period_control.period_status_code, fiscal_period.period_start_date, fiscal_period.period_end_date FROM accounting_core.fiscal_period - LEFT JOIN accounting_core.accounting_book_period_control + JOIN accounting_core.accounting_book_period_control ON accounting_book_period_control.tenant_account_id = fiscal_period.tenant_account_id AND accounting_book_period_control.fiscal_period_id @@ -5356,8 +5338,9 @@ def _post_closing_journal( ) income_rows = connection.execute( """ - SELECT chart_account.chart_account_code, - account_role_mapping.account_role_code, + SELECT journal_entry_line.chart_account_id, + chart_account.chart_account_code, + journal_entry_line.account_role_code, SUM(journal_entry_line.debit_amount), SUM(journal_entry_line.credit_amount) FROM accounting_core.journal_entry_line @@ -5367,25 +5350,25 @@ def _post_closing_journal( JOIN accounting_core.chart_account ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id AND chart_account.chart_account_id = journal_entry_line.chart_account_id - JOIN accounting_core.account_role_mapping - ON account_role_mapping.tenant_account_id = chart_account.tenant_account_id - AND account_role_mapping.chart_account_id = chart_account.chart_account_id - AND account_role_mapping.valid_to IS NULL WHERE general_journal.tenant_account_id = %s AND general_journal.legal_entity_id = %s AND general_journal.accounting_book_id = %s AND general_journal.accounting_date <= %s - AND account_role_mapping.account_role_code IN ( + AND journal_entry_line.account_role_code IN ( 'usage_revenue', 'write_off_expense' ) - GROUP BY chart_account.chart_account_code, account_role_mapping.account_role_code - ORDER BY chart_account.chart_account_code + GROUP BY journal_entry_line.chart_account_id, + chart_account.chart_account_code, + journal_entry_line.account_role_code + ORDER BY chart_account.chart_account_code, + journal_entry_line.chart_account_id """, (tenant_id, legal_entity_id, book_id, period_end_date), ).fetchall() closing_lines: list[PostedJournalLine] = [] + historical_chart_account_ids: dict[int, UUID] = {} retained_earnings_amount = Decimal("0") - for account_code, role_code, debit_total, credit_total in income_rows: + for account_id, account_code, role_code, debit_total, credit_total in income_rows: net_amount = Decimal(credit_total) - Decimal(debit_total) if net_amount == 0: continue @@ -5410,6 +5393,7 @@ def _post_closing_journal( credit_amount=-net_amount, ) ) + historical_chart_account_ids[line_number] = account_id retained_earnings_amount += net_amount if not closing_lines: return @@ -5489,6 +5473,7 @@ def _post_closing_journal( policy=policy, proposal_record_id=proposal_record_id, lines=tuple(closing_lines), + historical_chart_account_ids=historical_chart_account_ids, ) def _require_retained_earnings_mapping( @@ -5660,6 +5645,7 @@ def _insert_journal( policy: AccountingPolicy, proposal_record_id: UUID, lines: tuple[PostedJournalLine, ...], + historical_chart_account_ids: Mapping[int, UUID] | None = None, ) -> UUID: connection.execute( "SELECT set_config('accounting_core.journal_write_role', %s, true)", @@ -5692,17 +5678,40 @@ def _insert_journal( ), ).fetchone()[0] for line in lines: - chart_account_id = connection.execute( - """ - SELECT chart_account_id - FROM accounting_core.chart_account - WHERE tenant_account_id = %s - AND accounting_book_id = %s - AND chart_account_code = %s - AND valid_to IS NULL - """, - (tenant_id, book_id, line.chart_account_code), - ).fetchone() + historical_chart_account_id = ( + historical_chart_account_ids.get(line.line_number) + if historical_chart_account_ids is not None + else None + ) + if historical_chart_account_id is None: + chart_account_id = connection.execute( + """ + SELECT chart_account_id + FROM accounting_core.chart_account + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND chart_account_code = %s + AND valid_to IS NULL + """, + (tenant_id, book_id, line.chart_account_code), + ).fetchone() + else: + chart_account_id = connection.execute( + """ + SELECT chart_account_id + FROM accounting_core.chart_account + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND chart_account_id = %s + AND chart_account_code = %s + """, + ( + tenant_id, + book_id, + historical_chart_account_id, + line.chart_account_code, + ), + ).fetchone() if chart_account_id is None: raise AccountingValidationError( f"Chart account {line.chart_account_code} is not recorded on this book. " @@ -6417,11 +6426,12 @@ def _canonical_snapshot_hash( "lines": [ { "chart_account_code": account_code, + "chart_account_id": str(account_id), "credit_total_amount": format(credit_total, "f"), "debit_total_amount": format(debit_total, "f"), "net_balance_amount": format(debit_total - credit_total, "f"), } - for _account_id, account_code, debit_total, credit_total in lines + for account_id, account_code, debit_total, credit_total in lines ], "period_code": period_code, "snapshot_currency_code": snapshot_currency_code, diff --git a/tests/test_book_period_application_authority_contract.py b/tests/test_book_period_application_authority_contract.py new file mode 100644 index 00000000..7597d49e --- /dev/null +++ b/tests/test_book_period_application_authority_contract.py @@ -0,0 +1,89 @@ +"""Application-side contracts for accounting-book period authority. + +Migration 0034 deliberately leaves unsupported non-open book/period pairs absent. +The persistence adapter must therefore treat a missing accounting_book_period_control +row as missing authority rather than rebuilding or inferring it from fiscal_period. +""" + +from __future__ import annotations + +import inspect +import unittest + +from accounting_information_platform.persistence import PostgresPostingLedger + + +class BookPeriodApplicationAuthorityContractTests(unittest.TestCase): + """Keep application helpers aligned with the book-scoped PostgreSQL authority.""" + + def test_close_lock_is_read_lock_fail_closed_only(self) -> None: + source = inspect.getsource(PostgresPostingLedger._lock_book_period) + + self.assertNotIn( + "INSERT INTO accounting_core.accounting_book_period_control", + source, + "close runtime must not manufacture book-period authority", + ) + self.assertNotIn( + "fiscal_period.period_status_code", + source, + "close runtime must not copy the shared calendar projection into book authority", + ) + self.assertIn("FOR UPDATE OF accounting_book_period_control", source) + + def test_adjusting_state_has_no_shared_calendar_fallback(self) -> None: + source = inspect.getsource(PostgresPostingLedger._load_book_period_state) + + self.assertNotIn("COALESCE(", source) + self.assertNotIn("LEFT JOIN accounting_core.accounting_book_period_control", source) + self.assertIn("JOIN accounting_core.accounting_book_period_control", source) + self.assertIn("accounting_book_period_control.period_status_code", source) + + def test_open_posting_has_no_shared_calendar_fallback(self) -> None: + source = inspect.getsource(PostgresPostingLedger._require_open_book_period_bounds) + + self.assertNotIn("COALESCE(", source) + self.assertNotIn("LEFT JOIN accounting_core.accounting_book_period_control", source) + self.assertIn("JOIN accounting_core.accounting_book_period_control", source) + self.assertIn("accounting_book_period_control.period_status_code", source) + + def test_trial_balance_read_uses_book_scoped_close_authority(self) -> None: + """A hard-closed book must not inherit an open sibling's calendar projection.""" + source = inspect.getsource(PostgresPostingLedger.load_period_trial_balance) + + self.assertIn( + "_load_book_period_state(", + source, + "trial balance must select live versus retained evidence from book-period authority", + ) + self.assertNotIn( + "_require_fiscal_period(", + source, + "trial balance must not infer one book's close state from fiscal_period", + ) + + def test_authority_cleanup_preserves_unrelated_persistence_surfaces(self) -> None: + """A focused period-authority edit must not erase unrelated accounting reads.""" + required_methods = ( + "load_trial_balance", + "load_account_ledger", + "load_financial_statement", + "load_financial_statement_package", + "load_vat_period_register", + "load_home_tax_submissions", + ) + + missing = [ + method_name + for method_name in required_methods + if not hasattr(PostgresPostingLedger, method_name) + ] + self.assertEqual( + missing, + [], + f"period-authority cleanup removed unrelated persistence methods: {missing}", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_book_period_control_insert_authority_contract.py b/tests/test_book_period_control_insert_authority_contract.py new file mode 100644 index 00000000..8659aa72 --- /dev/null +++ b/tests/test_book_period_control_insert_authority_contract.py @@ -0,0 +1,44 @@ +"""Static contract for canonical book-period authority materialization.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +MIGRATION = ROOT / "database" / "migrations" / "0034_book_period_control_seed.sql" + + +def test_direct_book_period_control_insert_is_not_an_authority_writer() -> None: + """Require direct control inserts to fail explicitly instead of reporting silent success.""" + migration = MIGRATION.read_text(encoding="utf-8") + guard_start = migration.index( + "CREATE OR REPLACE FUNCTION accounting_core.guard_book_period_control_insert_authority" + ) + guard_end = migration.index( + "REVOKE ALL ON FUNCTION accounting_core.guard_book_period_control_insert_authority", + guard_start, + ) + guard = migration[guard_start:guard_end] + + assert "pg_trigger_depth() < 2" in guard + assert "NEW.period_status_code IS DISTINCT FROM 'open'" in guard + assert "NEW.period_closed_at IS NOT NULL" in guard + assert "RAISE EXCEPTION" in guard + assert "book_period_control_insert_authority_required" in guard + assert "USING ERRCODE = 'check_violation';" in guard + assert "RETURN NULL;" not in guard + assert "CREATE TRIGGER book_period_control_insert_authority_guard" in migration + assert "BEFORE INSERT" in migration + + +def test_authority_guard_is_installed_after_migration_repair() -> None: + """Keep the one-time owner repair outside the post-install runtime insert guard.""" + migration = MIGRATION.read_text(encoding="utf-8") + repair_position = migration.index( + "INSERT INTO accounting_core.accounting_book_period_control (", + migration.index("-- Repair databases"), + ) + guard_position = migration.index( + "CREATE OR REPLACE FUNCTION accounting_core.guard_book_period_control_insert_authority" + ) + + assert repair_position < guard_position diff --git a/tests/test_book_period_control_seed.py b/tests/test_book_period_control_seed.py new file mode 100644 index 00000000..eaa9dc6f --- /dev/null +++ b/tests/test_book_period_control_seed.py @@ -0,0 +1,406 @@ +"""Real PostgreSQL regressions for book-period control and freshness-fence seeding.""" + +from __future__ import annotations + +import threading +import unittest +import uuid +from datetime import date + +import psycopg + +from tests import test_postgres_posting as posting + + +class BookPeriodControlSeedTests(unittest.TestCase): + """Require every active book-period pair to exist before journals can be admitted.""" + + @classmethod + def setUpClass(cls) -> None: + """Install the complete current migration chain into the PostgreSQL fixture.""" + posting.PostgresPostingTests.setUpClass() + + def setUp(self) -> None: + """Seed one isolated tenant whose period and book are created after migration install.""" + self.case = posting.PostgresPostingTests("setUp") + self.case.setUp() + self.addCleanup(self.case.doCleanups) + self.addCleanup(self.case.tearDown) + + def test_seeded_book_and_existing_period_have_control_and_all_fences(self) -> None: + """Creating an active book after its period must materialize close authority immediately.""" + control_count, fence_count = self._control_and_fence_counts("2026-08") + + self.assertEqual(control_count, 1) + self.assertEqual(fence_count, 64) + + def test_new_book_does_not_inherit_shared_closed_period_projection(self) -> None: + """A new book must not receive close authority copied from the tenant-wide compatibility status.""" + new_book_id = uuid.uuid4() + legal_entity_id, _fiscal_calendar_id = self._seed_scope_ids() + + with psycopg.connect(posting.DATABASE_URL) as connection: + period_id = connection.execute( + """ + UPDATE accounting_core.fiscal_period + SET period_status_code = 'soft_closed', + period_closed_at = clock_timestamp() + WHERE tenant_account_id = %s + AND period_code = '2026-08' + RETURNING fiscal_period_id + """, + (self.case.tenant_id,), + ).fetchone()[0] + self._insert_book( + connection, + new_book_id=new_book_id, + legal_entity_id=legal_entity_id, + ) + connection.commit() + + with psycopg.connect(posting.DATABASE_URL) as connection: + control_row = connection.execute( + """ + SELECT period_status_code, period_closed_at + FROM accounting_core.accounting_book_period_control + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, new_book_id, period_id), + ).fetchone() + fence_count = connection.execute( + """ + SELECT COUNT(*) + FROM accounting_core.period_journal_population_fence + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, new_book_id, period_id), + ).fetchone()[0] + + self.assertIsNone(control_row) + self.assertEqual(fence_count, 0) + + def test_period_open_seeds_control_and_all_fences_for_existing_book(self) -> None: + """Opening a later period must materialize control and freshness rows for active books.""" + period_code = "2026-09" + self.case.ledger.open_fiscal_period( + self.case.policy.legal_entity_reference, + period_code, + date(2026, 9, 1), + date(2026, 9, 30), + idempotency_key=f"period-open:{uuid.uuid4()}", + source_payload_hash="sha256:" + "7" * 64, + ) + + control_count, fence_count = self._control_and_fence_counts(period_code) + + self.assertEqual(control_count, 1) + self.assertEqual(fence_count, 64) + + def test_concurrent_new_book_and_period_cannot_commit_without_pair(self) -> None: + """Opposite-side master-data inserts must serialize before either trigger scans its peer.""" + new_book_id = uuid.uuid4() + new_period_id = uuid.uuid4() + period_code = f"seed-race-{uuid.uuid4().hex[:8]}" + legal_entity_id, fiscal_calendar_id = self._seed_scope_ids() + + book_connection = psycopg.connect(posting.DATABASE_URL) + self.addCleanup(book_connection.close) + book_connection.execute("SET LOCAL lock_timeout = '5s'") + self._insert_book( + book_connection, + new_book_id=new_book_id, + legal_entity_id=legal_entity_id, + ) + + period_started = threading.Event() + period_insert_completed = threading.Event() + worker_errors: list[BaseException] = [] + + def insert_period() -> None: + try: + with psycopg.connect(posting.DATABASE_URL) as connection: + connection.execute("SET LOCAL lock_timeout = '5s'") + period_started.set() + self._insert_period( + connection, + new_period_id=new_period_id, + fiscal_calendar_id=fiscal_calendar_id, + period_code=period_code, + ) + connection.commit() + period_insert_completed.set() + except BaseException as error: # pragma: no cover - surfaced on the main test thread + worker_errors.append(error) + period_insert_completed.set() + + worker = threading.Thread(target=insert_period, daemon=True) + worker.start() + self.assertTrue(period_started.wait(timeout=2.0)) + completed_before_book_commit = period_insert_completed.wait(timeout=1.0) + + book_connection.commit() + worker.join(timeout=6.0) + + self.assertFalse(worker.is_alive(), "concurrent fiscal-period insert did not finish after peer commit") + if worker_errors: + raise worker_errors[0] + self.assertFalse( + completed_before_book_commit, + "opposite-side seed trigger committed before the uncommitted active book became visible", + ) + self._assert_control_and_fences(new_book_id, new_period_id) + + def test_repeatable_read_seed_race_fails_closed_then_retries_fresh(self) -> None: + """A fixed snapshot must serialize-fail rather than commit a peer-blind master-data pair.""" + new_book_id = uuid.uuid4() + new_period_id = uuid.uuid4() + period_code = f"seed-rr-{uuid.uuid4().hex[:8]}" + legal_entity_id, fiscal_calendar_id = self._seed_scope_ids() + + book_connection = psycopg.connect(posting.DATABASE_URL) + self.addCleanup(book_connection.close) + book_connection.execute("SET LOCAL lock_timeout = '5s'") + self._insert_book( + book_connection, + new_book_id=new_book_id, + legal_entity_id=legal_entity_id, + ) + + period_started = threading.Event() + worker_errors: list[BaseException] = [] + + def insert_repeatable_read_period() -> None: + try: + with psycopg.connect(posting.DATABASE_URL) as connection: + connection.execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ") + connection.execute("SET LOCAL lock_timeout = '5s'") + period_started.set() + self._insert_period( + connection, + new_period_id=new_period_id, + fiscal_calendar_id=fiscal_calendar_id, + period_code=period_code, + ) + connection.commit() + except BaseException as error: # pragma: no cover - asserted on the main test thread + worker_errors.append(error) + + worker = threading.Thread(target=insert_repeatable_read_period, daemon=True) + worker.start() + self.assertTrue(period_started.wait(timeout=2.0)) + + book_connection.commit() + worker.join(timeout=6.0) + + self.assertFalse(worker.is_alive(), "repeatable-read fiscal-period insert did not terminate") + self.assertEqual(len(worker_errors), 1) + self.assertIsInstance(worker_errors[0], psycopg.errors.SerializationFailure) + + with psycopg.connect(posting.DATABASE_URL) as retry_connection: + self._insert_period( + retry_connection, + new_period_id=new_period_id, + fiscal_calendar_id=fiscal_calendar_id, + period_code=period_code, + ) + retry_connection.commit() + + self._assert_control_and_fences(new_book_id, new_period_id) + + def test_seed_sources_and_targets_finish_with_forced_rls(self) -> None: + """Owner-only migration visibility must never leak into the committed runtime schema.""" + expected_relations = { + "accounting_book", + "fiscal_period", + "accounting_book_period_control", + "period_journal_population_fence", + } + with psycopg.connect(posting.DATABASE_URL) as connection: + rows = connection.execute( + """ + SELECT pg_class.relname, + pg_class.relrowsecurity, + pg_class.relforcerowsecurity + FROM pg_catalog.pg_class + JOIN pg_catalog.pg_namespace + ON pg_namespace.oid = pg_class.relnamespace + WHERE pg_namespace.nspname = 'accounting_core' + AND pg_class.relname = ANY(%s) + """, + (list(expected_relations),), + ).fetchall() + + actual = { + str(name): (bool(rls_enabled), bool(rls_forced)) + for name, rls_enabled, rls_forced in rows + } + self.assertEqual(set(actual), expected_relations) + self.assertEqual(actual, {name: (True, True) for name in expected_relations}) + + def _seed_scope_ids(self) -> tuple[uuid.UUID, uuid.UUID]: + """Return this fixture tenant's legal-entity and fiscal-calendar identifiers.""" + with psycopg.connect(posting.DATABASE_URL) as lookup: + legal_entity_id = lookup.execute( + """ + SELECT legal_entity_id + FROM accounting_core.legal_entity_record + WHERE tenant_account_id = %s + ORDER BY recorded_at + LIMIT 1 + """, + (self.case.tenant_id,), + ).fetchone()[0] + fiscal_calendar_id = lookup.execute( + """ + SELECT fiscal_calendar_id + FROM accounting_core.fiscal_calendar + WHERE tenant_account_id = %s + ORDER BY created_at + LIMIT 1 + """, + (self.case.tenant_id,), + ).fetchone()[0] + return legal_entity_id, fiscal_calendar_id + + def _insert_book( + self, + connection: psycopg.Connection, + *, + new_book_id: uuid.UUID, + legal_entity_id: uuid.UUID, + ) -> None: + """Insert one active book through the database master-data boundary.""" + connection.execute( + """ + INSERT INTO accounting_core.accounting_book ( + accounting_book_id, + tenant_account_id, + legal_entity_id, + book_role_code, + book_name, + reporting_currency_code, + valid_from + ) + VALUES (%s, %s, %s, %s, %s, 'USD', '2099-01-01T00:00:00Z') + """, + ( + new_book_id, + self.case.tenant_id, + legal_entity_id, + f"seed_race_{new_book_id.hex}", + f"Seed race {new_book_id.hex}", + ), + ) + + def _insert_period( + self, + connection: psycopg.Connection, + *, + new_period_id: uuid.UUID, + fiscal_calendar_id: uuid.UUID, + period_code: str, + ) -> None: + """Insert one open period through the database master-data boundary.""" + connection.execute( + """ + INSERT INTO accounting_core.fiscal_period ( + fiscal_period_id, + tenant_account_id, + fiscal_calendar_id, + period_code, + period_start_date, + period_end_date, + period_status_code + ) + VALUES (%s, %s, %s, %s, DATE '2099-01-01', DATE '2099-01-31', 'open') + """, + ( + new_period_id, + self.case.tenant_id, + fiscal_calendar_id, + period_code, + ), + ) + + def _assert_control_and_fences( + self, + accounting_book_id: uuid.UUID, + fiscal_period_id: uuid.UUID, + ) -> None: + """Require one control row and the complete 64-row freshness population.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + control_count = connection.execute( + """ + SELECT COUNT(*) + FROM accounting_core.accounting_book_period_control + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, accounting_book_id, fiscal_period_id), + ).fetchone()[0] + fence_count = connection.execute( + """ + SELECT COUNT(*) + FROM accounting_core.period_journal_population_fence + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, accounting_book_id, fiscal_period_id), + ).fetchone()[0] + + self.assertEqual(control_count, 1) + self.assertEqual(fence_count, 64) + + def _control_and_fence_counts(self, period_code: str) -> tuple[int, int]: + """Return retained control and stripe cardinality for this fixture's primary book-period.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + book_id = connection.execute( + """ + SELECT accounting_book_id + FROM accounting_core.accounting_book + WHERE tenant_account_id = %s + AND book_name = %s + AND valid_to IS NULL + """, + (self.case.tenant_id, self.case.policy.accounting_book_reference), + ).fetchone()[0] + period_id = connection.execute( + """ + SELECT fiscal_period_id + FROM accounting_core.fiscal_period + WHERE tenant_account_id = %s + AND period_code = %s + """, + (self.case.tenant_id, period_code), + ).fetchone()[0] + control_count = connection.execute( + """ + SELECT COUNT(*) + FROM accounting_core.accounting_book_period_control + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, book_id, period_id), + ).fetchone()[0] + fence_count = connection.execute( + """ + SELECT COUNT(*) + FROM accounting_core.period_journal_population_fence + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, book_id, period_id), + ).fetchone()[0] + return int(control_count), int(fence_count) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_book_period_control_seed_contract.py b/tests/test_book_period_control_seed_contract.py new file mode 100644 index 00000000..fc14a492 --- /dev/null +++ b/tests/test_book_period_control_seed_contract.py @@ -0,0 +1,229 @@ +"""Static contracts for post-install book-period authority seeding.""" + +from __future__ import annotations + +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +MIGRATION = ROOT / "database/migrations/0034_book_period_control_seed.sql" +BOOK_PERIOD_MIGRATION = ROOT / "database/migrations/0009_accounting_book_period_control.sql" +INSTALLER = ROOT / "src/accounting_information_platform/migration_install.py" + + +class BookPeriodControlSeedContractTests(unittest.TestCase): + """Keep future master data on the same database-owned period authority boundary.""" + + def test_period_and_book_creation_both_seed_controls(self) -> None: + """Either master-data creation order must materialize the book-period pair.""" + source = MIGRATION.read_text(encoding="utf-8") + + self.assertIn("seed_book_period_control_for_period", source) + self.assertIn("AFTER INSERT\n ON accounting_core.fiscal_period", source) + self.assertIn("seed_book_period_control_for_book", source) + self.assertIn("AFTER INSERT\n ON accounting_core.accounting_book", source) + self.assertGreaterEqual( + source.count("INSERT INTO accounting_core.accounting_book_period_control"), + 3, + ) + self.assertGreaterEqual(source.count("ON CONFLICT"), 3) + + def test_book_activation_seeds_existing_open_periods(self) -> None: + """Changing an inactive book to active must invoke the same open-only seeder.""" + source = MIGRATION.read_text(encoding="utf-8") + + activation_trigger = source[ + source.index("CREATE TRIGGER book_period_control_seed_for_book_activation") : + ] + self.assertIn("AFTER UPDATE OF valid_to", activation_trigger) + self.assertIn("ON accounting_core.accounting_book", activation_trigger) + self.assertIn( + "WHEN (OLD.valid_to IS NOT NULL AND NEW.valid_to IS NULL)", + activation_trigger, + ) + self.assertIn( + "EXECUTE FUNCTION accounting_core.seed_book_period_control_for_book();", + activation_trigger, + ) + + def test_non_open_projection_never_synthesizes_book_close_authority(self) -> None: + """Only an actually opened period may create a new book-period control automatically.""" + source = MIGRATION.read_text(encoding="utf-8") + period_function = source[ + source.index( + "CREATE OR REPLACE FUNCTION " + "accounting_core.seed_book_period_control_for_period()" + ) : source.index("CREATE TRIGGER book_period_control_seed_for_period") + ] + book_function = source[ + source.index( + "CREATE OR REPLACE FUNCTION " + "accounting_core.seed_book_period_control_for_book()" + ) : source.index("CREATE TRIGGER book_period_control_seed_for_book") + ] + book_select_start = book_function.index("SELECT NEW.tenant_account_id") + book_period_source = book_function.index( + "FROM accounting_core.fiscal_period", + book_select_start, + ) + projected_book_control_values = book_function[ + book_select_start:book_period_source + ] + repair_backfill = source[ + source.rindex( + "INSERT INTO accounting_core.accounting_book_period_control (" + ) : + ] + repair_select_start = repair_backfill.index( + "SELECT accounting_book.tenant_account_id" + ) + repair_period_source = repair_backfill.index( + "FROM accounting_core.accounting_book", + repair_select_start, + ) + projected_repair_values = repair_backfill[ + repair_select_start:repair_period_source + ] + + self.assertIn( + "IF NEW.period_status_code IS DISTINCT FROM 'open' THEN\n" + " RETURN NEW;\n" + " END IF;", + period_function, + ) + period_projection_start = period_function.index( + "SELECT NEW.tenant_account_id" + ) + period_projection = period_function[period_projection_start:] + self.assertNotIn("NEW.period_status_code", period_projection) + self.assertNotIn("NEW.period_closed_at", period_projection) + self.assertNotIn( + "fiscal_period.period_status_code", projected_book_control_values + ) + self.assertNotIn( + "fiscal_period.period_closed_at", projected_book_control_values + ) + self.assertIn("AND fiscal_period.period_status_code = 'open'", book_function) + self.assertNotIn("fiscal_period.period_status_code", projected_repair_values) + self.assertNotIn("fiscal_period.period_closed_at", projected_repair_values) + self.assertIn( + "WHERE accounting_book.valid_to IS NULL\n" + " AND fiscal_period.period_status_code = 'open'", + repair_backfill, + ) + + def test_opposite_side_seeders_version_one_tenant_serialization_row(self) -> None: + """Peer scans need a common row version so fixed snapshots fail closed instead of missing data.""" + source = MIGRATION.read_text(encoding="utf-8") + period_function = source[ + source.index( + "CREATE OR REPLACE FUNCTION " + "accounting_core.seed_book_period_control_for_period()" + ) : source.index("CREATE TRIGGER book_period_control_seed_for_period") + ] + book_function = source[ + source.index( + "CREATE OR REPLACE FUNCTION " + "accounting_core.seed_book_period_control_for_book()" + ) : source.index("CREATE TRIGGER book_period_control_seed_for_book") + ] + + for function_source in (period_function, book_function): + tenant_update = function_source.index( + "UPDATE accounting_core.tenant_account AS tenant" + ) + retained_value = function_source.index( + "SET created_at = tenant.created_at", + tenant_update, + ) + control_insert = function_source.index( + "INSERT INTO accounting_core.accounting_book_period_control (" + ) + self.assertLess(tenant_update, retained_value) + self.assertLess(retained_value, control_insert) + + self.assertEqual( + source.count("UPDATE accounting_core.tenant_account AS tenant"), + 2, + ) + self.assertNotIn("SET created_at = clock_timestamp()", source) + + def test_trigger_functions_use_hardened_execution_context(self) -> None: + """Master-data triggers must not inherit caller-controlled object resolution.""" + source = MIGRATION.read_text(encoding="utf-8") + + self.assertEqual(source.count("SECURITY DEFINER"), 3) + self.assertEqual(source.count("SET search_path = pg_catalog, pg_temp"), 3) + self.assertIn( + "REVOKE ALL ON FUNCTION accounting_core.seed_book_period_control_for_period()", + source, + ) + self.assertIn( + "REVOKE ALL ON FUNCTION accounting_core.seed_book_period_control_for_book()", + source, + ) + self.assertIn( + "REVOKE ALL ON FUNCTION accounting_core.guard_book_period_control_insert_authority()", + source, + ) + + def test_cross_tenant_backfills_are_owner_safe_without_disabling_rls(self) -> None: + """Unbound migration owners need owner-only visibility on source and target tables.""" + initial_source = BOOK_PERIOD_MIGRATION.read_text(encoding="utf-8") + initial_backfill = initial_source.index( + "INSERT INTO accounting_core.accounting_book_period_control (" + ) + initial_control_force = initial_source.index( + "ALTER TABLE accounting_core.accounting_book_period_control " + "FORCE ROW LEVEL SECURITY;" + ) + initial_book_no_force = initial_source.index( + "ALTER TABLE accounting_core.accounting_book NO FORCE ROW LEVEL SECURITY;" + ) + initial_period_no_force = initial_source.index( + "ALTER TABLE accounting_core.fiscal_period NO FORCE ROW LEVEL SECURITY;" + ) + initial_book_force = initial_source.rindex( + "ALTER TABLE accounting_core.accounting_book FORCE ROW LEVEL SECURITY;" + ) + initial_period_force = initial_source.rindex( + "ALTER TABLE accounting_core.fiscal_period FORCE ROW LEVEL SECURITY;" + ) + self.assertLess(initial_book_no_force, initial_backfill) + self.assertLess(initial_period_no_force, initial_backfill) + self.assertLess(initial_backfill, initial_book_force) + self.assertLess(initial_backfill, initial_period_force) + self.assertLess(initial_backfill, initial_control_force) + + repair_source = MIGRATION.read_text(encoding="utf-8") + repair_backfill = repair_source.rindex( + "INSERT INTO accounting_core.accounting_book_period_control (" + ) + for table_name in ( + "accounting_book", + "fiscal_period", + "accounting_book_period_control", + "period_journal_population_fence", + ): + no_force = repair_source.index( + f"ALTER TABLE accounting_core.{table_name} NO FORCE ROW LEVEL SECURITY;" + ) + force = repair_source.rindex( + f"ALTER TABLE accounting_core.{table_name} FORCE ROW LEVEL SECURITY;" + ) + self.assertLess(no_force, repair_backfill) + self.assertLess(repair_backfill, force) + + self.assertNotIn("DISABLE ROW LEVEL SECURITY", initial_source) + self.assertNotIn("DISABLE ROW LEVEL SECURITY", repair_source) + + def test_canonical_installer_includes_seed_migration(self) -> None: + """Supported foundation installs cannot stop before future-pair seeding exists.""" + source = INSTALLER.read_text(encoding="utf-8") + + self.assertIn('"0034_book_period_control_seed.sql"', source) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_open_period_application_lock_contract.py b/tests/test_open_period_application_lock_contract.py new file mode 100644 index 00000000..e62d46e2 --- /dev/null +++ b/tests/test_open_period_application_lock_contract.py @@ -0,0 +1,36 @@ +"""Static regression for the ordinary open-period application lock profile.""" + +from __future__ import annotations + +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +PERSISTENCE = ROOT / "src/accounting_information_platform/persistence.py" + + +class OpenPeriodApplicationLockContractTests(unittest.TestCase): + """Keep ordinary posting from collapsing onto close-command advisory mutexes.""" + + def test_open_period_lookup_does_not_take_close_command_lock(self) -> None: + """Database admission owns journal/transition ordering; ordinary posts need no close mutex.""" + source = PERSISTENCE.read_text(encoding="utf-8") + helper_start = source.index(" def _require_open_book_period_bounds(") + helper_end = source.index(" def _require_adjusting_period(", helper_start) + helper_source = source[helper_start:helper_end] + + self.assertNotIn( + "self._acquire_command_lock(", + helper_source, + "ordinary open-period lookup must not acquire a command-level advisory mutex", + ) + self.assertIn( + 'if row[2] != "open":', + helper_source, + "removing the advisory mutex must retain fail-closed application validation for a non-open period", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_open_period_fence_installer_contract.py b/tests/test_open_period_fence_installer_contract.py new file mode 100644 index 00000000..f2b9de39 --- /dev/null +++ b/tests/test_open_period_fence_installer_contract.py @@ -0,0 +1,92 @@ +"""Installer and migration-order contracts for the open-period freshness fence.""" + +from __future__ import annotations + +import unittest +from pathlib import Path +from unittest.mock import patch + +from accounting_information_platform import AccountingValidationError, apply_foundation_migration + + +ROOT = Path(__file__).resolve().parents[1] +OPEN_PERIOD_FENCE_MIGRATION = ( + ROOT / "database/migrations/0033_open_period_journal_population_fence.sql" +) + + +class OpenPeriodFenceInstallerContractTests(unittest.TestCase): + """Prevent supported installs from omitting or tenant-scoping the 0033 backfill.""" + + def test_installer_fails_closed_when_0033_is_missing(self) -> None: + """The canonical migration chain may not stop before the open-period freshness fence.""" + original_is_file = Path.is_file + + def is_file(path: Path) -> bool: + if path.name == OPEN_PERIOD_FENCE_MIGRATION.name: + return False + return original_is_file(path) + + with patch.object(Path, "is_file", is_file): + with self.assertRaises(AccountingValidationError): + apply_foundation_migration( + "postgresql://unused", + ROOT / "database/migrations/0001_accounting_foundation.sql", + ) + + def test_cross_tenant_fence_backfill_precedes_force_rls(self) -> None: + """Migration-owned seed data is complete before runtime tenant isolation is forced.""" + migration = OPEN_PERIOD_FENCE_MIGRATION.read_text(encoding="utf-8") + backfill = "INSERT INTO accounting_core.period_journal_population_fence" + force_rls = ( + "ALTER TABLE accounting_core.period_journal_population_fence " + "FORCE ROW LEVEL SECURITY" + ) + self.assertIn(backfill, migration) + self.assertIn(force_rls, migration) + self.assertLess(migration.index(backfill), migration.index(force_rls)) + self.assertIn("CROSS JOIN generate_series(0, 63)", migration) + self.assertIn("period_journal_population_fence_seed", migration) + + def test_runtime_fence_seeder_requires_bound_tenant_while_force_rls_is_active(self) -> None: + """Runtime seeding must reject an unbound tenant before FORCE-RLS target DML.""" + migration = OPEN_PERIOD_FENCE_MIGRATION.read_text(encoding="utf-8") + start = migration.index( + "CREATE OR REPLACE FUNCTION accounting_core.seed_period_journal_population_fence()" + ) + end = migration.index( + "REVOKE ALL ON FUNCTION accounting_core.seed_period_journal_population_fence()", + start, + ) + seeder = migration[start:end] + self.assertIn("relforcerowsecurity", seeder) + self.assertIn("accounting_core.current_tenant_account_id()", seeder) + self.assertIn("period_journal_population_fence_tenant_binding_required", seeder) + self.assertLess( + seeder.index("period_journal_population_fence_tenant_binding_required"), + seeder.index("INSERT INTO accounting_core.period_journal_population_fence"), + ) + + def test_runtime_binding_guard_preserves_effective_roles_that_bypass_rls(self) -> None: + """The explicit guard must not be stricter than PostgreSQL's effective-role RLS rules.""" + migration = OPEN_PERIOD_FENCE_MIGRATION.read_text(encoding="utf-8") + start = migration.index( + "CREATE OR REPLACE FUNCTION accounting_core.seed_period_journal_population_fence()" + ) + end = migration.index( + "REVOKE ALL ON FUNCTION accounting_core.seed_period_journal_population_fence()", + start, + ) + seeder = migration[start:end] + self.assertIn("pg_catalog.pg_roles", seeder) + self.assertIn("rolsuper", seeder) + self.assertIn("rolbypassrls", seeder) + self.assertIn("current_user", seeder) + self.assertLess( + seeder.index("rolbypassrls"), + seeder.index("accounting_core.current_tenant_account_id()"), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_operability_migration_chain_contract.py b/tests/test_operability_migration_chain_contract.py new file mode 100644 index 00000000..535c6269 --- /dev/null +++ b/tests/test_operability_migration_chain_contract.py @@ -0,0 +1,56 @@ +"""Contract tying operator migration instructions to the canonical installer.""" + +from __future__ import annotations + +import ast +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +INSTALLER = ROOT / "src/accounting_information_platform/migration_install.py" +OPERABILITY = ROOT / "docs/OPERABILITY.md" + + +def _forward_migration_names() -> tuple[str, ...]: + """Read the literal forward-migration tuple without importing database code.""" + module = ast.parse(INSTALLER.read_text(encoding="utf-8")) + for node in ast.walk(module): + if not isinstance(node, ast.Assign): + continue + if not any( + isinstance(target, ast.Name) and target.id == "forward_migration_paths" + for target in node.targets + ): + continue + if not isinstance(node.value, ast.Tuple): + continue + + names: list[str] = [] + for element in node.value.elts: + if not isinstance(element, ast.BinOp) or not isinstance(element.op, ast.Div): + raise AssertionError("forward_migration_paths must remain literal Path divisions") + if not isinstance(element.right, ast.Constant) or not isinstance( + element.right.value, str + ): + raise AssertionError("forward migration filename must remain a string literal") + names.append(element.right.value) + return tuple(names) + + raise AssertionError("canonical forward_migration_paths tuple is missing") + + +def test_operability_lists_every_canonical_forward_migration_in_order() -> None: + """Operator instructions may not stop before the installer authority chain.""" + text = OPERABILITY.read_text(encoding="utf-8") + cursor = -1 + + for migration_name in _forward_migration_names(): + next_cursor = text.find(f"database/migrations/{migration_name}", cursor + 1) + assert next_cursor > cursor, ( + f"docs/OPERABILITY.md must list {migration_name} after the previous " + "canonical forward migration" + ) + cursor = next_cursor + + assert "hard_close_snapshot_pair_legacy_preflight" in text + assert "trial_balance_snapshot_hard_close_pair_legacy_preflight" in text diff --git a/tests/test_period_close_book_scope.py b/tests/test_period_close_book_scope.py index 0c8a7a96..6115d3f4 100644 --- a/tests/test_period_close_book_scope.py +++ b/tests/test_period_close_book_scope.py @@ -263,6 +263,28 @@ def test_soft_close_one_book_does_not_block_sibling_book_posting(self) -> None: self.mgmt_book_reference, ) + def test_hard_closed_book_default_trial_balance_uses_retained_snapshot(self) -> None: + """An open sibling must not make a hard-closed book read mutable live totals.""" + close_receipt = self.ledger.close_fiscal_period( + self.legal_entity_reference, + self.stat_book_reference, + "2026-08", + "KRW", + period_status_code="hard_closed", + idempotency_key=f"{self.tenant_reference}:statutory:2026-08:hard-close", + ) + + trial_balance = self.ledger.load_period_trial_balance( + self.legal_entity_reference, + self.stat_book_reference, + "2026-08", + ) + + self.assertEqual(close_receipt.period_status_code, "hard_closed") + self.assertEqual(trial_balance["period_status_code"], "hard_closed") + self.assertEqual(trial_balance["balance_source_code"], "snapshot") + self.assertEqual(trial_balance["snapshot_record_id"], close_receipt.snapshot_record_id) + def test_soft_close_replay_rejects_a_different_command_key(self) -> None: """A soft-close replay is exact and cannot accept a different command identity.""" original_key = f"{self.tenant_reference}:statutory:2026-08:soft-close" diff --git a/tests/test_period_close_posted_account_identity_source_contract.py b/tests/test_period_close_posted_account_identity_source_contract.py new file mode 100644 index 00000000..64d08d25 --- /dev/null +++ b/tests/test_period_close_posted_account_identity_source_contract.py @@ -0,0 +1,49 @@ +"""Static contracts for historical chart-account identity during period close.""" + +from __future__ import annotations + +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +PERSISTENCE = ROOT / "src/accounting_information_platform/persistence.py" + + +class PeriodClosePostedAccountIdentitySourceContractTests(unittest.TestCase): + """Separate historical close offsets from current-catalog ordinary posting.""" + + @classmethod + def setUpClass(cls) -> None: + """Read the production adapter once for focused source-boundary assertions.""" + cls.source = PERSISTENCE.read_text(encoding="utf-8") + + def test_ordinary_journal_insert_still_requires_current_chart_account(self) -> None: + """Fixing close must not let ordinary new postings target expired accounts.""" + method = self.source.split(" def _insert_journal(", 1)[1].split( + " def _insert_receipt(", 1 + )[0] + + self.assertIn("AND valid_to IS NULL", method) + + def test_closing_source_carries_exact_posted_chart_account_identity(self) -> None: + """Closing offsets must not reconstruct a historical account from today's code catalog.""" + method = self.source.split(" def _post_closing_journal(", 1)[1].split( + " def _require_retained_earnings_mapping(", 1 + )[0] + + self.assertIn("journal_entry_line.chart_account_id", method) + self.assertIn("GROUP BY journal_entry_line.chart_account_id", method) + + def test_hard_close_does_not_require_buyer_reporting_projection(self) -> None: + """Reporting catalog completeness must not become a second hard-close authority.""" + method = self.source.split(" def close_fiscal_period(", 1)[1].split( + " def open_fiscal_period(", 1 + )[0] + + self.assertNotIn("_assemble_period_close_package(", method) + self.assertIn("_persist_period_close(", method) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_period_close_posted_role_source_contract.py b/tests/test_period_close_posted_role_source_contract.py new file mode 100644 index 00000000..1edb53c6 --- /dev/null +++ b/tests/test_period_close_posted_role_source_contract.py @@ -0,0 +1,35 @@ +"""Static contract for period-close historical role classification.""" + +from __future__ import annotations + +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +PERSISTENCE = ROOT / "src/accounting_information_platform/persistence.py" + + +class PeriodClosePostedRoleSourceContractTests(unittest.TestCase): + """Keep hard-close source semantics on immutable posted journal-line facts.""" + + def test_closing_journal_classifies_income_from_posted_line_role(self) -> None: + """Do not reclassify posted P&L through the mutable current role catalog.""" + source = PERSISTENCE.read_text(encoding="utf-8") + method = source.split(" def _post_closing_journal(", 1)[1].split( + " def _require_retained_earnings_mapping(", 1 + )[0] + + self.assertIn("journal_entry_line.account_role_code", method) + self.assertIn("AND journal_entry_line.account_role_code IN (", method) + self.assertIn( + "GROUP BY chart_account.chart_account_code,\n" + " journal_entry_line.account_role_code", + method, + ) + self.assertNotIn("JOIN accounting_core.account_role_mapping", method) + self.assertNotIn("account_role_mapping.account_role_code", method) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_postgres_book_activation_seed_red.py b/tests/test_postgres_book_activation_seed_red.py new file mode 100644 index 00000000..a87d9ae4 --- /dev/null +++ b/tests/test_postgres_book_activation_seed_red.py @@ -0,0 +1,134 @@ +"""Real PostgreSQL regression for activating a book into an already-open period.""" + +from __future__ import annotations + +import unittest +import uuid + +import psycopg + +from tests import test_postgres_posting as posting + + +class BookActivationSeedTests(unittest.TestCase): + """Require activation to materialize the open book-period authority and fence.""" + + @classmethod + def setUpClass(cls) -> None: + """Install the complete current migration chain into the PostgreSQL fixture.""" + posting.PostgresPostingTests.setUpClass() + + def setUp(self) -> None: + """Seed one isolated tenant for the book-lifecycle regression.""" + self.case = posting.PostgresPostingTests("setUp") + self.case.setUp() + self.addCleanup(self.case.doCleanups) + self.addCleanup(self.case.tearDown) + + def test_activating_book_seeds_existing_open_period_authority(self) -> None: + """An inactive book becoming active must gain open control plus all 64 fences.""" + book_id = uuid.uuid4() + with psycopg.connect(posting.DATABASE_URL) as connection: + legal_entity_id = connection.execute( + """ + SELECT legal_entity_id + FROM accounting_core.legal_entity_record + WHERE tenant_account_id = %s + ORDER BY recorded_at + LIMIT 1 + """, + (self.case.tenant_id,), + ).fetchone()[0] + period_id = connection.execute( + """ + SELECT fiscal_period_id + FROM accounting_core.fiscal_period + WHERE tenant_account_id = %s + AND period_code = '2026-08' + AND period_status_code = 'open' + """, + (self.case.tenant_id,), + ).fetchone()[0] + connection.execute( + """ + INSERT INTO accounting_core.accounting_book ( + accounting_book_id, + tenant_account_id, + legal_entity_id, + book_role_code, + book_name, + reporting_currency_code, + valid_from, + valid_to + ) + VALUES ( + %s, %s, %s, %s, %s, 'USD', + '2026-01-01T00:00:00Z', + '2026-07-31T23:59:59Z' + ) + """, + ( + book_id, + self.case.tenant_id, + legal_entity_id, + f"activation_{book_id.hex}", + f"Activation {book_id.hex}", + ), + ) + connection.commit() + + self._assert_population(book_id, period_id, expected_control_count=0, expected_fences=0) + + with psycopg.connect(posting.DATABASE_URL) as connection: + connection.execute( + """ + UPDATE accounting_core.accounting_book + SET valid_to = NULL + WHERE tenant_account_id = %s + AND accounting_book_id = %s + """, + (self.case.tenant_id, book_id), + ) + connection.commit() + + self._assert_population(book_id, period_id, expected_control_count=1, expected_fences=64) + + def _assert_population( + self, + accounting_book_id: uuid.UUID, + fiscal_period_id: uuid.UUID, + *, + expected_control_count: int, + expected_fences: int, + ) -> None: + """Assert open authority is absent while inactive and complete after activation.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + control_rows = connection.execute( + """ + SELECT period_status_code, period_closed_at + FROM accounting_core.accounting_book_period_control + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, accounting_book_id, fiscal_period_id), + ).fetchall() + fence_count = connection.execute( + """ + SELECT COUNT(*) + FROM accounting_core.period_journal_population_fence + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, accounting_book_id, fiscal_period_id), + ).fetchone()[0] + + self.assertEqual(len(control_rows), expected_control_count) + self.assertEqual(fence_count, expected_fences) + if expected_control_count: + self.assertEqual(control_rows, [("open", None)]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_postgres_book_period_control_insert_authority_red.py b/tests/test_postgres_book_period_control_insert_authority_red.py new file mode 100644 index 00000000..3dcb54c5 --- /dev/null +++ b/tests/test_postgres_book_period_control_insert_authority_red.py @@ -0,0 +1,112 @@ +"""Real PostgreSQL regression for explicit rejection of direct close-authority inserts.""" + +from __future__ import annotations + +import unittest +import uuid + +import psycopg + +from tests import test_postgres_posting as posting + + +class BookPeriodControlInsertAuthorityTests(unittest.TestCase): + """Keep book-period close authority on the canonical database-owned seeding path.""" + + @classmethod + def setUpClass(cls) -> None: + """Install the complete current migration chain into the PostgreSQL fixture.""" + posting.PostgresPostingTests.setUpClass() + + def setUp(self) -> None: + """Seed one isolated tenant and its normal open accounting book/period.""" + self.case = posting.PostgresPostingTests("setUp") + self.case.setUp() + self.addCleanup(self.case.doCleanups) + self.addCleanup(self.case.tearDown) + + def test_direct_open_control_insert_fails_with_explicit_authority_error(self) -> None: + """A raw INSERT must raise instead of silently dropping an unauthorized authority write.""" + new_book_id = uuid.uuid4() + with psycopg.connect(posting.DATABASE_URL) as connection: + legal_entity_id = connection.execute( + """ + SELECT legal_entity_id + FROM accounting_core.legal_entity_record + WHERE tenant_account_id = %s + ORDER BY recorded_at + LIMIT 1 + """, + (self.case.tenant_id,), + ).fetchone()[0] + period_id = connection.execute( + """ + UPDATE accounting_core.fiscal_period + SET period_status_code = 'soft_closed', + period_closed_at = clock_timestamp() + WHERE tenant_account_id = %s + AND period_code = '2026-08' + RETURNING fiscal_period_id + """, + (self.case.tenant_id,), + ).fetchone()[0] + connection.execute( + """ + INSERT INTO accounting_core.accounting_book ( + accounting_book_id, + tenant_account_id, + legal_entity_id, + book_role_code, + book_name, + reporting_currency_code, + valid_from + ) + VALUES (%s, %s, %s, %s, %s, 'USD', '2026-01-01T00:00:00Z') + """, + ( + new_book_id, + self.case.tenant_id, + legal_entity_id, + f"direct_control_{new_book_id.hex}", + f"Direct control {new_book_id.hex}", + ), + ) + connection.commit() + + with psycopg.connect(posting.DATABASE_URL) as connection: + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "book_period_control_insert_authority_required", + ): + connection.execute( + """ + INSERT INTO accounting_core.accounting_book_period_control ( + tenant_account_id, + accounting_book_id, + fiscal_period_id, + period_status_code, + period_closed_at + ) + VALUES (%s, %s, %s, 'open', NULL) + """, + (self.case.tenant_id, new_book_id, period_id), + ) + connection.rollback() + + with psycopg.connect(posting.DATABASE_URL) as connection: + control_count = connection.execute( + """ + SELECT COUNT(*) + FROM accounting_core.accounting_book_period_control + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, new_book_id, period_id), + ).fetchone()[0] + + self.assertEqual(control_count, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_postgres_book_period_control_no_projection_red.py b/tests/test_postgres_book_period_control_no_projection_red.py new file mode 100644 index 00000000..2a80fbe3 --- /dev/null +++ b/tests/test_postgres_book_period_control_no_projection_red.py @@ -0,0 +1,127 @@ +"""Real PostgreSQL regression for missing book-period close authority.""" + +from __future__ import annotations + +import unittest +import uuid + +import psycopg + +from accounting_information_platform import AccountingValidationError +from tests import test_postgres_posting as posting + + +class BookPeriodControlNoProjectionTests(unittest.TestCase): + """Require close control to remain book-owned when a shared period is already closed.""" + + @classmethod + def setUpClass(cls) -> None: + """Install the complete current migration chain into the PostgreSQL fixture.""" + posting.PostgresPostingTests.setUpClass() + + def setUp(self) -> None: + """Seed one isolated posting tenant for the authority-boundary regression.""" + self.case = posting.PostgresPostingTests("setUp") + self.case.setUp() + self.addCleanup(self.case.doCleanups) + self.addCleanup(self.case.tearDown) + + def test_close_lock_does_not_synthesize_non_open_book_authority(self) -> None: + """A missing book-period control must not be copied from shared fiscal-period state.""" + new_book_id = uuid.uuid4() + with psycopg.connect(posting.DATABASE_URL) as connection: + legal_entity_id = connection.execute( + """ + SELECT legal_entity_id + FROM accounting_core.legal_entity_record + WHERE tenant_account_id = %s + ORDER BY recorded_at + LIMIT 1 + """, + (self.case.tenant_id,), + ).fetchone()[0] + period_id = connection.execute( + """ + UPDATE accounting_core.fiscal_period + SET period_status_code = 'soft_closed', + period_closed_at = clock_timestamp() + WHERE tenant_account_id = %s + AND period_code = '2026-08' + RETURNING fiscal_period_id + """, + (self.case.tenant_id,), + ).fetchone()[0] + connection.execute( + """ + INSERT INTO accounting_core.accounting_book ( + accounting_book_id, + tenant_account_id, + legal_entity_id, + book_role_code, + book_name, + reporting_currency_code, + valid_from + ) + VALUES (%s, %s, %s, %s, %s, 'USD', '2026-01-01T00:00:00Z') + """, + ( + new_book_id, + self.case.tenant_id, + legal_entity_id, + f"no_projection_{new_book_id.hex}", + f"No projection {new_book_id.hex}", + ), + ) + connection.commit() + + self._assert_no_control_or_fence(new_book_id, period_id) + + with psycopg.connect(posting.DATABASE_URL) as connection: + with self.assertRaisesRegex( + AccountingValidationError, + "has no control row for this accounting book", + ): + self.case.ledger._lock_book_period( + connection, + self.case.tenant_id, + new_book_id, + "2026-08", + ) + connection.rollback() + + self._assert_no_control_or_fence(new_book_id, period_id) + + def _assert_no_control_or_fence( + self, + accounting_book_id: uuid.UUID, + fiscal_period_id: uuid.UUID, + ) -> None: + """Require the unsupported non-open pair to remain absent after close admission.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + control_count = connection.execute( + """ + SELECT COUNT(*) + FROM accounting_core.accounting_book_period_control + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, accounting_book_id, fiscal_period_id), + ).fetchone()[0] + fence_count = connection.execute( + """ + SELECT COUNT(*) + FROM accounting_core.period_journal_population_fence + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, accounting_book_id, fiscal_period_id), + ).fetchone()[0] + + self.assertEqual(control_count, 0) + self.assertEqual(fence_count, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_postgres_hard_close_snapshot_pair_red.py b/tests/test_postgres_hard_close_snapshot_pair_red.py new file mode 100644 index 00000000..c4cb5c30 --- /dev/null +++ b/tests/test_postgres_hard_close_snapshot_pair_red.py @@ -0,0 +1,183 @@ +"""Real PostgreSQL RED/GREEN for the hard-close-to-snapshot commit pair.""" + +from __future__ import annotations + +from pathlib import Path +import unittest + +import psycopg + +from tests import test_postgres_posting as posting + + +ROOT = Path(__file__).resolve().parents[1] +PAIR_MIGRATION = ROOT / "database/migrations/0036_hard_close_trial_balance_snapshot_pair.sql" + + +class HardCloseSnapshotPairPostgresTests(unittest.TestCase): + """A hard-closed book period may commit only with its retained trial balance.""" + + @classmethod + def setUpClass(cls) -> None: + """Install the complete current accounting migration chain.""" + posting.PostgresPostingTests.setUpClass() + + def setUp(self) -> None: + """Create one governed book period and leave it soft-closed without a snapshot.""" + self.case = posting.PostgresPostingTests("setUp") + self.case.setUp() + self.addCleanup(self.case.doCleanups) + self.addCleanup(self.case.tearDown) + self.case.ledger.close_fiscal_period( + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + "2026-08", + "KRW", + period_status_code="soft_closed", + idempotency_key=f"{self.case.policy.tenant_reference}:hard-close-pair:soft", + ) + + def _scope(self, connection: psycopg.Connection[object]) -> tuple[object, object]: + row = connection.execute( + """ + SELECT accounting_book.accounting_book_id, + fiscal_period.fiscal_period_id + FROM accounting_core.accounting_book + JOIN accounting_core.accounting_book_period_control + ON accounting_book_period_control.tenant_account_id + = accounting_book.tenant_account_id + AND accounting_book_period_control.accounting_book_id + = accounting_book.accounting_book_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id + = accounting_book_period_control.tenant_account_id + AND fiscal_period.fiscal_period_id + = accounting_book_period_control.fiscal_period_id + WHERE accounting_book.tenant_account_id = %s + AND accounting_book.book_name = %s + AND fiscal_period.period_code = '2026-08' + """, + (self.case.tenant_id, self.case.policy.accounting_book_reference), + ).fetchone() + self.assertIsNotNone(row) + assert row is not None + return row[0], row[1] + + def test_hard_close_cannot_commit_without_matching_snapshot(self) -> None: + """A database writer cannot retain hard-closed authority without retained evidence.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + accounting_book_id, fiscal_period_id = self._scope(connection) + connection.execute( + """ + UPDATE accounting_core.accounting_book_period_control + SET period_status_code = 'hard_closed', + period_closed_at = clock_timestamp() + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, accounting_book_id, fiscal_period_id), + ) + + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "hard_close_snapshot_pair_required", + ): + connection.commit() + connection.rollback() + + with psycopg.connect(posting.DATABASE_URL) as connection: + accounting_book_id, fiscal_period_id = self._scope(connection) + period_status, snapshot_count = connection.execute( + """ + SELECT accounting_book_period_control.period_status_code, + ( + SELECT count(*) + FROM accounting_reporting.trial_balance_snapshot + WHERE trial_balance_snapshot.tenant_account_id + = accounting_book_period_control.tenant_account_id + AND trial_balance_snapshot.accounting_book_id + = accounting_book_period_control.accounting_book_id + AND trial_balance_snapshot.fiscal_period_id + = accounting_book_period_control.fiscal_period_id + ) + FROM accounting_core.accounting_book_period_control + WHERE accounting_book_period_control.tenant_account_id = %s + AND accounting_book_period_control.accounting_book_id = %s + AND accounting_book_period_control.fiscal_period_id = %s + """, + (self.case.tenant_id, accounting_book_id, fiscal_period_id), + ).fetchone() + + self.assertEqual(period_status, "soft_closed") + self.assertEqual(snapshot_count, 0) + + def test_upgrade_refuses_preexisting_hard_close_without_snapshot(self) -> None: + """Migration 0036 may not silently grandfather a one-sided hard-close fact.""" + migration_sql = PAIR_MIGRATION.read_text(encoding="utf-8") + + with psycopg.connect( + posting.DATABASE_URL, + autocommit=True, + cursor_factory=psycopg.ClientCursor, + ) as connection: + accounting_book_id, fiscal_period_id = self._scope(connection) + connection.execute( + """ + DROP TRIGGER hard_close_trial_balance_snapshot_pair_guard + ON accounting_core.accounting_book_period_control + """ + ) + connection.execute("BEGIN") + connection.execute( + """ + UPDATE accounting_core.accounting_book_period_control + SET period_status_code = 'hard_closed', + period_closed_at = clock_timestamp() + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, accounting_book_id, fiscal_period_id), + ) + connection.execute("COMMIT") + + try: + with psycopg.connect( + posting.DATABASE_URL, + autocommit=True, + cursor_factory=psycopg.ClientCursor, + ) as connection: + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "hard_close_snapshot_pair_legacy_preflight", + ): + connection.execute(migration_sql) + connection.execute("ROLLBACK") + finally: + with psycopg.connect( + posting.DATABASE_URL, + autocommit=True, + cursor_factory=psycopg.ClientCursor, + ) as connection: + connection.execute( + """ + DROP TRIGGER IF EXISTS hard_close_trial_balance_snapshot_pair_guard + ON accounting_core.accounting_book_period_control + """ + ) + connection.execute( + """ + UPDATE accounting_core.accounting_book_period_control + SET period_status_code = 'soft_closed' + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, accounting_book_id, fiscal_period_id), + ) + connection.execute(migration_sql) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_postgres_open_period_close_serialization_red.py b/tests/test_postgres_open_period_close_serialization_red.py new file mode 100644 index 00000000..4e82af48 --- /dev/null +++ b/tests/test_postgres_open_period_close_serialization_red.py @@ -0,0 +1,331 @@ +"""Real PostgreSQL regression for an open-period journal racing hard close.""" + +from __future__ import annotations + +import threading +import unittest +import uuid +from datetime import date +from decimal import Decimal +from unittest import mock + +import psycopg + +from accounting_information_platform import PostedJournalLine, PostgresPostingLedger +from tests import test_postgres_posting as posting + + +class OpenPeriodCloseSerializationPostgresTests(unittest.TestCase): + """Require direct open-to-hard-close evidence to include every admitted journal.""" + + @classmethod + def setUpClass(cls) -> None: + """Install the complete production migration chain used by posting tests.""" + posting.PostgresPostingTests.setUpClass() + + def setUp(self) -> None: + """Create one open book-period with an initial authoritative journal.""" + self.case = posting.PostgresPostingTests("setUp") + self.case.setUp() + self.addCleanup(self.case.doCleanups) + self.addCleanup(self.case.tearDown) + self.case.ledger.post(self.case._two_line_proposal(), self.case.policy) + + def _scope(self, connection: object) -> tuple[object, object, object]: + row = connection.execute( + """ + SELECT legal_entity_record.legal_entity_id, + accounting_book.accounting_book_id, + fiscal_period.fiscal_period_id + FROM accounting_core.legal_entity_record + JOIN accounting_core.accounting_book + ON accounting_book.tenant_account_id = legal_entity_record.tenant_account_id + AND accounting_book.legal_entity_id = legal_entity_record.legal_entity_id + JOIN accounting_core.accounting_book_period_control + ON accounting_book_period_control.tenant_account_id = accounting_book.tenant_account_id + AND accounting_book_period_control.accounting_book_id = accounting_book.accounting_book_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id = accounting_book_period_control.tenant_account_id + AND fiscal_period.fiscal_period_id = accounting_book_period_control.fiscal_period_id + WHERE legal_entity_record.tenant_account_id = %s + AND legal_entity_record.legal_entity_code = %s + AND accounting_book.book_name = %s + AND fiscal_period.period_code = '2026-08' + """, + ( + self.case.tenant_id, + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + ), + ).fetchone() + self.assertIsNotNone(row) + assert row is not None + return row + + def test_open_period_role_context_without_close_lock_cannot_prepopulate_snapshot(self) -> None: + """Closing capability plus a GUC is not enough to forge open-period retained evidence.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + legal_entity_id, accounting_book_id, fiscal_period_id = self._scope(connection) + connection.execute( + "SELECT set_config('accounting_core.journal_write_role', 'period_closing', true)" + ) + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "trial_balance_snapshot_authority_required", + ): + connection.execute( + """ + INSERT INTO accounting_reporting.trial_balance_snapshot ( + tenant_account_id, + legal_entity_id, + accounting_book_id, + fiscal_period_id, + snapshot_currency_code, + source_journal_count, + source_payload_hash, + close_idempotency_key + ) + VALUES (%s, %s, %s, %s, 'KRW', 1, %s, %s) + """, + ( + self.case.tenant_id, + legal_entity_id, + accounting_book_id, + fiscal_period_id, + "sha256:" + "c" * 64, + f"{self.case.policy.tenant_reference}:open-close-race:forged", + ), + ) + + def test_incomplete_preseeded_fence_population_blocks_period_transition(self) -> None: + """A missing stripe cannot degrade freshness validation into best-effort close.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + _legal_entity_id, accounting_book_id, fiscal_period_id = self._scope(connection) + fence_count = connection.execute( + """ + SELECT count(*) + FROM accounting_core.period_journal_population_fence + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, accounting_book_id, fiscal_period_id), + ).fetchone()[0] + self.assertEqual(fence_count, 64) + connection.execute( + """ + DELETE FROM accounting_core.period_journal_population_fence + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + AND fence_slot = 63 + """, + (self.case.tenant_id, accounting_book_id, fiscal_period_id), + ) + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "period_journal_population_fence_missing", + ): + connection.execute( + """ + UPDATE accounting_core.accounting_book_period_control + SET period_status_code = 'soft_closed' + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, accounting_book_id, fiscal_period_id), + ) + connection.rollback() + + def test_open_period_journal_committed_after_close_snapshot_invalidates_stale_close(self) -> None: + """A stale direct hard close fails, then exact-key retry retains the admitted journal.""" + close_ledger = PostgresPostingLedger( + posting.DATABASE_URL, self.case.policy.tenant_reference + ) + posting_ledger = PostgresPostingLedger( + posting.DATABASE_URL, self.case.policy.tenant_reference + ) + close_idempotency_key = f"{self.case.policy.tenant_reference}:open-close-race:hard" + pre_period_row_lock_reached = threading.Event() + concurrent_post_committed = threading.Event() + self.addCleanup(concurrent_post_committed.set) + close_result: list[object] = [] + close_errors: list[BaseException] = [] + original_lock_book_period = close_ledger._lock_book_period + + def pause_before_period_row_lock(*args: object, **kwargs: object) -> object: + pre_period_row_lock_reached.set() + if not concurrent_post_committed.wait(timeout=10): + raise AssertionError("open-period journal did not commit before close resumed") + return original_lock_book_period(*args, **kwargs) + + def run_close() -> None: + try: + close_result.append( + close_ledger.close_fiscal_period( + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + "2026-08", + "KRW", + period_status_code="hard_closed", + idempotency_key=close_idempotency_key, + ) + ) + except BaseException as error: # noqa: BLE001 - thread transports exact failure + close_errors.append(error) + + with mock.patch.object( + close_ledger, "_lock_book_period", side_effect=pause_before_period_row_lock + ): + closer = threading.Thread(target=run_close, daemon=True) + closer.start() + self.assertTrue( + pre_period_row_lock_reached.wait(timeout=10), + "hard-close did not reach the deterministic pre-period-row-lock boundary", + ) + posting_ledger.post_adjusting_journal( + legal_entity_reference=self.case.policy.legal_entity_reference, + accounting_book_reference=self.case.policy.accounting_book_reference, + period_code="2026-08", + journal_date=date(2026, 8, 31), + idempotency_key=f"{self.case.policy.tenant_reference}:open-close-race:journal", + source_payload_hash="sha256:" + "b" * 64, + proposal_id=str(uuid.uuid4()), + transaction_currency="KRW", + lines=( + PostedJournalLine( + line_number=1, + chart_account_code="110100", + account_role_code="accounts_receivable", + debit_amount=Decimal("11.750000"), + credit_amount=Decimal("0"), + ), + PostedJournalLine( + line_number=2, + chart_account_code="410100", + account_role_code="usage_revenue", + debit_amount=Decimal("0"), + credit_amount=Decimal("11.750000"), + ), + ), + ) + concurrent_post_committed.set() + closer.join(timeout=20) + + self.assertFalse(closer.is_alive(), "hard-close remained blocked after open journal commit") + self.assertEqual(close_result, []) + self.assertEqual(len(close_errors), 1) + self.assertIsInstance(close_errors[0], psycopg.errors.SerializationFailure) + + with psycopg.connect(posting.DATABASE_URL) as connection: + status_code, snapshot_count = connection.execute( + """ + SELECT accounting_book_period_control.period_status_code, + ( + SELECT COUNT(*) + FROM accounting_reporting.trial_balance_snapshot + WHERE trial_balance_snapshot.tenant_account_id = %s + AND trial_balance_snapshot.accounting_book_id + = accounting_book_period_control.accounting_book_id + AND trial_balance_snapshot.fiscal_period_id + = accounting_book_period_control.fiscal_period_id + ) + FROM accounting_core.accounting_book_period_control + JOIN accounting_core.accounting_book + ON accounting_book.tenant_account_id + = accounting_book_period_control.tenant_account_id + AND accounting_book.accounting_book_id + = accounting_book_period_control.accounting_book_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id + = accounting_book_period_control.tenant_account_id + AND fiscal_period.fiscal_period_id + = accounting_book_period_control.fiscal_period_id + WHERE accounting_book_period_control.tenant_account_id = %s + AND accounting_book.book_name = %s + AND fiscal_period.period_code = '2026-08' + """, + ( + self.case.tenant_id, + self.case.tenant_id, + self.case.policy.accounting_book_reference, + ), + ).fetchone() + self.assertEqual(status_code, "open") + self.assertEqual(snapshot_count, 0) + + close_result.append( + close_ledger.close_fiscal_period( + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + "2026-08", + "KRW", + period_status_code="hard_closed", + idempotency_key=close_idempotency_key, + ) + ) + self.assertEqual(len(close_result), 1) + + with psycopg.connect(posting.DATABASE_URL) as connection: + live_debit, live_credit, retained_debit, retained_credit = connection.execute( + """ + SELECT + COALESCE(( + SELECT SUM(journal_entry_line.debit_amount) + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id + AND chart_account.chart_account_id = journal_entry_line.chart_account_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id = general_journal.tenant_account_id + AND fiscal_period.fiscal_period_id = general_journal.fiscal_period_id + WHERE general_journal.tenant_account_id = %s + AND chart_account.chart_account_code = '110100' + AND fiscal_period.period_code = '2026-08' + ), 0), + COALESCE(( + SELECT SUM(journal_entry_line.credit_amount) + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id + AND chart_account.chart_account_id = journal_entry_line.chart_account_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id = general_journal.tenant_account_id + AND fiscal_period.fiscal_period_id = general_journal.fiscal_period_id + WHERE general_journal.tenant_account_id = %s + AND chart_account.chart_account_code = '110100' + AND fiscal_period.period_code = '2026-08' + ), 0), + trial_balance_line.debit_total_amount, + trial_balance_line.credit_total_amount + FROM accounting_reporting.trial_balance_line + JOIN accounting_reporting.trial_balance_snapshot + ON trial_balance_snapshot.tenant_account_id = trial_balance_line.tenant_account_id + AND trial_balance_snapshot.trial_balance_snapshot_id + = trial_balance_line.trial_balance_snapshot_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = trial_balance_line.tenant_account_id + AND chart_account.chart_account_id = trial_balance_line.chart_account_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id = trial_balance_snapshot.tenant_account_id + AND fiscal_period.fiscal_period_id = trial_balance_snapshot.fiscal_period_id + WHERE trial_balance_snapshot.tenant_account_id = %s + AND chart_account.chart_account_code = '110100' + AND fiscal_period.period_code = '2026-08' + """, + (self.case.tenant_id, self.case.tenant_id, self.case.tenant_id), + ).fetchone() + + self.assertEqual(Decimal(live_debit), Decimal(retained_debit)) + self.assertEqual(Decimal(live_credit), Decimal(retained_credit)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_postgres_open_period_journal_fence.py b/tests/test_postgres_open_period_journal_fence.py new file mode 100644 index 00000000..2fe04570 --- /dev/null +++ b/tests/test_postgres_open_period_journal_fence.py @@ -0,0 +1,206 @@ +"""Real PostgreSQL regression for the period-close journal population fence.""" + +from __future__ import annotations + +import threading +import unittest +import uuid +from unittest import mock + +import psycopg + +from accounting_information_platform import PostgresPostingLedger +from tests import test_postgres_posting as posting + + +class OpenPeriodJournalFencePostgresTests(unittest.TestCase): + """Keep ordinary open-period posting off close-control serialization points.""" + + @classmethod + def setUpClass(cls) -> None: + """Install the complete production migration chain used by posting tests.""" + posting.PostgresPostingTests.setUpClass() + + def setUp(self) -> None: + """Create one isolated tenant/book/period fixture in the open state.""" + self.case = posting.PostgresPostingTests("setUp") + self.case.setUp() + self.addCleanup(self.case.doCleanups) + self.addCleanup(self.case.tearDown) + + def _journal_population_revision(self) -> int: + with psycopg.connect(posting.DATABASE_URL) as connection: + row = connection.execute( + """ + SELECT accounting_book_period_control.journal_population_revision + FROM accounting_core.accounting_book_period_control + JOIN accounting_core.accounting_book + ON accounting_book.tenant_account_id + = accounting_book_period_control.tenant_account_id + AND accounting_book.accounting_book_id + = accounting_book_period_control.accounting_book_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id + = accounting_book_period_control.tenant_account_id + AND fiscal_period.fiscal_period_id + = accounting_book_period_control.fiscal_period_id + WHERE accounting_book_period_control.tenant_account_id = %s + AND accounting_book.book_name = %s + AND fiscal_period.period_code = '2026-08' + """, + ( + self.case.tenant_id, + self.case.policy.accounting_book_reference, + ), + ).fetchone() + self.assertIsNotNone(row) + return int(row[0]) + + def _hold_book_period_share_lock(self, connection: object) -> None: + row = connection.execute( + """ + SELECT accounting_book_period_control.period_status_code + FROM accounting_core.accounting_book_period_control + JOIN accounting_core.accounting_book + ON accounting_book.tenant_account_id + = accounting_book_period_control.tenant_account_id + AND accounting_book.accounting_book_id + = accounting_book_period_control.accounting_book_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id + = accounting_book_period_control.tenant_account_id + AND fiscal_period.fiscal_period_id + = accounting_book_period_control.fiscal_period_id + WHERE accounting_book_period_control.tenant_account_id = %s + AND accounting_book.book_name = %s + AND fiscal_period.period_code = '2026-08' + FOR SHARE OF accounting_book_period_control + """, + ( + self.case.tenant_id, + self.case.policy.accounting_book_reference, + ), + ).fetchone() + self.assertEqual(row[0], "open") + + def test_open_period_posting_does_not_version_close_control_row(self) -> None: + """Open-period journals may share the period fence but must not serialize on one row update.""" + before_revision = self._journal_population_revision() + + self.case.ledger.post(self.case._two_line_proposal(), self.case.policy) + + self.assertEqual(self._journal_population_revision(), before_revision) + + def test_open_period_posting_can_progress_while_peer_holds_share_fence(self) -> None: + """A peer shared fence must not turn ordinary journal admission into an exclusive-row queue.""" + posting_finished = threading.Event() + posting_errors: list[BaseException] = [] + + def post_journal() -> None: + try: + self.case.ledger.post(self.case._two_line_proposal(), self.case.policy) + except BaseException as error: # noqa: BLE001 - thread transports exact failure + posting_errors.append(error) + finally: + posting_finished.set() + + with psycopg.connect(posting.DATABASE_URL) as fence_connection: + self._hold_book_period_share_lock(fence_connection) + worker = threading.Thread(target=post_journal, daemon=True) + worker.start() + progressed_while_share_lock_held = posting_finished.wait(timeout=5) + fence_connection.rollback() + worker.join(timeout=10) + + self.assertFalse(worker.is_alive(), "ordinary posting remained blocked after fence release") + self.assertEqual(posting_errors, []) + self.assertTrue( + progressed_while_share_lock_held, + "ordinary open-period posting blocked behind a peer shared book-period fence", + ) + self.assertEqual(self._journal_population_revision(), 0) + + def test_open_period_postings_do_not_serialize_on_application_period_lock(self) -> None: + """Two ordinary posts may overlap before either journal row is written.""" + first_ledger = PostgresPostingLedger( + posting.DATABASE_URL, tenant_reference=self.case.policy.tenant_reference + ) + second_ledger = PostgresPostingLedger( + posting.DATABASE_URL, tenant_reference=self.case.policy.tenant_reference + ) + first_period_admission_reached = threading.Event() + release_first_post = threading.Event() + self.addCleanup(release_first_post.set) + second_post_finished = threading.Event() + first_errors: list[BaseException] = [] + second_errors: list[BaseException] = [] + original_require_open_period = first_ledger._require_open_book_period_bounds + + first_proposal = self.case._two_line_proposal( + proposal_id=str(uuid.uuid4()), + idempotency_key=f"{self.case.policy.tenant_reference}:open-concurrency:first", + source_payload_hash="sha256:" + "d" * 64, + source_event_references=( + f"{self.case.policy.tenant_reference}:open-concurrency:first", + ), + ) + second_proposal = self.case._two_line_proposal( + proposal_id=str(uuid.uuid4()), + idempotency_key=f"{self.case.policy.tenant_reference}:open-concurrency:second", + source_payload_hash="sha256:" + "e" * 64, + source_event_references=( + f"{self.case.policy.tenant_reference}:open-concurrency:second", + ), + ) + + def pause_first_after_period_admission(*args: object, **kwargs: object) -> object: + result = original_require_open_period(*args, **kwargs) + first_period_admission_reached.set() + if not release_first_post.wait(timeout=10): + raise AssertionError("first open posting was not released after concurrency probe") + return result + + def post_first() -> None: + try: + first_ledger.post(first_proposal, self.case.policy) + except BaseException as error: # noqa: BLE001 - thread transports exact failure + first_errors.append(error) + + def post_second() -> None: + try: + second_ledger.post(second_proposal, self.case.policy) + except BaseException as error: # noqa: BLE001 - thread transports exact failure + second_errors.append(error) + finally: + second_post_finished.set() + + with mock.patch.object( + first_ledger, + "_require_open_book_period_bounds", + side_effect=pause_first_after_period_admission, + ): + first_worker = threading.Thread(target=post_first, daemon=True) + first_worker.start() + self.assertTrue( + first_period_admission_reached.wait(timeout=10), + "first posting did not reach the deterministic pre-journal boundary", + ) + second_worker = threading.Thread(target=post_second, daemon=True) + second_worker.start() + second_progressed_while_first_was_paused = second_post_finished.wait(timeout=5) + release_first_post.set() + first_worker.join(timeout=10) + second_worker.join(timeout=10) + + self.assertFalse(first_worker.is_alive(), "first posting did not finish after release") + self.assertFalse(second_worker.is_alive(), "second posting did not finish after first release") + self.assertEqual(first_errors, []) + self.assertEqual(second_errors, []) + self.assertTrue( + second_progressed_while_first_was_paused, + "ordinary open-period postings are serialized by the application period advisory lock", + ) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_postgres_period_close_journal_serialization_red.py b/tests/test_postgres_period_close_journal_serialization_red.py new file mode 100644 index 00000000..6b1a3a9c --- /dev/null +++ b/tests/test_postgres_period_close_journal_serialization_red.py @@ -0,0 +1,233 @@ +"""Real PostgreSQL regression for journal admission racing a hard-close snapshot.""" + +from __future__ import annotations + +import threading +import unittest +import uuid +from datetime import date +from decimal import Decimal +from unittest import mock + +import psycopg + +from accounting_information_platform import PostedJournalLine, PostgresPostingLedger +from tests import test_postgres_posting as posting + + +class PeriodCloseJournalSerializationPostgresTests(unittest.TestCase): + """Require hard-close evidence to include every journal admitted before close authority wins.""" + + @classmethod + def setUpClass(cls) -> None: + """Install the complete production migration chain used by posting tests.""" + posting.PostgresPostingTests.setUpClass() + + def setUp(self) -> None: + """Create one posted book-period and move it to soft-close before the race.""" + self.case = posting.PostgresPostingTests("setUp") + self.case.setUp() + self.addCleanup(self.case.doCleanups) + self.addCleanup(self.case.tearDown) + self.case.ledger.post(self.case._two_line_proposal(), self.case.policy) + self.case.ledger.close_fiscal_period( + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + "2026-08", + "KRW", + period_status_code="soft_closed", + idempotency_key=f"{self.case.policy.tenant_reference}:close-race:soft", + ) + + def test_hard_close_cannot_freeze_a_snapshot_before_an_admitted_adjustment_commits(self) -> None: + """A stale close fails closed, then its exact idempotent retry snapshots the admitted journal.""" + close_ledger = PostgresPostingLedger( + posting.DATABASE_URL, self.case.policy.tenant_reference + ) + adjustment_ledger = PostgresPostingLedger( + posting.DATABASE_URL, self.case.policy.tenant_reference + ) + close_idempotency_key = f"{self.case.policy.tenant_reference}:close-race:hard" + pre_lock_snapshot_reached = threading.Event() + adjustment_committed = threading.Event() + self.addCleanup(adjustment_committed.set) + close_result: list[object] = [] + close_errors: list[BaseException] = [] + original_acquire = close_ledger._acquire_command_lock + + def pause_before_period_lock(connection: object, lock_reference: str) -> None: + if lock_reference.startswith("period:"): + pre_lock_snapshot_reached.set() + if not adjustment_committed.wait(timeout=10): + raise AssertionError("adjusting journal did not commit before close lock resumed") + original_acquire(connection, lock_reference) + + def run_close() -> None: + try: + close_result.append( + close_ledger.close_fiscal_period( + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + "2026-08", + "KRW", + period_status_code="hard_closed", + idempotency_key=close_idempotency_key, + ) + ) + except BaseException as error: # noqa: BLE001 - thread transports exact failure + close_errors.append(error) + + with mock.patch.object( + close_ledger, "_acquire_command_lock", side_effect=pause_before_period_lock + ): + closer = threading.Thread(target=run_close, daemon=True) + closer.start() + self.assertTrue( + pre_lock_snapshot_reached.wait(timeout=10), + "hard-close did not reach the deterministic pre-lock snapshot boundary", + ) + adjustment_ledger.post_adjusting_journal( + legal_entity_reference=self.case.policy.legal_entity_reference, + accounting_book_reference=self.case.policy.accounting_book_reference, + period_code="2026-08", + journal_date=date(2026, 8, 31), + idempotency_key=f"{self.case.policy.tenant_reference}:close-race:adjusting", + source_payload_hash="sha256:" + "a" * 64, + proposal_id=str(uuid.uuid4()), + transaction_currency="KRW", + lines=( + PostedJournalLine( + line_number=1, + chart_account_code="110100", + account_role_code="accounts_receivable", + debit_amount=Decimal("7.250000"), + credit_amount=Decimal("0"), + ), + PostedJournalLine( + line_number=2, + chart_account_code="410100", + account_role_code="usage_revenue", + debit_amount=Decimal("0"), + credit_amount=Decimal("7.250000"), + ), + ), + ) + adjustment_committed.set() + closer.join(timeout=20) + + self.assertFalse(closer.is_alive(), "hard-close remained blocked after adjustment commit") + self.assertEqual(close_result, []) + self.assertEqual(len(close_errors), 1) + self.assertIsInstance(close_errors[0], psycopg.errors.SerializationFailure) + + with psycopg.connect(posting.DATABASE_URL) as connection: + status_code, snapshot_count = connection.execute( + """ + SELECT accounting_book_period_control.period_status_code, + ( + SELECT COUNT(*) + FROM accounting_reporting.trial_balance_snapshot + WHERE trial_balance_snapshot.tenant_account_id = %s + AND trial_balance_snapshot.accounting_book_id + = accounting_book_period_control.accounting_book_id + AND trial_balance_snapshot.fiscal_period_id + = accounting_book_period_control.fiscal_period_id + ) + FROM accounting_core.accounting_book_period_control + JOIN accounting_core.accounting_book + ON accounting_book.tenant_account_id + = accounting_book_period_control.tenant_account_id + AND accounting_book.accounting_book_id + = accounting_book_period_control.accounting_book_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id + = accounting_book_period_control.tenant_account_id + AND fiscal_period.fiscal_period_id + = accounting_book_period_control.fiscal_period_id + WHERE accounting_book_period_control.tenant_account_id = %s + AND accounting_book.book_name = %s + AND fiscal_period.period_code = '2026-08' + """, + ( + self.case.tenant_id, + self.case.tenant_id, + self.case.policy.accounting_book_reference, + ), + ).fetchone() + self.assertEqual(status_code, "soft_closed") + self.assertEqual(snapshot_count, 0) + + close_result.append( + close_ledger.close_fiscal_period( + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + "2026-08", + "KRW", + period_status_code="hard_closed", + idempotency_key=close_idempotency_key, + ) + ) + self.assertEqual(len(close_result), 1) + + with psycopg.connect(posting.DATABASE_URL) as connection: + live_debit, live_credit, retained_debit, retained_credit = connection.execute( + """ + SELECT + COALESCE(( + SELECT SUM(journal_entry_line.debit_amount) + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id + AND chart_account.chart_account_id = journal_entry_line.chart_account_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id = general_journal.tenant_account_id + AND fiscal_period.fiscal_period_id = general_journal.fiscal_period_id + WHERE general_journal.tenant_account_id = %s + AND chart_account.chart_account_code = '110100' + AND fiscal_period.period_code = '2026-08' + ), 0), + COALESCE(( + SELECT SUM(journal_entry_line.credit_amount) + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id + AND chart_account.chart_account_id = journal_entry_line.chart_account_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id = general_journal.tenant_account_id + AND fiscal_period.fiscal_period_id = general_journal.fiscal_period_id + WHERE general_journal.tenant_account_id = %s + AND chart_account.chart_account_code = '110100' + AND fiscal_period.period_code = '2026-08' + ), 0), + trial_balance_line.debit_total_amount, + trial_balance_line.credit_total_amount + FROM accounting_reporting.trial_balance_line + JOIN accounting_reporting.trial_balance_snapshot + ON trial_balance_snapshot.tenant_account_id = trial_balance_line.tenant_account_id + AND trial_balance_snapshot.trial_balance_snapshot_id + = trial_balance_line.trial_balance_snapshot_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = trial_balance_line.tenant_account_id + AND chart_account.chart_account_id = trial_balance_line.chart_account_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id = trial_balance_snapshot.tenant_account_id + AND fiscal_period.fiscal_period_id = trial_balance_snapshot.fiscal_period_id + WHERE trial_balance_snapshot.tenant_account_id = %s + AND chart_account.chart_account_code = '110100' + AND fiscal_period.period_code = '2026-08' + """, + (self.case.tenant_id, self.case.tenant_id, self.case.tenant_id), + ).fetchone() + + self.assertEqual(Decimal(live_debit), Decimal(retained_debit)) + self.assertEqual(Decimal(live_credit), Decimal(retained_credit)) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_postgres_period_close_posted_account_identity_red.py b/tests/test_postgres_period_close_posted_account_identity_red.py new file mode 100644 index 00000000..ec1d2c85 --- /dev/null +++ b/tests/test_postgres_period_close_posted_account_identity_red.py @@ -0,0 +1,324 @@ +"""Real PostgreSQL RED for hard close after chart-account catalog expiry.""" + +from __future__ import annotations + +import hashlib +import json +import unittest +from decimal import Decimal + +import psycopg + +from tests import test_postgres_posting as posting + + +class PeriodClosePostedAccountIdentityPostgresTests(unittest.TestCase): + """Keep hard-close offsets bound to immutable posted chart-account identity.""" + + @classmethod + def setUpClass(cls) -> None: + """Install the complete current accounting migration chain.""" + posting.PostgresPostingTests.setUpClass() + + def setUp(self) -> None: + """Seed one open book with the production posting catalog.""" + self.case = posting.PostgresPostingTests("setUp") + self.case.setUp() + self.addCleanup(self.case.doCleanups) + self.addCleanup(self.case.tearDown) + + def test_hard_close_uses_posted_account_after_chart_account_expires(self) -> None: + """A later chart-account expiry cannot strand or redirect an already-posted balance.""" + self.case.ledger.post(self.case._two_line_proposal(), self.case.policy) + + with psycopg.connect(posting.DATABASE_URL) as connection: + source_account_id = connection.execute( + """ + SELECT journal_entry_line.chart_account_id + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id + AND chart_account.chart_account_id = journal_entry_line.chart_account_id + WHERE general_journal.tenant_account_id = %s + AND chart_account.chart_account_code = '410100' + AND journal_entry_line.account_role_code = 'usage_revenue' + """, + (self.case.tenant_id,), + ).fetchone()[0] + updated = connection.execute( + """ + UPDATE accounting_core.chart_account + SET valid_to = TIMESTAMPTZ '2026-09-01 00:00:00+00' + WHERE tenant_account_id = %s + AND chart_account_id = %s + AND valid_to IS NULL + """, + (self.case.tenant_id, source_account_id), + ).rowcount + connection.commit() + + self.assertEqual(updated, 1) + + receipt = self.case.ledger.close_fiscal_period( + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + "2026-08", + "KRW", + period_status_code="hard_closed", + idempotency_key=( + f"{self.case.policy.tenant_reference}:posted-account-stability:hard-close" + ), + ) + + with psycopg.connect(posting.DATABASE_URL) as connection: + closing_account_id = connection.execute( + """ + SELECT journal_entry_line.chart_account_id + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + WHERE general_journal.tenant_account_id = %s + AND general_journal.journal_reference LIKE + 'urn:cwl:accounting:general_journal:period_closing:%%' + AND journal_entry_line.account_role_code = 'usage_revenue' + """, + (self.case.tenant_id,), + ).fetchone()[0] + snapshot_line = connection.execute( + """ + SELECT trial_balance_line.debit_total_amount, + trial_balance_line.credit_total_amount, + trial_balance_line.net_balance_amount + FROM accounting_reporting.trial_balance_snapshot + JOIN accounting_reporting.trial_balance_line + ON trial_balance_line.tenant_account_id = trial_balance_snapshot.tenant_account_id + AND trial_balance_line.trial_balance_snapshot_id = trial_balance_snapshot.trial_balance_snapshot_id + WHERE trial_balance_snapshot.tenant_account_id = %s + AND trial_balance_line.chart_account_id = %s + """, + (self.case.tenant_id, source_account_id), + ).fetchone() + + self.assertEqual(receipt.period_status_code, "hard_closed") + self.assertEqual(closing_account_id, source_account_id) + self.assertEqual( + tuple(Decimal(value) for value in snapshot_line), + (Decimal("25000"), Decimal("25000"), Decimal("0")), + ) + + def test_hard_close_does_not_redirect_posted_account_when_code_is_reused(self) -> None: + """A successor account reusing the code cannot receive the historical closing contra.""" + self.case.ledger.post(self.case._two_line_proposal(), self.case.policy) + transition_at = "2026-09-01 00:00:00+00" + + with psycopg.connect(posting.DATABASE_URL) as connection: + source_account_id, book_id = connection.execute( + """ + SELECT journal_entry_line.chart_account_id, + general_journal.accounting_book_id + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id + AND chart_account.chart_account_id = journal_entry_line.chart_account_id + WHERE general_journal.tenant_account_id = %s + AND chart_account.chart_account_code = '410100' + AND journal_entry_line.account_role_code = 'usage_revenue' + """, + (self.case.tenant_id,), + ).fetchone() + mapping_updated = connection.execute( + """ + UPDATE accounting_core.account_role_mapping + SET valid_to = %s::timestamptz + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND chart_account_id = %s + AND account_role_code = 'usage_revenue' + AND valid_to IS NULL + """, + (transition_at, self.case.tenant_id, book_id, source_account_id), + ).rowcount + account_updated = connection.execute( + """ + UPDATE accounting_core.chart_account + SET valid_to = %s::timestamptz + WHERE tenant_account_id = %s + AND chart_account_id = %s + AND valid_to IS NULL + """, + (transition_at, self.case.tenant_id, source_account_id), + ).rowcount + successor_account_id = connection.execute( + """ + INSERT INTO accounting_core.chart_account ( + tenant_account_id, + accounting_book_id, + chart_account_code, + account_name, + normal_balance_code, + account_class_code, + valid_from + ) + VALUES (%s, %s, '410100', 'Usage revenue successor', 'credit', 'revenue', + %s::timestamptz) + RETURNING chart_account_id + """, + (self.case.tenant_id, book_id, transition_at), + ).fetchone()[0] + connection.execute( + """ + INSERT INTO accounting_core.account_role_mapping ( + tenant_account_id, + accounting_book_id, + account_role_code, + chart_account_id, + accounting_policy_version, + posting_rule_version, + valid_from + ) + VALUES (%s, %s, 'usage_revenue', %s, %s, %s, %s::timestamptz) + """, + ( + self.case.tenant_id, + book_id, + successor_account_id, + self.case.policy.accounting_policy_version, + self.case.policy.posting_rule_version, + transition_at, + ), + ) + connection.commit() + + self.assertEqual(mapping_updated, 1) + self.assertEqual(account_updated, 1) + self.assertNotEqual(successor_account_id, source_account_id) + + receipt = self.case.ledger.close_fiscal_period( + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + "2026-08", + "KRW", + period_status_code="hard_closed", + idempotency_key=( + f"{self.case.policy.tenant_reference}:posted-account-code-reuse:hard-close" + ), + ) + + with psycopg.connect(posting.DATABASE_URL) as connection: + closing_account_id = connection.execute( + """ + SELECT journal_entry_line.chart_account_id + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + WHERE general_journal.tenant_account_id = %s + AND general_journal.journal_reference LIKE + 'urn:cwl:accounting:general_journal:period_closing:%%' + AND journal_entry_line.account_role_code = 'usage_revenue' + """, + (self.case.tenant_id,), + ).fetchone()[0] + snapshot_line = connection.execute( + """ + SELECT trial_balance_line.debit_total_amount, + trial_balance_line.credit_total_amount, + trial_balance_line.net_balance_amount + FROM accounting_reporting.trial_balance_snapshot + JOIN accounting_reporting.trial_balance_line + ON trial_balance_line.tenant_account_id = trial_balance_snapshot.tenant_account_id + AND trial_balance_line.trial_balance_snapshot_id = trial_balance_snapshot.trial_balance_snapshot_id + WHERE trial_balance_snapshot.tenant_account_id = %s + AND trial_balance_line.chart_account_id = %s + """, + (self.case.tenant_id, source_account_id), + ).fetchone() + closing_source_payload_hash = connection.execute( + """ + SELECT journal_proposal_record.source_payload_hash + FROM accounting_core.general_journal + JOIN accounting_integration.journal_proposal_record + ON journal_proposal_record.tenant_account_id = general_journal.tenant_account_id + AND journal_proposal_record.proposal_record_id = general_journal.source_proposal_record_id + WHERE general_journal.tenant_account_id = %s + AND general_journal.journal_reference LIKE + 'urn:cwl:accounting:general_journal:period_closing:%%' + """, + (self.case.tenant_id,), + ).fetchone()[0] + closing_lines = connection.execute( + """ + SELECT journal_entry_line.line_number, + journal_entry_line.chart_account_id, + chart_account.chart_account_code, + journal_entry_line.account_role_code, + journal_entry_line.debit_amount, + journal_entry_line.credit_amount + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = journal_entry_line.tenant_account_id + AND chart_account.chart_account_id = journal_entry_line.chart_account_id + WHERE general_journal.tenant_account_id = %s + AND general_journal.journal_reference LIKE + 'urn:cwl:accounting:general_journal:period_closing:%%' + ORDER BY journal_entry_line.line_number + """, + (self.case.tenant_id,), + ).fetchall() + + closing_payload_lines: list[dict[str, object]] = [] + for ( + line_number, + chart_account_id, + chart_account_code, + account_role_code, + debit_amount, + credit_amount, + ) in closing_lines: + payload_line: dict[str, object] = { + "account_role_code": str(account_role_code), + "chart_account_code": str(chart_account_code), + "credit_amount": format(Decimal(credit_amount), "f"), + "debit_amount": format(Decimal(debit_amount), "f"), + "line_number": int(line_number), + } + if account_role_code != "retained_earnings": + payload_line["chart_account_id"] = str(chart_account_id) + closing_payload_lines.append(payload_line) + closing_payload = json.dumps( + { + "accounting_book_reference": self.case.policy.accounting_book_reference, + "legal_entity_reference": self.case.policy.legal_entity_reference, + "lines": closing_payload_lines, + "period_code": "2026-08", + "tenant_reference": self.case.policy.tenant_reference, + }, + separators=(",", ":"), + sort_keys=True, + ) + expected_closing_source_payload_hash = "sha256:" + hashlib.sha256( + closing_payload.encode("utf-8") + ).hexdigest() + + self.assertEqual(receipt.period_status_code, "hard_closed") + self.assertEqual(closing_account_id, source_account_id) + self.assertNotEqual(closing_account_id, successor_account_id) + self.assertEqual(closing_source_payload_hash, expected_closing_source_payload_hash) + self.assertEqual( + tuple(Decimal(value) for value in snapshot_line), + (Decimal("25000"), Decimal("25000"), Decimal("0")), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_postgres_period_close_posted_role_stability_red.py b/tests/test_postgres_period_close_posted_role_stability_red.py new file mode 100644 index 00000000..12cba4ec --- /dev/null +++ b/tests/test_postgres_period_close_posted_role_stability_red.py @@ -0,0 +1,104 @@ +"""Real PostgreSQL RED/GREEN for period close using immutable posted roles.""" + +from __future__ import annotations + +import unittest +from decimal import Decimal + +import psycopg + +from tests import test_postgres_posting as posting + + +class PeriodClosePostedRoleStabilityPostgresTests(unittest.TestCase): + """Keep hard-close classification bound to posted journal facts, not current catalog state.""" + + @classmethod + def setUpClass(cls) -> None: + """Install the complete current accounting migration chain.""" + posting.PostgresPostingTests.setUpClass() + + def setUp(self) -> None: + """Seed one open book with the production posting catalog.""" + self.case = posting.PostgresPostingTests("setUp") + self.case.setUp() + self.addCleanup(self.case.doCleanups) + self.addCleanup(self.case.tearDown) + + def test_hard_close_uses_posted_role_after_catalog_mapping_expires(self) -> None: + """A later catalog expiry cannot reclassify a journal that was already posted.""" + self.case.ledger.post(self.case._two_line_proposal(), self.case.policy) + + with psycopg.connect(posting.DATABASE_URL) as connection: + updated = connection.execute( + """ + UPDATE accounting_core.account_role_mapping + SET valid_to = TIMESTAMPTZ '2026-09-01 00:00:00+00' + WHERE tenant_account_id = %s + AND account_role_code = 'usage_revenue' + AND valid_to IS NULL + """, + (self.case.tenant_id,), + ).rowcount + posted_roles = connection.execute( + """ + SELECT journal_entry_line.account_role_code, + journal_entry_line.credit_amount + FROM accounting_core.journal_entry_line + JOIN accounting_core.general_journal + ON general_journal.tenant_account_id = journal_entry_line.tenant_account_id + AND general_journal.general_journal_id = journal_entry_line.general_journal_id + WHERE general_journal.tenant_account_id = %s + AND journal_entry_line.account_role_code = 'usage_revenue' + """, + (self.case.tenant_id,), + ).fetchall() + + self.assertEqual(updated, 1) + self.assertEqual(posted_roles, [("usage_revenue", Decimal("25000.000000"))]) + + receipt = self.case.ledger.close_fiscal_period( + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + "2026-08", + "KRW", + period_status_code="hard_closed", + idempotency_key=( + f"{self.case.policy.tenant_reference}:posted-role-stability:hard-close" + ), + ) + + with psycopg.connect(posting.DATABASE_URL) as connection: + snapshot_lines = { + str(row[0]): (Decimal(row[1]), Decimal(row[2]), Decimal(row[3])) + for row in connection.execute( + """ + SELECT chart_account.chart_account_code, + trial_balance_line.debit_total_amount, + trial_balance_line.credit_total_amount, + trial_balance_line.net_balance_amount + FROM accounting_reporting.trial_balance_snapshot + JOIN accounting_reporting.trial_balance_line + ON trial_balance_line.tenant_account_id = trial_balance_snapshot.tenant_account_id + AND trial_balance_line.trial_balance_snapshot_id = trial_balance_snapshot.trial_balance_snapshot_id + JOIN accounting_core.chart_account + ON chart_account.tenant_account_id = trial_balance_line.tenant_account_id + AND chart_account.chart_account_id = trial_balance_line.chart_account_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id = trial_balance_snapshot.tenant_account_id + AND fiscal_period.fiscal_period_id = trial_balance_snapshot.fiscal_period_id + WHERE trial_balance_snapshot.tenant_account_id = %s + AND fiscal_period.period_code = '2026-08' + """, + (self.case.tenant_id,), + ).fetchall() + } + + self.assertEqual(receipt.period_status_code, "hard_closed") + self.assertEqual(self.case._count_closing_journals(), 1) + self.assertEqual(snapshot_lines["410100"], (Decimal("25000"), Decimal("25000"), Decimal("0"))) + self.assertEqual(snapshot_lines["310100"], (Decimal("0"), Decimal("25000"), Decimal("-25000"))) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_postgres_period_transition_isolation_red.py b/tests/test_postgres_period_transition_isolation_red.py new file mode 100644 index 00000000..6004b538 --- /dev/null +++ b/tests/test_postgres_period_transition_isolation_red.py @@ -0,0 +1,94 @@ +"""Real PostgreSQL RED/GREEN for period-close transition isolation authority.""" + +from __future__ import annotations + +import unittest + +import psycopg + +from tests import test_postgres_posting as posting + + +class PeriodTransitionIsolationPostgresTests(unittest.TestCase): + """Reject close-state mutation that bypasses the supported snapshot-isolation contract.""" + + @classmethod + def setUpClass(cls) -> None: + """Install the complete production migration chain used by posting tests.""" + posting.PostgresPostingTests.setUpClass() + + def setUp(self) -> None: + """Create one open book-period with its complete journal-population fence.""" + self.case = posting.PostgresPostingTests("setUp") + self.case.setUp() + self.addCleanup(self.case.doCleanups) + self.addCleanup(self.case.tearDown) + self.case.ledger.post(self.case._two_line_proposal(), self.case.policy) + + def _scope(self, connection: psycopg.Connection[object]) -> tuple[object, object]: + row = connection.execute( + """ + SELECT accounting_book.accounting_book_id, + accounting_book_period_control.fiscal_period_id + FROM accounting_core.accounting_book + JOIN accounting_core.accounting_book_period_control + ON accounting_book_period_control.tenant_account_id + = accounting_book.tenant_account_id + AND accounting_book_period_control.accounting_book_id + = accounting_book.accounting_book_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id + = accounting_book_period_control.tenant_account_id + AND fiscal_period.fiscal_period_id + = accounting_book_period_control.fiscal_period_id + WHERE accounting_book.tenant_account_id = %s + AND accounting_book.book_name = %s + AND fiscal_period.period_code = '2026-08' + """, + (self.case.tenant_id, self.case.policy.accounting_book_reference), + ).fetchone() + self.assertIsNotNone(row) + assert row is not None + return row + + def _assert_transition_rejected(self, connection: psycopg.Connection[object]) -> None: + accounting_book_id, fiscal_period_id = self._scope(connection) + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "period_close_isolation_required", + ): + connection.execute( + """ + UPDATE accounting_core.accounting_book_period_control + SET period_status_code = 'soft_closed', + period_closed_at = clock_timestamp() + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, accounting_book_id, fiscal_period_id), + ) + connection.rollback() + + def test_read_committed_cannot_transition_book_period_close_state(self) -> None: + """A raw READ COMMITTED writer cannot claim close authority from a weak snapshot.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + isolation = connection.execute( + "SELECT current_setting('transaction_isolation')" + ).fetchone()[0] + self.assertEqual(isolation, "read committed") + self._assert_transition_rejected(connection) + + def test_read_uncommitted_alias_cannot_transition_book_period_close_state(self) -> None: + """PostgreSQL's weak READ UNCOMMITTED alias cannot bypass the close-isolation gate.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + connection.execute("SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED") + isolation = connection.execute( + "SELECT current_setting('transaction_isolation')" + ).fetchone()[0] + self.assertEqual(isolation, "read uncommitted") + self._assert_transition_rejected(connection) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_postgres_soft_close_command_evidence_pair_red.py b/tests/test_postgres_soft_close_command_evidence_pair_red.py new file mode 100644 index 00000000..61dbc104 --- /dev/null +++ b/tests/test_postgres_soft_close_command_evidence_pair_red.py @@ -0,0 +1,172 @@ +"""Real PostgreSQL RED/GREEN for the soft-close command-evidence commit pair.""" + +from __future__ import annotations + +from pathlib import Path +import unittest + +import psycopg + +from tests import test_postgres_posting as posting + + +ROOT = Path(__file__).resolve().parents[1] +PAIR_MIGRATION = ROOT / "database/migrations/0037_soft_close_command_evidence_pair.sql" + + +class SoftCloseCommandEvidencePairPostgresTests(unittest.TestCase): + """A soft-closed book period may commit only with its durable command evidence.""" + + @classmethod + def setUpClass(cls) -> None: + """Install the complete current accounting migration chain.""" + posting.PostgresPostingTests.setUpClass() + + def setUp(self) -> None: + """Create one governed open book period with its complete population fence.""" + self.case = posting.PostgresPostingTests("setUp") + self.case.setUp() + self.addCleanup(self.case.doCleanups) + self.addCleanup(self.case.tearDown) + self.case.ledger.post(self.case._two_line_proposal(), self.case.policy) + + def _scope(self, connection: psycopg.Connection[object]) -> tuple[object, object]: + row = connection.execute( + """ + SELECT accounting_book.accounting_book_id, + accounting_book_period_control.fiscal_period_id + FROM accounting_core.accounting_book + JOIN accounting_core.accounting_book_period_control + ON accounting_book_period_control.tenant_account_id + = accounting_book.tenant_account_id + AND accounting_book_period_control.accounting_book_id + = accounting_book.accounting_book_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id + = accounting_book_period_control.tenant_account_id + AND fiscal_period.fiscal_period_id + = accounting_book_period_control.fiscal_period_id + WHERE accounting_book.tenant_account_id = %s + AND accounting_book.book_name = %s + AND fiscal_period.period_code = '2026-08' + """, + (self.case.tenant_id, self.case.policy.accounting_book_reference), + ).fetchone() + self.assertIsNotNone(row) + assert row is not None + return row[0], row[1] + + def test_soft_close_cannot_commit_without_durable_command_evidence(self) -> None: + """A raw database writer cannot retain soft-close authority without command evidence.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + connection.execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ") + accounting_book_id, fiscal_period_id = self._scope(connection) + connection.execute( + """ + UPDATE accounting_core.accounting_book_period_control + SET period_status_code = 'soft_closed', + period_closed_at = clock_timestamp() + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, accounting_book_id, fiscal_period_id), + ) + + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "soft_close_command_evidence_pair_required", + ): + connection.commit() + connection.rollback() + + with psycopg.connect(posting.DATABASE_URL) as connection: + accounting_book_id, fiscal_period_id = self._scope(connection) + row = connection.execute( + """ + SELECT period_status_code, + soft_close_idempotency_key, + soft_close_source_payload_hash, + soft_close_source_journal_count + FROM accounting_core.accounting_book_period_control + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, accounting_book_id, fiscal_period_id), + ).fetchone() + + self.assertEqual(row, ("open", None, None, None)) + + def test_upgrade_refuses_preexisting_soft_close_without_command_evidence(self) -> None: + """Migration 0037 may not grandfather a one-sided soft-close authority fact.""" + migration_sql = PAIR_MIGRATION.read_text(encoding="utf-8") + + with psycopg.connect( + posting.DATABASE_URL, + autocommit=True, + cursor_factory=psycopg.ClientCursor, + ) as connection: + accounting_book_id, fiscal_period_id = self._scope(connection) + connection.execute( + """ + DROP TRIGGER soft_close_command_evidence_pair_guard + ON accounting_core.accounting_book_period_control + """ + ) + connection.execute("BEGIN ISOLATION LEVEL REPEATABLE READ") + connection.execute( + """ + UPDATE accounting_core.accounting_book_period_control + SET period_status_code = 'soft_closed', + period_closed_at = clock_timestamp() + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, accounting_book_id, fiscal_period_id), + ) + connection.execute("COMMIT") + + try: + with psycopg.connect( + posting.DATABASE_URL, + autocommit=True, + cursor_factory=psycopg.ClientCursor, + ) as connection: + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "soft_close_command_evidence_pair_legacy_preflight", + ): + connection.execute(migration_sql) + connection.execute("ROLLBACK") + finally: + with psycopg.connect( + posting.DATABASE_URL, + autocommit=True, + cursor_factory=psycopg.ClientCursor, + ) as connection: + connection.execute( + """ + DROP TRIGGER IF EXISTS soft_close_command_evidence_pair_guard + ON accounting_core.accounting_book_period_control + """ + ) + connection.execute("BEGIN ISOLATION LEVEL REPEATABLE READ") + connection.execute( + """ + UPDATE accounting_core.accounting_book_period_control + SET period_status_code = 'open', + period_closed_at = NULL + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, accounting_book_id, fiscal_period_id), + ) + connection.execute("COMMIT") + connection.execute(migration_sql) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_postgres_trial_balance_snapshot_admission_red.py b/tests/test_postgres_trial_balance_snapshot_admission_red.py new file mode 100644 index 00000000..7d9ff56b --- /dev/null +++ b/tests/test_postgres_trial_balance_snapshot_admission_red.py @@ -0,0 +1,203 @@ +"""Real PostgreSQL RED/GREEN for trial-balance snapshot admission authority.""" + +from __future__ import annotations + +import unittest +from datetime import date + +import psycopg + +from tests import test_postgres_posting as posting + + +class TrialBalanceSnapshotAdmissionPostgresTests(unittest.TestCase): + """Only the governed hard-close path may create a retained snapshot population.""" + + @classmethod + def setUpClass(cls) -> None: + """Install the complete current accounting migration chain.""" + posting.PostgresPostingTests.setUpClass() + + def setUp(self) -> None: + """Leave one balanced book-period soft-closed before hard-close authority.""" + self.case = posting.PostgresPostingTests("setUp") + self.case.setUp() + self.addCleanup(self.case.doCleanups) + self.addCleanup(self.case.tearDown) + self.posting_receipt = self.case.ledger.post( + self.case._two_line_proposal(), self.case.policy + ) + self.case.ledger.close_fiscal_period( + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + "2026-08", + "KRW", + period_status_code="soft_closed", + idempotency_key=f"{self.case.policy.tenant_reference}:snapshot-admission:soft", + ) + + def _scope(self, connection: psycopg.Connection[object]) -> tuple[object, object, object]: + row = connection.execute( + """ + SELECT legal_entity_record.legal_entity_id, + accounting_book.accounting_book_id, + fiscal_period.fiscal_period_id + FROM accounting_core.legal_entity_record + JOIN accounting_core.accounting_book + ON accounting_book.tenant_account_id = legal_entity_record.tenant_account_id + AND accounting_book.legal_entity_id = legal_entity_record.legal_entity_id + JOIN accounting_core.accounting_book_period_control + ON accounting_book_period_control.tenant_account_id = accounting_book.tenant_account_id + AND accounting_book_period_control.accounting_book_id = accounting_book.accounting_book_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id = accounting_book_period_control.tenant_account_id + AND fiscal_period.fiscal_period_id = accounting_book_period_control.fiscal_period_id + WHERE legal_entity_record.tenant_account_id = %s + AND legal_entity_record.legal_entity_code = %s + AND accounting_book.book_name = %s + AND fiscal_period.period_code = '2026-08' + """, + ( + self.case.tenant_id, + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + ), + ).fetchone() + self.assertIsNotNone(row) + assert row is not None + return row + + def test_raw_soft_close_snapshot_insert_requires_close_authority(self) -> None: + """A privileged SQL session cannot pre-populate retained close evidence directly.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + legal_entity_id, accounting_book_id, fiscal_period_id = self._scope(connection) + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "trial_balance_snapshot_authority_required", + ): + connection.execute( + """ + INSERT INTO accounting_reporting.trial_balance_snapshot ( + tenant_account_id, + legal_entity_id, + accounting_book_id, + fiscal_period_id, + snapshot_currency_code, + source_journal_count, + source_payload_hash, + close_idempotency_key + ) + VALUES (%s, %s, %s, %s, 'KRW', 0, %s, %s) + """, + ( + self.case.tenant_id, + legal_entity_id, + accounting_book_id, + fiscal_period_id, + "sha256:" + "6" * 64, + f"{self.case.policy.tenant_reference}:snapshot-admission:forged", + ), + ) + connection.rollback() + + def test_guc_only_closing_writer_cannot_forge_snapshot_authority(self) -> None: + """Caller-set write-role state cannot substitute for the canonical close lock.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + legal_entity_id, accounting_book_id, fiscal_period_id = self._scope(connection) + has_closing_capability = connection.execute( + "SELECT pg_has_role(session_user, 'accounting_closing_writer', 'MEMBER')" + ).fetchone()[0] + self.assertTrue(has_closing_capability) + connection.execute( + "SELECT set_config('accounting_core.journal_write_role', 'period_closing', true)" + ) + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "trial_balance_snapshot_authority_required", + ): + connection.execute( + """ + INSERT INTO accounting_reporting.trial_balance_snapshot ( + tenant_account_id, + legal_entity_id, + accounting_book_id, + fiscal_period_id, + snapshot_currency_code, + snapshot_generated_at, + source_journal_count, + source_payload_hash, + close_idempotency_key + ) + VALUES (%s, %s, %s, %s, 'KRW', '2099-01-01T00:00:00Z', 0, %s, %s) + """, + ( + self.case.tenant_id, + legal_entity_id, + accounting_book_id, + fiscal_period_id, + "sha256:" + "5" * 64, + f"{self.case.policy.tenant_reference}:snapshot-admission:guc-forged", + ), + ) + connection.rollback() + + def test_hard_close_without_closing_journal_still_has_snapshot_authority(self) -> None: + """A zero-net-income period still hard-closes when no period-closing journal is needed.""" + self.case.ledger.reverse( + self.posting_receipt.journal_reference, + date(2026, 8, 31), + "billing_correction", + self.case.policy, + reversal_idempotency_key=( + f"{self.case.policy.tenant_reference}:snapshot-admission:zero-income" + ), + ) + receipt = self.case.ledger.close_fiscal_period( + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + "2026-08", + "KRW", + period_status_code="hard_closed", + idempotency_key=f"{self.case.policy.tenant_reference}:snapshot-admission:zero-hard", + ) + self.assertEqual(receipt.period_status_code, "hard_closed") + with psycopg.connect(posting.DATABASE_URL) as connection: + closing_journal_count = connection.execute( + """ + SELECT count(*) + FROM accounting_core.general_journal + WHERE tenant_account_id = %s + AND journal_reference LIKE 'urn:cwl:accounting:general_journal:period_closing:%%' + """, + (self.case.tenant_id,), + ).fetchone()[0] + self.assertEqual(closing_journal_count, 0) + + def test_governed_hard_close_still_creates_one_snapshot(self) -> None: + """The purpose-limited period-closing path remains the sole admitted writer.""" + receipt = self.case.ledger.close_fiscal_period( + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + "2026-08", + "KRW", + period_status_code="hard_closed", + idempotency_key=f"{self.case.policy.tenant_reference}:snapshot-admission:hard", + ) + self.assertEqual(receipt.period_status_code, "hard_closed") + with psycopg.connect(posting.DATABASE_URL) as connection: + _, accounting_book_id, fiscal_period_id = self._scope(connection) + count = connection.execute( + """ + SELECT count(*) + FROM accounting_reporting.trial_balance_snapshot + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + (self.case.tenant_id, accounting_book_id, fiscal_period_id), + ).fetchone()[0] + self.assertEqual(count, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_postgres_trial_balance_snapshot_commit_pair_red.py b/tests/test_postgres_trial_balance_snapshot_commit_pair_red.py new file mode 100644 index 00000000..8dd47fd6 --- /dev/null +++ b/tests/test_postgres_trial_balance_snapshot_commit_pair_red.py @@ -0,0 +1,149 @@ +"""Real PostgreSQL RED/GREEN for commit-time hard-close snapshot pairing.""" + +from __future__ import annotations + +import unittest + +import psycopg + +from tests import test_postgres_posting as posting + + +class TrialBalanceSnapshotCommitPairPostgresTests(unittest.TestCase): + """A retained snapshot may commit only with the matching hard-closed book-period fact.""" + + @classmethod + def setUpClass(cls) -> None: + """Install the complete current accounting migration chain.""" + posting.PostgresPostingTests.setUpClass() + + def setUp(self) -> None: + """Create one governed book-period and leave it soft-closed.""" + self.case = posting.PostgresPostingTests("setUp") + self.case.setUp() + self.addCleanup(self.case.doCleanups) + self.addCleanup(self.case.tearDown) + self.case.ledger.close_fiscal_period( + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + "2026-08", + "KRW", + period_status_code="soft_closed", + idempotency_key=f"{self.case.policy.tenant_reference}:snapshot-pair:soft", + ) + + def _scope(self, connection: psycopg.Connection[object]) -> tuple[object, object, object]: + row = connection.execute( + """ + SELECT legal_entity_record.legal_entity_id, + accounting_book.accounting_book_id, + fiscal_period.fiscal_period_id + FROM accounting_core.legal_entity_record + JOIN accounting_core.accounting_book + ON accounting_book.tenant_account_id = legal_entity_record.tenant_account_id + AND accounting_book.legal_entity_id = legal_entity_record.legal_entity_id + JOIN accounting_core.accounting_book_period_control + ON accounting_book_period_control.tenant_account_id = accounting_book.tenant_account_id + AND accounting_book_period_control.accounting_book_id = accounting_book.accounting_book_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id = accounting_book_period_control.tenant_account_id + AND fiscal_period.fiscal_period_id = accounting_book_period_control.fiscal_period_id + WHERE legal_entity_record.tenant_account_id = %s + AND legal_entity_record.legal_entity_code = %s + AND accounting_book.book_name = %s + AND fiscal_period.period_code = '2026-08' + """, + ( + self.case.tenant_id, + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + ), + ).fetchone() + self.assertIsNotNone(row) + assert row is not None + return row + + def test_soft_closed_snapshot_cannot_commit_without_hard_close_pair(self) -> None: + """The closing capability cannot retain a snapshot while book authority stays soft-closed.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + legal_entity_id, accounting_book_id, fiscal_period_id = self._scope(connection) + connection.execute( + "SELECT pg_advisory_xact_lock(hashtext(%s), hashtext(%s))", + ( + self.case.policy.tenant_reference, + f"period:{accounting_book_id}:2026-08", + ), + ) + connection.execute( + """ + INSERT INTO accounting_reporting.trial_balance_snapshot ( + tenant_account_id, + legal_entity_id, + accounting_book_id, + fiscal_period_id, + snapshot_currency_code, + source_journal_count, + source_payload_hash, + close_idempotency_key + ) + VALUES (%s, %s, %s, %s, 'KRW', 0, %s, %s) + """, + ( + self.case.tenant_id, + legal_entity_id, + accounting_book_id, + fiscal_period_id, + "sha256:" + "7" * 64, + f"{self.case.policy.tenant_reference}:snapshot-pair:forged", + ), + ) + + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "trial_balance_snapshot_hard_close_pair_required", + ): + connection.commit() + connection.rollback() + + with psycopg.connect(posting.DATABASE_URL) as connection: + snapshot_count = connection.execute( + """ + SELECT count(*) + FROM accounting_reporting.trial_balance_snapshot + WHERE tenant_account_id = %s + AND accounting_book_id = ( + SELECT accounting_book_id + FROM accounting_core.accounting_book + WHERE tenant_account_id = %s + AND book_name = %s + ) + """, + ( + self.case.tenant_id, + self.case.tenant_id, + self.case.policy.accounting_book_reference, + ), + ).fetchone()[0] + period_status = connection.execute( + """ + SELECT accounting_book_period_control.period_status_code + FROM accounting_core.accounting_book_period_control + JOIN accounting_core.accounting_book + ON accounting_book.tenant_account_id = accounting_book_period_control.tenant_account_id + AND accounting_book.accounting_book_id = accounting_book_period_control.accounting_book_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id = accounting_book_period_control.tenant_account_id + AND fiscal_period.fiscal_period_id = accounting_book_period_control.fiscal_period_id + WHERE accounting_book_period_control.tenant_account_id = %s + AND accounting_book.book_name = %s + AND fiscal_period.period_code = '2026-08' + """, + (self.case.tenant_id, self.case.policy.accounting_book_reference), + ).fetchone()[0] + + self.assertEqual(snapshot_count, 0) + self.assertEqual(period_status, "soft_closed") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_postgres_trial_balance_snapshot_concurrency_red.py b/tests/test_postgres_trial_balance_snapshot_concurrency_red.py new file mode 100644 index 00000000..58e1ae1d --- /dev/null +++ b/tests/test_postgres_trial_balance_snapshot_concurrency_red.py @@ -0,0 +1,161 @@ +"""Real PostgreSQL concurrency RED for one hard-close snapshot population per book-period.""" + +from __future__ import annotations + +import unittest + +import psycopg + +from tests import test_postgres_posting as posting + + +class TrialBalanceSnapshotConcurrencyPostgresTests(unittest.TestCase): + """Keep the one-population invariant valid across stale repeatable-read snapshots.""" + + @classmethod + def setUpClass(cls) -> None: + """Install the complete production migration chain used by posting tests.""" + posting.PostgresPostingTests.setUpClass() + + def setUp(self) -> None: + """Create one posted book-period and leave it soft-closed without a snapshot.""" + self.case = posting.PostgresPostingTests("setUp") + self.case.setUp() + self.addCleanup(self.case.doCleanups) + self.addCleanup(self.case.tearDown) + self.case.ledger.post(self.case._two_line_proposal(), self.case.policy) + self.case.ledger.close_fiscal_period( + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + "2026-08", + "KRW", + period_status_code="soft_closed", + idempotency_key=f"{self.case.policy.tenant_reference}:snapshot-race:soft", + ) + with psycopg.connect(posting.DATABASE_URL) as connection: + scope = connection.execute( + """ + SELECT legal_entity_record.legal_entity_id, + accounting_book.accounting_book_id, + accounting_book_period_control.fiscal_period_id + FROM accounting_core.legal_entity_record + JOIN accounting_core.accounting_book + ON accounting_book.tenant_account_id = legal_entity_record.tenant_account_id + AND accounting_book.legal_entity_id = legal_entity_record.legal_entity_id + JOIN accounting_core.accounting_book_period_control + ON accounting_book_period_control.tenant_account_id + = accounting_book.tenant_account_id + AND accounting_book_period_control.accounting_book_id + = accounting_book.accounting_book_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id + = accounting_book_period_control.tenant_account_id + AND fiscal_period.fiscal_period_id + = accounting_book_period_control.fiscal_period_id + WHERE legal_entity_record.tenant_account_id = %s + AND legal_entity_record.legal_entity_code = %s + AND accounting_book.book_name = %s + AND fiscal_period.period_code = '2026-08' + """, + ( + self.case.tenant_id, + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + ), + ).fetchone() + self.assertIsNotNone(scope) + assert scope is not None + self.legal_entity_id, self.accounting_book_id, self.fiscal_period_id = scope + + def _insert_snapshot( + self, + connection: psycopg.Connection[object], + *, + generated_at: str, + payload_digit: str, + command_suffix: str, + ) -> None: + """Insert one purpose-limited pre-close population candidate for the exact scope.""" + connection.execute( + "SELECT pg_advisory_xact_lock(hashtext(%s), hashtext(%s))", + ( + self.case.policy.tenant_reference, + f"period:{self.accounting_book_id}:2026-08", + ), + ) + connection.execute( + """ + INSERT INTO accounting_reporting.trial_balance_snapshot ( + tenant_account_id, + legal_entity_id, + accounting_book_id, + fiscal_period_id, + snapshot_currency_code, + snapshot_generated_at, + source_journal_count, + source_payload_hash, + close_idempotency_key + ) + VALUES (%s, %s, %s, %s, 'KRW', %s, 0, %s, %s) + """, + ( + self.case.tenant_id, + self.legal_entity_id, + self.accounting_book_id, + self.fiscal_period_id, + generated_at, + "sha256:" + payload_digit * 64, + f"{self.case.policy.tenant_reference}:snapshot-race:{command_suffix}", + ), + ) + + def test_stale_repeatable_read_cannot_admit_second_snapshot_population(self) -> None: + """A fixed pre-commit snapshot must not race around the one-population invariant.""" + stale = psycopg.connect(posting.DATABASE_URL) + writer = psycopg.connect(posting.DATABASE_URL) + self.addCleanup(stale.close) + self.addCleanup(writer.close) + try: + stale.execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ") + authority = stale.execute( + """ + SELECT period_status_code + FROM accounting_core.accounting_book_period_control + WHERE tenant_account_id = %s + AND accounting_book_id = %s + AND fiscal_period_id = %s + """, + ( + self.case.tenant_id, + self.accounting_book_id, + self.fiscal_period_id, + ), + ).fetchone() + self.assertEqual(authority, ("soft_closed",)) + + self._insert_snapshot( + writer, + generated_at="2098-01-01T00:00:00Z", + payload_digit="6", + command_suffix="writer", + ) + writer.commit() + + with self.assertRaises(psycopg.errors.UniqueViolation) as captured: + self._insert_snapshot( + stale, + generated_at="2099-01-01T00:00:00Z", + payload_digit="7", + command_suffix="stale", + ) + self.assertEqual( + captured.exception.diag.constraint_name, + "trial_balance_snapshot_one_population_per_book_period", + ) + finally: + stale.rollback() + writer.rollback() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_postgres_trial_balance_snapshot_currency_red.py b/tests/test_postgres_trial_balance_snapshot_currency_red.py new file mode 100644 index 00000000..4850c115 --- /dev/null +++ b/tests/test_postgres_trial_balance_snapshot_currency_red.py @@ -0,0 +1,109 @@ +"""Real PostgreSQL RED/GREEN for retained trial-balance currency scope.""" + +from __future__ import annotations + +import unittest + +import psycopg + +from tests import test_postgres_posting as posting + + +class TrialBalanceSnapshotCurrencyPostgresTests(unittest.TestCase): + """Bind retained hard-close currency to the authoritative accounting book.""" + + @classmethod + def setUpClass(cls) -> None: + """Install the complete current accounting migration chain.""" + posting.PostgresPostingTests.setUpClass() + + def setUp(self) -> None: + """Leave one balanced accounting book soft-closed for direct DB admission.""" + self.case = posting.PostgresPostingTests("setUp") + self.case.setUp() + self.addCleanup(self.case.doCleanups) + self.addCleanup(self.case.tearDown) + self.case.ledger.post(self.case._two_line_proposal(), self.case.policy) + self.case.ledger.close_fiscal_period( + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + "2026-08", + "KRW", + period_status_code="soft_closed", + idempotency_key=f"{self.case.policy.tenant_reference}:snapshot-currency:soft", + ) + + def test_snapshot_header_rejects_currency_different_from_accounting_book(self) -> None: + """A retained snapshot cannot relabel the accounting book's reporting currency.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + scope = connection.execute( + """ + SELECT legal_entity_record.legal_entity_id, + accounting_book.accounting_book_id, + fiscal_period.fiscal_period_id, + accounting_book.reporting_currency_code + FROM accounting_core.legal_entity_record + JOIN accounting_core.accounting_book + ON accounting_book.tenant_account_id = legal_entity_record.tenant_account_id + AND accounting_book.legal_entity_id = legal_entity_record.legal_entity_id + JOIN accounting_core.accounting_book_period_control + ON accounting_book_period_control.tenant_account_id = accounting_book.tenant_account_id + AND accounting_book_period_control.accounting_book_id = accounting_book.accounting_book_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id = accounting_book_period_control.tenant_account_id + AND fiscal_period.fiscal_period_id = accounting_book_period_control.fiscal_period_id + WHERE legal_entity_record.tenant_account_id = %s + AND legal_entity_record.legal_entity_code = %s + AND accounting_book.book_name = %s + AND fiscal_period.period_code = '2026-08' + """, + ( + self.case.tenant_id, + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + ), + ).fetchone() + self.assertIsNotNone(scope) + assert scope is not None + legal_entity_id, accounting_book_id, fiscal_period_id, book_currency = scope + wrong_currency = "USD" if book_currency != "USD" else "JPY" + connection.execute( + "SELECT pg_advisory_xact_lock(hashtext(%s), hashtext(%s))", + ( + self.case.policy.tenant_reference, + f"period:{accounting_book_id}:2026-08", + ), + ) + + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "trial_balance_snapshot_currency_mismatch", + ): + connection.execute( + """ + INSERT INTO accounting_reporting.trial_balance_snapshot ( + tenant_account_id, + legal_entity_id, + accounting_book_id, + fiscal_period_id, + snapshot_currency_code, + source_journal_count, + source_payload_hash, + close_idempotency_key + ) + VALUES (%s, %s, %s, %s, %s, 0, %s, %s) + """, + ( + self.case.tenant_id, + legal_entity_id, + accounting_book_id, + fiscal_period_id, + wrong_currency, + "sha256:" + "c" * 64, + f"{self.case.policy.tenant_reference}:snapshot-currency:wrong", + ), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_postgres_trial_balance_snapshot_immutability_red.py b/tests/test_postgres_trial_balance_snapshot_immutability_red.py new file mode 100644 index 00000000..c5b9bb8c --- /dev/null +++ b/tests/test_postgres_trial_balance_snapshot_immutability_red.py @@ -0,0 +1,368 @@ +"""Real PostgreSQL RED/GREEN for immutable hard-close trial-balance evidence.""" + +from __future__ import annotations + +import unittest +import uuid + +import psycopg + +from tests import test_postgres_posting as posting + + +class TrialBalanceSnapshotImmutabilityPostgresTests(unittest.TestCase): + """Keep a hard-close snapshot and its line population immutable after commit.""" + + @classmethod + def setUpClass(cls) -> None: + """Install the complete current accounting migration chain.""" + posting.PostgresPostingTests.setUpClass() + + def setUp(self) -> None: + """Create one posted population and close it through the production command.""" + self.case = posting.PostgresPostingTests("setUp") + self.case.setUp() + self.addCleanup(self.case.doCleanups) + self.addCleanup(self.case.tearDown) + + self.case.ledger.post(self.case._two_line_proposal(), self.case.policy) + self.case.ledger.close_fiscal_period( + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + "2026-08", + "KRW", + period_status_code="soft_closed", + idempotency_key=f"{self.case.policy.tenant_reference}:snapshot-freeze:soft", + ) + hard_close = self.case.ledger.close_fiscal_period( + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + "2026-08", + "KRW", + period_status_code="hard_closed", + idempotency_key=f"{self.case.policy.tenant_reference}:snapshot-freeze:hard", + ) + self.assertEqual(hard_close.period_status_code, "hard_closed") + + with psycopg.connect(posting.DATABASE_URL) as connection: + snapshot_row = connection.execute( + """ + SELECT trial_balance_snapshot_id, accounting_book_id + FROM accounting_reporting.trial_balance_snapshot + WHERE tenant_account_id = %s + ORDER BY snapshot_generated_at DESC, trial_balance_snapshot_id DESC + LIMIT 1 + """, + (self.case.tenant_id,), + ).fetchone() + self.assertIsNotNone(snapshot_row) + assert snapshot_row is not None + self.snapshot_id = snapshot_row[0] + self.accounting_book_id = snapshot_row[1] + line_row = connection.execute( + """ + SELECT trial_balance_line_id + FROM accounting_reporting.trial_balance_line + WHERE tenant_account_id = %s + AND trial_balance_snapshot_id = %s + ORDER BY trial_balance_line_id + LIMIT 1 + """, + (self.case.tenant_id, self.snapshot_id), + ).fetchone() + self.assertIsNotNone(line_row) + assert line_row is not None + self.line_id = line_row[0] + + def test_hard_close_snapshot_header_cannot_be_rewritten(self) -> None: + """A committed close snapshot cannot later point at different source evidence.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "trial_balance_snapshot_immutable", + ): + connection.execute( + """ + UPDATE accounting_reporting.trial_balance_snapshot + SET source_payload_hash = %s + WHERE tenant_account_id = %s + AND trial_balance_snapshot_id = %s + """, + ("sha256:" + "9" * 64, self.case.tenant_id, self.snapshot_id), + ) + connection.rollback() + + def test_hard_close_snapshot_header_cannot_be_deleted(self) -> None: + """A committed close snapshot cannot be removed after it becomes evidence.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "trial_balance_snapshot_immutable", + ): + connection.execute( + """ + DELETE FROM accounting_reporting.trial_balance_snapshot + WHERE tenant_account_id = %s + AND trial_balance_snapshot_id = %s + """, + (self.case.tenant_id, self.snapshot_id), + ) + connection.rollback() + + def test_hard_close_cannot_gain_a_new_snapshot_header(self) -> None: + """A later empty snapshot cannot become the newest retained close evidence.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "trial_balance_snapshot_immutable", + ): + connection.execute( + """ + INSERT INTO accounting_reporting.trial_balance_snapshot ( + tenant_account_id, + legal_entity_id, + accounting_book_id, + fiscal_period_id, + snapshot_currency_code, + source_journal_count, + source_payload_hash, + close_idempotency_key + ) + SELECT tenant_account_id, + legal_entity_id, + accounting_book_id, + fiscal_period_id, + snapshot_currency_code, + source_journal_count, + %s, + %s + FROM accounting_reporting.trial_balance_snapshot + WHERE tenant_account_id = %s + AND trial_balance_snapshot_id = %s + """, + ( + "sha256:" + "8" * 64, + f"{self.case.policy.tenant_reference}:snapshot-freeze:forged", + self.case.tenant_id, + self.snapshot_id, + ), + ) + connection.rollback() + + def test_hard_close_snapshot_line_cannot_be_rewritten(self) -> None: + """A retained account balance cannot be changed after hard close.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "trial_balance_snapshot_immutable", + ): + connection.execute( + """ + UPDATE accounting_reporting.trial_balance_line + SET net_balance_amount = net_balance_amount + 1 + WHERE tenant_account_id = %s + AND trial_balance_line_id = %s + """, + (self.case.tenant_id, self.line_id), + ) + connection.rollback() + + def test_hard_close_snapshot_line_cannot_be_deleted(self) -> None: + """A retained account balance cannot be removed after hard close.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "trial_balance_snapshot_immutable", + ): + connection.execute( + """ + DELETE FROM accounting_reporting.trial_balance_line + WHERE tenant_account_id = %s + AND trial_balance_line_id = %s + """, + (self.case.tenant_id, self.line_id), + ) + connection.rollback() + + def test_hard_close_snapshot_population_cannot_be_extended(self) -> None: + """A new valid chart account cannot be appended to an already closed snapshot.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + chart_account_id = connection.execute( + """ + INSERT INTO accounting_core.chart_account ( + tenant_account_id, + accounting_book_id, + chart_account_code, + account_name, + normal_balance_code, + valid_from, + account_class_code + ) + VALUES (%s, %s, %s, 'Post-close mutation probe', 'debit', %s, 'asset') + RETURNING chart_account_id + """, + ( + self.case.tenant_id, + self.accounting_book_id, + f"99{uuid.uuid4().hex[:10]}", + posting.VALID_FROM, + ), + ).fetchone()[0] + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "trial_balance_snapshot_immutable", + ): + connection.execute( + """ + INSERT INTO accounting_reporting.trial_balance_line ( + tenant_account_id, + trial_balance_snapshot_id, + chart_account_id, + debit_total_amount, + credit_total_amount, + net_balance_amount + ) + VALUES (%s, %s, %s, 0, 0, 0) + """, + (self.case.tenant_id, self.snapshot_id, chart_account_id), + ) + connection.rollback() + + +class TrialBalanceSnapshotPreCloseAuthorityPostgresTests(unittest.TestCase): + """Fail hard close when retained snapshot evidence already occupies the book-period.""" + + @classmethod + def setUpClass(cls) -> None: + """Install the same production migration chain used by posting integration tests.""" + posting.PostgresPostingTests.setUpClass() + + def setUp(self) -> None: + """Leave one posted book-period soft-closed before the hard-close authority step.""" + self.case = posting.PostgresPostingTests("setUp") + self.case.setUp() + self.addCleanup(self.case.doCleanups) + self.addCleanup(self.case.tearDown) + self.case.ledger.post(self.case._two_line_proposal(), self.case.policy) + self.case.ledger.close_fiscal_period( + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + "2026-08", + "KRW", + period_status_code="soft_closed", + idempotency_key=f"{self.case.policy.tenant_reference}:snapshot-preclose:soft", + ) + + def test_preexisting_snapshot_from_closing_capability_cannot_become_hard_close_authority(self) -> None: + """A purpose-limited pre-close row must make hard close fail closed, not become authority.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + scope = connection.execute( + """ + SELECT legal_entity_record.legal_entity_id, + accounting_book.accounting_book_id, + fiscal_period.fiscal_period_id + FROM accounting_core.legal_entity_record + JOIN accounting_core.accounting_book + ON accounting_book.tenant_account_id = legal_entity_record.tenant_account_id + AND accounting_book.legal_entity_id = legal_entity_record.legal_entity_id + JOIN accounting_core.accounting_book_period_control + ON accounting_book_period_control.tenant_account_id + = accounting_book.tenant_account_id + AND accounting_book_period_control.accounting_book_id + = accounting_book.accounting_book_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id + = accounting_book_period_control.tenant_account_id + AND fiscal_period.fiscal_period_id + = accounting_book_period_control.fiscal_period_id + WHERE legal_entity_record.tenant_account_id = %s + AND legal_entity_record.legal_entity_code = %s + AND accounting_book.book_name = %s + AND fiscal_period.period_code = '2026-08' + """, + ( + self.case.tenant_id, + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + ), + ).fetchone() + self.assertIsNotNone(scope) + assert scope is not None + legal_entity_id, accounting_book_id, fiscal_period_id = scope + connection.execute( + "SELECT pg_advisory_xact_lock(hashtext(%s), hashtext(%s))", + ( + self.case.policy.tenant_reference, + f"period:{accounting_book_id}:2026-08", + ), + ) + connection.execute( + """ + INSERT INTO accounting_reporting.trial_balance_snapshot ( + tenant_account_id, + legal_entity_id, + accounting_book_id, + fiscal_period_id, + snapshot_currency_code, + snapshot_generated_at, + source_journal_count, + source_payload_hash, + close_idempotency_key + ) + VALUES (%s, %s, %s, %s, 'KRW', '2099-01-01T00:00:00Z', 0, %s, %s) + """, + ( + self.case.tenant_id, + legal_entity_id, + accounting_book_id, + fiscal_period_id, + "sha256:" + "7" * 64, + f"{self.case.policy.tenant_reference}:snapshot-preclose:forged", + ), + ) + connection.commit() + + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "trial_balance_snapshot_population_conflict", + ): + self.case.ledger.close_fiscal_period( + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + "2026-08", + "KRW", + period_status_code="hard_closed", + idempotency_key=f"{self.case.policy.tenant_reference}:snapshot-preclose:hard", + ) + + with psycopg.connect(posting.DATABASE_URL) as connection: + period_state, snapshot_count = connection.execute( + """ + SELECT accounting_book_period_control.period_status_code, + ( + SELECT count(*) + FROM accounting_reporting.trial_balance_snapshot + WHERE trial_balance_snapshot.tenant_account_id = %s + AND trial_balance_snapshot.accounting_book_id = %s + AND trial_balance_snapshot.fiscal_period_id = %s + ) + FROM accounting_core.accounting_book_period_control + WHERE accounting_book_period_control.tenant_account_id = %s + AND accounting_book_period_control.accounting_book_id = %s + AND accounting_book_period_control.fiscal_period_id = %s + """, + ( + self.case.tenant_id, + accounting_book_id, + fiscal_period_id, + self.case.tenant_id, + accounting_book_id, + fiscal_period_id, + ), + ).fetchone() + self.assertEqual(period_state, "soft_closed") + self.assertEqual(snapshot_count, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_postgres_trial_balance_snapshot_scope_red.py b/tests/test_postgres_trial_balance_snapshot_scope_red.py new file mode 100644 index 00000000..4566fbc6 --- /dev/null +++ b/tests/test_postgres_trial_balance_snapshot_scope_red.py @@ -0,0 +1,279 @@ +"""Real PostgreSQL RED/GREEN for retained trial-balance evidence scope integrity.""" + +from __future__ import annotations + +import unittest + +import psycopg + +from tests import test_postgres_posting as posting + + +class TrialBalanceSnapshotScopePostgresTests(unittest.TestCase): + """Keep retained snapshot headers and lines inside one accounting-book scope.""" + + @classmethod + def setUpClass(cls) -> None: + """Install the complete current accounting migration chain.""" + posting.PostgresPostingTests.setUpClass() + + def setUp(self) -> None: + """Leave one balanced book-period soft-closed for controlled snapshot admission.""" + self.case = posting.PostgresPostingTests("setUp") + self.case.setUp() + self.addCleanup(self.case.doCleanups) + self.addCleanup(self.case.tearDown) + self.case.ledger.post(self.case._two_line_proposal(), self.case.policy) + self.case.ledger.close_fiscal_period( + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + "2026-08", + "KRW", + period_status_code="soft_closed", + idempotency_key=f"{self.case.policy.tenant_reference}:snapshot-scope:soft", + ) + + def _scope(self, connection: psycopg.Connection[object]) -> tuple[object, object, object]: + row = connection.execute( + """ + SELECT legal_entity_record.legal_entity_id, + accounting_book.accounting_book_id, + fiscal_period.fiscal_period_id + FROM accounting_core.legal_entity_record + JOIN accounting_core.accounting_book + ON accounting_book.tenant_account_id = legal_entity_record.tenant_account_id + AND accounting_book.legal_entity_id = legal_entity_record.legal_entity_id + JOIN accounting_core.accounting_book_period_control + ON accounting_book_period_control.tenant_account_id = accounting_book.tenant_account_id + AND accounting_book_period_control.accounting_book_id = accounting_book.accounting_book_id + JOIN accounting_core.fiscal_period + ON fiscal_period.tenant_account_id = accounting_book_period_control.tenant_account_id + AND fiscal_period.fiscal_period_id = accounting_book_period_control.fiscal_period_id + WHERE legal_entity_record.tenant_account_id = %s + AND legal_entity_record.legal_entity_code = %s + AND accounting_book.book_name = %s + AND fiscal_period.period_code = '2026-08' + """, + ( + self.case.tenant_id, + self.case.policy.legal_entity_reference, + self.case.policy.accounting_book_reference, + ), + ).fetchone() + self.assertIsNotNone(row) + assert row is not None + return row + + def _acquire_period_close_authority( + self, + connection: psycopg.Connection[object], + accounting_book_id: object, + ) -> None: + """Acquire the exact transaction-scoped close lock required by snapshot admission.""" + connection.execute( + "SELECT pg_advisory_xact_lock(hashtext(%s), hashtext(%s))", + ( + self.case.policy.tenant_reference, + f"period:{accounting_book_id}:2026-08", + ), + ) + + def test_snapshot_header_rejects_legal_entity_from_another_book_scope(self) -> None: + """A retained snapshot cannot pair one book with another legal entity.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + _, accounting_book_id, fiscal_period_id = self._scope(connection) + other_legal_entity_id = connection.execute( + """ + INSERT INTO accounting_core.legal_entity_record ( + tenant_account_id, + legal_entity_code, + entity_name, + functional_currency_code, + valid_from + ) + VALUES (%s, %s, 'Other scope entity', 'KRW', %s) + RETURNING legal_entity_id + """, + ( + self.case.tenant_id, + f"{self.case.policy.legal_entity_reference}:other", + posting.VALID_FROM, + ), + ).fetchone()[0] + self._acquire_period_close_authority(connection, accounting_book_id) + + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "trial_balance_snapshot_book_entity_mismatch", + ): + connection.execute( + """ + INSERT INTO accounting_reporting.trial_balance_snapshot ( + tenant_account_id, + legal_entity_id, + accounting_book_id, + fiscal_period_id, + snapshot_currency_code, + source_journal_count, + source_payload_hash, + close_idempotency_key + ) + VALUES (%s, %s, %s, %s, 'KRW', 0, %s, %s) + """, + ( + self.case.tenant_id, + other_legal_entity_id, + accounting_book_id, + fiscal_period_id, + "sha256:" + "7" * 64, + f"{self.case.policy.tenant_reference}:snapshot-scope:wrong-entity", + ), + ) + + def test_snapshot_line_rejects_chart_account_from_another_book(self) -> None: + """A retained line cannot import a chart account owned by another accounting book.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + legal_entity_id, accounting_book_id, fiscal_period_id = self._scope(connection) + other_book_id = connection.execute( + """ + INSERT INTO accounting_core.accounting_book ( + tenant_account_id, + legal_entity_id, + book_role_code, + book_name, + reporting_currency_code, + valid_from + ) + VALUES (%s, %s, 'management', %s, 'KRW', %s) + RETURNING accounting_book_id + """, + ( + self.case.tenant_id, + legal_entity_id, + f"{self.case.policy.accounting_book_reference}:other", + posting.VALID_FROM, + ), + ).fetchone()[0] + other_chart_account_id = connection.execute( + """ + INSERT INTO accounting_core.chart_account ( + tenant_account_id, + accounting_book_id, + chart_account_code, + account_name, + normal_balance_code, + valid_from + ) + VALUES (%s, %s, '990001', 'Other-book account', 'debit', %s) + RETURNING chart_account_id + """, + (self.case.tenant_id, other_book_id, posting.VALID_FROM), + ).fetchone()[0] + self._acquire_period_close_authority(connection, accounting_book_id) + snapshot_id = connection.execute( + """ + INSERT INTO accounting_reporting.trial_balance_snapshot ( + tenant_account_id, + legal_entity_id, + accounting_book_id, + fiscal_period_id, + snapshot_currency_code, + source_journal_count, + source_payload_hash, + close_idempotency_key + ) + VALUES (%s, %s, %s, %s, 'KRW', 0, %s, %s) + RETURNING trial_balance_snapshot_id + """, + ( + self.case.tenant_id, + legal_entity_id, + accounting_book_id, + fiscal_period_id, + "sha256:" + "8" * 64, + f"{self.case.policy.tenant_reference}:snapshot-scope:line", + ), + ).fetchone()[0] + + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "trial_balance_line_book_scope_mismatch", + ): + connection.execute( + """ + INSERT INTO accounting_reporting.trial_balance_line ( + tenant_account_id, + trial_balance_snapshot_id, + chart_account_id, + debit_total_amount, + credit_total_amount, + net_balance_amount + ) + VALUES (%s, %s, %s, 1, 0, 1) + """, + (self.case.tenant_id, snapshot_id, other_chart_account_id), + ) + + def test_snapshot_line_rejects_nonconserving_net_balance(self) -> None: + """Retained debit, credit, and net values must conserve exact trial-balance arithmetic.""" + with psycopg.connect(posting.DATABASE_URL) as connection: + legal_entity_id, accounting_book_id, fiscal_period_id = self._scope(connection) + chart_account_id = connection.execute( + """ + SELECT chart_account.chart_account_id + FROM accounting_core.chart_account + WHERE chart_account.tenant_account_id = %s + AND chart_account.accounting_book_id = %s + ORDER BY chart_account.chart_account_code + LIMIT 1 + """, + (self.case.tenant_id, accounting_book_id), + ).fetchone()[0] + self._acquire_period_close_authority(connection, accounting_book_id) + snapshot_id = connection.execute( + """ + INSERT INTO accounting_reporting.trial_balance_snapshot ( + tenant_account_id, + legal_entity_id, + accounting_book_id, + fiscal_period_id, + snapshot_currency_code, + source_journal_count, + source_payload_hash, + close_idempotency_key + ) + VALUES (%s, %s, %s, %s, 'KRW', 0, %s, %s) + RETURNING trial_balance_snapshot_id + """, + ( + self.case.tenant_id, + legal_entity_id, + accounting_book_id, + fiscal_period_id, + "sha256:" + "9" * 64, + f"{self.case.policy.tenant_reference}:snapshot-scope:arithmetic", + ), + ).fetchone()[0] + + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "trial_balance_line_net_balance_conservation", + ): + connection.execute( + """ + INSERT INTO accounting_reporting.trial_balance_line ( + tenant_account_id, + trial_balance_snapshot_id, + chart_account_id, + debit_total_amount, + credit_total_amount, + net_balance_amount + ) + VALUES (%s, %s, %s, 10.250000, 3.125000, 999.000000) + """, + (self.case.tenant_id, snapshot_id, chart_account_id), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_soft_close_command_evidence_pair_contract.py b/tests/test_soft_close_command_evidence_pair_contract.py new file mode 100644 index 00000000..502bafc3 --- /dev/null +++ b/tests/test_soft_close_command_evidence_pair_contract.py @@ -0,0 +1,61 @@ +"""Static contracts for the soft-close authority/evidence commit pair.""" + +from __future__ import annotations + +import ast +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +MIGRATION = ROOT / "database/migrations/0037_soft_close_command_evidence_pair.sql" +INSTALLER = ROOT / "src/accounting_information_platform/migration_install.py" + + +def test_soft_close_pair_migration_is_deferred_fail_closed_and_upgrade_safe() -> None: + """Soft-close state must commit with complete evidence without fabricating legacy facts.""" + sql = MIGRATION.read_text(encoding="utf-8") + + assert "soft_close_command_evidence_pair_legacy_preflight" in sql + assert "soft_close_command_evidence_pair_required" in sql + assert "DEFERRABLE INITIALLY DEFERRED" in sql + assert "AFTER UPDATE OF period_status_code" in sql + assert "period_control.period_status_code = 'soft_closed'" in sql + assert "period_control.soft_close_idempotency_key IS NOT NULL" in sql + assert "period_control.soft_close_source_payload_hash IS NOT NULL" in sql + assert "period_control.soft_close_source_journal_count IS NOT NULL" in sql + assert "FOR SELECT\n TO current_user\n USING (true)" in sql + assert "DROP POLICY soft_close_evidence_pair_upgrade_visibility" in sql + assert ( + "REVOKE ALL ON FUNCTION accounting_core.require_soft_close_command_evidence_pair()\n" + " FROM PUBLIC;" + ) in sql + + +def test_canonical_installer_places_soft_close_pair_after_hard_close_pair() -> None: + """Supported installs may not stop before the soft-close authority/evidence guard.""" + module = ast.parse(INSTALLER.read_text(encoding="utf-8")) + migration_names: tuple[str, ...] | None = None + + for node in ast.walk(module): + if not isinstance(node, ast.Assign): + continue + if not any( + isinstance(target, ast.Name) and target.id == "forward_migration_paths" + for target in node.targets + ): + continue + if not isinstance(node.value, ast.Tuple): + continue + names: list[str] = [] + for element in node.value.elts: + assert isinstance(element, ast.BinOp) and isinstance(element.op, ast.Div) + assert isinstance(element.right, ast.Constant) + assert isinstance(element.right.value, str) + names.append(element.right.value) + migration_names = tuple(names) + break + + assert migration_names is not None + hard_close_index = migration_names.index("0036_hard_close_trial_balance_snapshot_pair.sql") + soft_close_index = migration_names.index("0037_soft_close_command_evidence_pair.sql") + assert soft_close_index == hard_close_index + 1 diff --git a/tests/test_trial_balance_snapshot_commit_pair_contract.py b/tests/test_trial_balance_snapshot_commit_pair_contract.py new file mode 100644 index 00000000..900ffb20 --- /dev/null +++ b/tests/test_trial_balance_snapshot_commit_pair_contract.py @@ -0,0 +1,71 @@ +"""Static contract for retained-snapshot and hard-close commit pairing.""" + +from __future__ import annotations + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SNAPSHOT_TO_CLOSE_MIGRATION = ( + ROOT / "database/migrations/0035_trial_balance_snapshot_hard_close_pair.sql" +) +CLOSE_TO_SNAPSHOT_MIGRATION = ( + ROOT / "database/migrations/0036_hard_close_trial_balance_snapshot_pair.sql" +) +INSTALLER_PATH = ROOT / "src/accounting_information_platform/migration_install.py" + + +def test_snapshot_pair_guard_is_deferred_and_fail_closed() -> None: + """Snapshot admission may be temporary soft-close state, but commit may not be.""" + sql = SNAPSHOT_TO_CLOSE_MIGRATION.read_text(encoding="utf-8") + + assert "CREATE CONSTRAINT TRIGGER trial_balance_snapshot_hard_close_pair_guard" in sql + assert "DEFERRABLE INITIALLY DEFERRED" in sql + assert "AFTER INSERT" in sql + assert "period_status_value IS DISTINCT FROM 'hard_closed'" in sql + assert "trial_balance_snapshot_hard_close_pair_required" in sql + assert "SECURITY DEFINER" in sql + assert "SET search_path = pg_catalog, pg_temp" in sql + assert ( + "REVOKE ALL ON FUNCTION accounting_reporting.require_trial_balance_snapshot_hard_close_pair()" + in sql + ) + + +def test_hard_close_pair_guard_is_deferred_and_fail_closed() -> None: + """Hard-close authority may not commit unless its retained snapshot exists.""" + sql = CLOSE_TO_SNAPSHOT_MIGRATION.read_text(encoding="utf-8") + + assert "CREATE CONSTRAINT TRIGGER hard_close_trial_balance_snapshot_pair_guard" in sql + assert "DEFERRABLE INITIALLY DEFERRED" in sql + assert "AFTER UPDATE OF period_status_code" in sql + assert "NEW.period_status_code = 'hard_closed'" in sql + assert "accounting_reporting.trial_balance_snapshot" in sql + assert "hard_close_snapshot_pair_required" in sql + assert "SECURITY DEFINER" in sql + assert "SET search_path = pg_catalog, pg_temp" in sql + assert ( + "REVOKE ALL ON FUNCTION accounting_reporting.require_hard_close_trial_balance_snapshot_pair()" + in sql + ) + + +def test_pair_upgrade_refuses_preexisting_one_sided_authority() -> None: + """Migration 0036 certifies existing pair state before installing future guards.""" + sql = CLOSE_TO_SNAPSHOT_MIGRATION.read_text(encoding="utf-8") + + assert "hard_close_snapshot_pair_control_upgrade_visibility" in sql + assert "hard_close_snapshot_pair_snapshot_upgrade_visibility" in sql + assert "hard_close_snapshot_pair_legacy_preflight" in sql + assert "trial_balance_snapshot_hard_close_pair_legacy_preflight" in sql + assert "DROP POLICY hard_close_snapshot_pair_snapshot_upgrade_visibility" in sql + assert "DROP POLICY hard_close_snapshot_pair_control_upgrade_visibility" in sql + assert "DISABLE ROW LEVEL SECURITY" not in sql + + +def test_canonical_installer_cannot_stop_before_bidirectional_pair_guards() -> None: + """Every supported foundation install reaches both commit-pair migrations.""" + installer = INSTALLER_PATH.read_text(encoding="utf-8") + + assert '"0035_trial_balance_snapshot_hard_close_pair.sql"' in installer + assert '"0036_hard_close_trial_balance_snapshot_pair.sql"' in installer diff --git a/tests/test_trial_balance_snapshot_hash_chart_account_identity_red.py b/tests/test_trial_balance_snapshot_hash_chart_account_identity_red.py new file mode 100644 index 00000000..de9a074e --- /dev/null +++ b/tests/test_trial_balance_snapshot_hash_chart_account_identity_red.py @@ -0,0 +1,46 @@ +"""RED contract for chart-account Entity identity in retained snapshot provenance.""" + +from __future__ import annotations + +import unittest +from decimal import Decimal +from uuid import UUID + +from accounting_information_platform.persistence import _canonical_snapshot_hash + + +class TrialBalanceSnapshotHashChartAccountIdentityTests(unittest.TestCase): + """Bind retained trial-balance provenance to the exact chart-account Entity.""" + + def test_snapshot_hash_changes_when_only_chart_account_id_changes(self) -> None: + """Code equality must not make two different account Entities hash-identical.""" + first_account_id = UUID("00000000-0000-7000-8000-000000000101") + successor_account_id = UUID("00000000-0000-7000-8000-000000000102") + common = { + "tenant_reference": "urn:cwl:tenant_snapshot_hash_identity", + "legal_entity_reference": "urn:cwl:legal_entity:snapshot_hash_identity", + "accounting_book_reference": "urn:cwl:accounting_book:snapshot_hash_identity", + "period_code": "2026-08", + "snapshot_currency_code": "KRW", + "source_journal_count": 1, + } + + original_hash = _canonical_snapshot_hash( + **common, + lines=((first_account_id, "410100", Decimal("0"), Decimal("25000")),), + ) + successor_hash = _canonical_snapshot_hash( + **common, + lines=((successor_account_id, "410100", Decimal("0"), Decimal("25000")),), + ) + replay_hash = _canonical_snapshot_hash( + **common, + lines=((first_account_id, "410100", Decimal("0"), Decimal("25000")),), + ) + + self.assertEqual(original_hash, replay_hash) + self.assertNotEqual(original_hash, successor_hash) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_trial_balance_snapshot_immutability_contract.py b/tests/test_trial_balance_snapshot_immutability_contract.py new file mode 100644 index 00000000..a8975bca --- /dev/null +++ b/tests/test_trial_balance_snapshot_immutability_contract.py @@ -0,0 +1,280 @@ +"""Static contracts for the hard-close trial-balance snapshot immutability migrations.""" + +from __future__ import annotations + +import unittest +from pathlib import Path +from unittest.mock import patch + +from accounting_information_platform import AccountingValidationError, apply_foundation_migration + + +ROOT = Path(__file__).resolve().parents[1] +PERSISTENCE = ROOT / "src/accounting_information_platform/persistence.py" +INDEX_MIGRATION = ( + ROOT / "database/migrations/0029_trial_balance_snapshot_population_unique_index.sql" +) +IMMUTABILITY_MIGRATION = ROOT / "database/migrations/0030_trial_balance_snapshot_immutability.sql" +VALIDATION_MIGRATION = ( + ROOT / "database/migrations/0031_trial_balance_line_conservation_validation.sql" +) +JOURNAL_FENCE_MIGRATION = ( + ROOT / "database/migrations/0032_period_close_journal_population_fence.sql" +) +OPEN_PERIOD_FENCE_MIGRATION = ( + ROOT / "database/migrations/0033_open_period_journal_population_fence.sql" +) + + +class TrialBalanceSnapshotImmutabilityContractTests(unittest.TestCase): + """Keep the migration chain and database-owned serialization boundary reviewable.""" + + def test_canonical_installer_fails_closed_when_snapshot_migration_is_missing(self) -> None: + """A supported install may not stop before the complete hard-close snapshot boundary.""" + original_is_file = Path.is_file + + def is_file(path: Path) -> bool: + if path.name in { + INDEX_MIGRATION.name, + IMMUTABILITY_MIGRATION.name, + VALIDATION_MIGRATION.name, + JOURNAL_FENCE_MIGRATION.name, + OPEN_PERIOD_FENCE_MIGRATION.name, + }: + return False + return original_is_file(path) + + with patch.object(Path, "is_file", is_file): + with self.assertRaises(AccountingValidationError): + apply_foundation_migration( + "postgresql://unused", + ROOT / "database/migrations/0001_accounting_foundation.sql", + ) + + def test_population_unique_index_is_built_without_blocking_writes(self) -> None: + """The large-table uniqueness proof must be built concurrently outside a transaction.""" + index_migration = INDEX_MIGRATION.read_text(encoding="utf-8") + self.assertIn( + "CREATE UNIQUE INDEX CONCURRENTLY trial_balance_snapshot_one_population_per_book_period", + index_migration, + ) + self.assertIn( + "ON accounting_reporting.trial_balance_snapshot " + "(tenant_account_id, accounting_book_id, fiscal_period_id)", + index_migration, + ) + self.assertNotIn("BEGIN;", index_migration) + self.assertNotIn("COMMIT;", index_migration) + + immutability_migration = IMMUTABILITY_MIGRATION.read_text(encoding="utf-8") + self.assertIn( + "ADD CONSTRAINT trial_balance_snapshot_one_population_per_book_period", + immutability_migration, + ) + self.assertIn( + "UNIQUE USING INDEX trial_balance_snapshot_one_population_per_book_period", + immutability_migration, + ) + self.assertNotIn("CREATE UNIQUE INDEX", immutability_migration) + + def test_line_conservation_validation_uses_a_separate_autocommit_migration(self) -> None: + """Validation must not inherit the stronger ADD-CONSTRAINT lock until transaction commit.""" + immutability_migration = IMMUTABILITY_MIGRATION.read_text(encoding="utf-8") + validation_migration = VALIDATION_MIGRATION.read_text(encoding="utf-8") + + self.assertIn( + "ADD CONSTRAINT trial_balance_line_net_balance_conservation", + immutability_migration, + ) + self.assertIn( + "CHECK (net_balance_amount = debit_total_amount - credit_total_amount)", + immutability_migration, + ) + self.assertIn("NOT VALID", immutability_migration) + self.assertNotIn( + "VALIDATE CONSTRAINT trial_balance_line_net_balance_conservation", + immutability_migration, + ) + self.assertEqual( + validation_migration.strip(), + "ALTER TABLE accounting_reporting.trial_balance_line\n" + " VALIDATE CONSTRAINT trial_balance_line_net_balance_conservation;", + ) + self.assertNotIn("BEGIN;", validation_migration) + self.assertNotIn("COMMIT;", validation_migration) + + def test_population_guards_serialize_on_book_period_authority(self) -> None: + """Snapshot and line admission must lock the book-period state they authorize.""" + migration = IMMUTABILITY_MIGRATION.read_text(encoding="utf-8") + self.assertIn("trial_balance_snapshot_population_guard", migration) + self.assertIn("trial_balance_line_population_guard", migration) + self.assertIn("FOR UPDATE;", migration) + self.assertIn("FOR UPDATE OF accounting_book_period_control", migration) + self.assertGreaterEqual(migration.count("period_status_value = 'hard_closed'"), 2) + self.assertIn("trial_balance_snapshot_immutable", migration) + self.assertGreaterEqual(migration.count("SECURITY DEFINER"), 2) + self.assertGreaterEqual( + migration.count("SET search_path = pg_catalog, pg_temp"), + 2, + ) + self.assertIn( + "REVOKE ALL ON FUNCTION accounting_reporting.guard_trial_balance_snapshot_insert()", + migration, + ) + self.assertIn( + "REVOKE ALL ON FUNCTION accounting_reporting.guard_trial_balance_line_insert()", + migration, + ) + + def test_journal_population_fence_preserves_open_period_concurrency(self) -> None: + """Open-period posting shares a lock; only close-window journals version the close row.""" + migration = JOURNAL_FENCE_MIGRATION.read_text(encoding="utf-8") + self.assertIn("journal_population_revision bigint NOT NULL DEFAULT 0", migration) + self.assertIn("CREATE OR REPLACE FUNCTION accounting_core.guard_period_insert()", migration) + self.assertIn("IF period_status_value = 'open' THEN", migration) + self.assertIn("FOR SHARE;", migration) + self.assertIn( + "IF locked_period_status_value = 'open' THEN\n RETURN NEW;", + migration, + ) + self.assertIn( + "SET journal_population_revision = journal_population_revision + 1", + migration, + ) + self.assertIn("AND period_status_code = 'soft_closed'", migration) + self.assertLess( + migration.index("FOR SHARE;"), + migration.index("SET journal_population_revision = journal_population_revision + 1"), + ) + self.assertIn("period_state_changed_retry", migration) + self.assertIn("accounting_book_id = NEW.accounting_book_id", migration) + self.assertIn("fiscal_period_id = NEW.fiscal_period_id", migration) + self.assertIn("RETURNING period_status_code", migration) + self.assertIn("period_control_missing", migration) + self.assertIn( + "pg_has_role(session_user, 'accounting_closing_writer', 'MEMBER')", + migration, + ) + self.assertIn("SECURITY DEFINER", migration) + self.assertIn("SET search_path = pg_catalog, pg_temp", migration) + self.assertIn( + "REVOKE ALL ON FUNCTION accounting_core.guard_period_insert() FROM PUBLIC", + migration, + ) + + def test_open_period_freshness_uses_stripes_not_one_exclusive_revision_row(self) -> None: + """Open journals version one stripe while a period transition validates all stripes.""" + migration = OPEN_PERIOD_FENCE_MIGRATION.read_text(encoding="utf-8") + self.assertIn("CREATE TABLE accounting_core.period_journal_population_fence", migration) + self.assertIn("fence_slot >= 0 AND fence_slot < 64", migration) + self.assertIn("CROSS JOIN generate_series(0, 63)", migration) + self.assertIn("period_journal_population_fence_seed", migration) + self.assertIn("get_byte(uuid_send(NEW.general_journal_id), 15) % 64", migration) + self.assertIn("SET journal_population_revision = journal_population_revision + 1", migration) + self.assertIn("period_state_transition_population_fence", migration) + self.assertIn("ORDER BY period_fence.fence_slot", migration) + self.assertIn("FOR UPDATE;", migration) + self.assertIn("locked_fence_rows <> 64", migration) + self.assertIn("period_journal_population_fence_missing", migration) + self.assertIn("FOR SHARE;", migration) + self.assertNotIn( + "IF period_status_value = 'open' THEN\n UPDATE accounting_core.accounting_book_period_control", + migration, + ) + self.assertIn("ENABLE ROW LEVEL SECURITY", migration) + self.assertIn("FORCE ROW LEVEL SECURITY", migration) + self.assertIn("period_journal_population_fence_isolation", migration) + self.assertGreaterEqual(migration.count("SECURITY DEFINER"), 3) + self.assertGreaterEqual(migration.count("SET search_path = pg_catalog, pg_temp"), 3) + + def test_direct_open_hard_close_requires_exact_close_lock(self) -> None: + """A governed direct hard close is admitted; caller-set role context is not authority.""" + migration = OPEN_PERIOD_FENCE_MIGRATION.read_text(encoding="utf-8") + snapshot_start = migration.index( + "CREATE OR REPLACE FUNCTION accounting_reporting.guard_trial_balance_snapshot_insert()" + ) + snapshot_guard = migration[snapshot_start:] + self.assertIn("period_status_value NOT IN ('open', 'soft_closed')", snapshot_guard) + self.assertIn("OR NOT close_command_lock_held", snapshot_guard) + self.assertNotIn("journal_write_role_value", snapshot_guard) + self.assertIn( + "pg_has_role(session_user, 'accounting_closing_writer', 'MEMBER')", + snapshot_guard, + ) + self.assertIn("trial_balance_snapshot_authority_required", snapshot_guard) + + def test_snapshot_header_requires_purpose_limited_hard_close_authority(self) -> None: + """Capability plus the exact close-command lock is required during migration 0030 too.""" + migration = IMMUTABILITY_MIGRATION.read_text(encoding="utf-8") + snapshot_start = migration.index( + "CREATE OR REPLACE FUNCTION accounting_reporting.guard_trial_balance_snapshot_insert()" + ) + snapshot_end = migration.index( + "CREATE TRIGGER trial_balance_snapshot_population_guard", + snapshot_start, + ) + snapshot_guard = migration[snapshot_start:snapshot_end] + self.assertIn("period_status_value <> 'soft_closed'", snapshot_guard) + self.assertIn("OR NOT close_command_lock_held", snapshot_guard) + self.assertNotIn("journal_write_role_value", snapshot_guard) + self.assertIn( + "pg_has_role(session_user, 'accounting_closing_writer', 'MEMBER')", + snapshot_guard, + ) + self.assertIn("close_command_lock_held", snapshot_guard) + self.assertIn("FROM pg_catalog.pg_locks AS held_lock", snapshot_guard) + self.assertIn("held_lock.objsubid = 2", snapshot_guard) + self.assertIn("held_lock.pid = pg_backend_pid()", snapshot_guard) + self.assertIn( + "'period:' || accounting_book.accounting_book_id::text || ':' || fiscal_period.period_code", + snapshot_guard, + ) + self.assertNotIn( + "'period:' || accounting_book.book_name || ':' || fiscal_period.period_code", + snapshot_guard, + ) + self.assertIn("trial_balance_snapshot_authority_required", snapshot_guard) + + def test_close_command_and_snapshot_guard_share_resolved_book_lock_identity(self) -> None: + """The application must acquire the same resolved book-id lock inspected by PostgreSQL.""" + source = PERSISTENCE.read_text(encoding="utf-8") + close_start = source.index(" def close_fiscal_period(") + close_end = source.index(" def open_fiscal_period(", close_start) + close_source = source[close_start:close_end] + book_resolution = "book_id, reporting_currency_code = self._require_book_for_close(" + canonical_lock = 'connection, f"period:{book_id}:{period_code}"' + caller_lock = 'connection, f"period:{accounting_book_reference}:{period_code}"' + + self.assertIn(book_resolution, close_source) + self.assertIn(canonical_lock, close_source) + self.assertNotIn(caller_lock, close_source) + self.assertLess(close_source.index(book_resolution), close_source.index(canonical_lock)) + + def test_snapshot_header_system_time_is_database_owned(self) -> None: + """Even an admitted closing writer cannot select retained snapshot chronology.""" + migration = IMMUTABILITY_MIGRATION.read_text(encoding="utf-8") + self.assertIn("NEW.snapshot_generated_at := clock_timestamp();", migration) + + def test_book_period_accepts_at_most_one_snapshot_population(self) -> None: + """Visible and stale snapshots must both be unable to create a second population.""" + migration = IMMUTABILITY_MIGRATION.read_text(encoding="utf-8") + self.assertIn("trial_balance_snapshot_population_conflict", migration) + self.assertIn( + "trial_balance_snapshot.accounting_book_id = NEW.accounting_book_id", + migration, + ) + self.assertIn( + "trial_balance_snapshot.fiscal_period_id = NEW.fiscal_period_id", + migration, + ) + + def test_header_and_line_mutations_are_rejected_at_the_table_boundary(self) -> None: + """Both retained snapshot levels must reject UPDATE and DELETE before constraints drift.""" + migration = IMMUTABILITY_MIGRATION.read_text(encoding="utf-8") + self.assertIn("trial_balance_snapshot_immutable_guard", migration) + self.assertIn("trial_balance_line_immutable_guard", migration) + self.assertGreaterEqual(migration.count("BEFORE UPDATE OR DELETE"), 2) + + +if __name__ == "__main__": + unittest.main()