fix(data): reject stale portfolio trigger snapshots

This commit is contained in:
2026-06-01 14:39:04 +03:00
parent 6e7a0cb493
commit f493836b77
4 changed files with 55 additions and 19 deletions
@@ -215,6 +215,9 @@ public async Task ConcurrentRequiredLinkDeletes_ShouldSerializeAndRejectInvalidP
[InlineData("portfolio_game_masters", "player_id")]
public async Task RepeatableReadConcurrentRequiredLinkDeletes_ShouldBeRejectedWithoutInvalidPublicCard(string linkTable, string linkColumn)
[Fact]
public async Task RepeatableReadDraftLinkDeleteRacingPublish_ShouldBeRejectedWithoutInvalidPublicCard()
[Theory]
[InlineData("portfolio_game_sessions")]
[InlineData("portfolio_game_masters")]
@@ -229,7 +232,7 @@ public async Task RequiredParentCascadeDelete_ShouldFailCommitForPublishedCard(s
public async Task ParentCardAndGroupCascadeDeletes_ShouldCommit()
```
The direct-delete, moved-link, and direct parent-cascade theories must expect PostgreSQL `23514` at commit. The explicit-unpublish scenario must delete the session successfully while preserving the first `published_at`. The `READ COMMITTED` concurrency scenarios must bound commits with timeouts, prove there is no deadlock or write-skew, and prove that an invalid public card cannot commit. The equivalent `REPEATABLE READ` scenario must reject both published-card transactions with `0A000`, because a stale snapshot after lock acquisition cannot safely validate the invariant. The parent-card and owning-group cascade scenarios must commit successfully.
The direct-delete, moved-link, and direct parent-cascade theories must expect PostgreSQL `23514` at commit. The explicit-unpublish scenario must delete the session successfully while preserving the first `published_at`. The `READ COMMITTED` concurrency scenarios must bound commits with timeouts, prove there is no deadlock or write-skew, and prove that an invalid public card cannot commit. The `REPEATABLE READ` scenarios must reject triggered portfolio writes with `0A000`, including draft-link deletion racing with publication, because a stale snapshot after lock acquisition cannot safely validate the invariant. The parent-card and owning-group cascade scenarios must commit successfully.
- [ ] **Step 3: Run the Task 1 tests to verify RED**
@@ -310,13 +313,7 @@ BEGIN
target_portfolio_game_id := OLD.portfolio_game_id;
END IF;
IF current_setting('transaction_isolation') <> 'read committed'
AND EXISTS (
SELECT 1
FROM portfolio_games pg
WHERE pg.id = target_portfolio_game_id
AND pg.is_public = true
) THEN
IF current_setting('transaction_isolation') <> 'read committed' THEN
RAISE EXCEPTION
'portfolio publication validation requires read committed isolation'
USING ERRCODE = '0A000';
@@ -400,7 +397,7 @@ CREATE INDEX ix_portfolio_game_reviews_pending
WHERE moderation_status = 'Pending';
```
The deferred constraint triggers retain the link-table `ON DELETE CASCADE` behavior. At transaction commit they acquire the same transaction-level advisory lock and reject a surviving published card when either required link set is empty. The intentionally global lock is appropriate for low-volume portfolio publication writes: under the application default `READ COMMITTED` isolation level it serializes validation, prevents write-skew across distinct child links, and gives multi-card transactions one lock order. PostgreSQL retains stale snapshots under `REPEATABLE READ` and `SERIALIZABLE`, so the guard rejects publication-related writes at those isolation levels with `0A000`. Child delete triggers do not lock or update the parent card. Draft edits, explicit unpublishing, and card or club cascade deletion remain valid. Normal session-deletion handlers explicitly unpublish linked cards before deleting sessions.
The deferred constraint triggers retain the link-table `ON DELETE CASCADE` behavior. At transaction commit they acquire the same transaction-level advisory lock and reject a surviving published card when either required link set is empty. The intentionally global lock is appropriate for low-volume portfolio publication writes: under the application default `READ COMMITTED` isolation level it serializes validation, prevents write-skew across distinct child links, and gives multi-card transactions one lock order. PostgreSQL retains stale snapshots under `REPEATABLE READ` and `SERIALIZABLE`, so the guard rejects every triggered portfolio write at those isolation levels with `0A000`. Child delete triggers do not lock or update the parent card. At `READ COMMITTED`, draft edits, explicit unpublishing, and card or club cascade deletion remain valid. Normal session-deletion handlers explicitly unpublish linked cards before deleting sessions.
- [ ] **Step 5: Explicitly unpublish linked cards in both session-deletion handlers**
@@ -446,7 +443,7 @@ Run:
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PortfolioMigrationTests|FullyQualifiedName~PortfolioMigrationPostgresTests|FullyQualifiedName~PortfolioSessionDeletionSourceTests|FullyQualifiedName~PortfolioSchemaGateSourceTests"
```
Expected: PASS, including PostgreSQL 17 migration application, rejected direct required-link deletes, rejected moved links and session/player cascades, successful explicit unpublish plus session delete with preserved `published_at`, bounded `READ COMMITTED` concurrent publish/delete and distinct-link deletion without deadlock, write-skew, or invalid public commit, rejected `REPEATABLE READ` published-card writes, successful parent-card and owning-group cascades, Discord identity scoping, and Compose schema gating.
Expected: PASS, including PostgreSQL 17 migration application, rejected direct required-link deletes, rejected moved links and session/player cascades, successful explicit unpublish plus session delete with preserved `published_at`, bounded `READ COMMITTED` concurrent publish/delete and distinct-link deletion without deadlock, write-skew, or invalid public commit, rejected `REPEATABLE READ` triggered writes including draft-delete versus publish races, successful parent-card and owning-group cascades, Discord identity scoping, and Compose schema gating.
- [ ] **Step 7: Commit**