Skip to content

Commit ac7889b

Browse files
authored
Cancel pending review when deleting a record (#50)
## Summary - close the moderation lane when a pending record is soft-deleted - cancel pending publication reviews through the existing append-only transition table - reject the attached pending revision and clear `pending_revision_id` - backfill already-deleted records that still carry a pending review/revision, including the state caught by the live v0.5.1 `selene-lab` acceptance - retain the existing media-orphaning trigger and deletion audit/idempotency behavior - add regression coverage that deletes a real approval-required pending reply and verifies no zombie pending metadata remains ## Validation - v0.5.1 MCP parity/invariant test passes; only the two known unrelated local sign-in-trace `ECONNRESET` flakes remain in Slice 1 - `npm run check`: 0 errors / 0 warnings / 2 pre-existing hints - production Worker dry-run passed
1 parent 1210270 commit ac7889b

2 files changed

Lines changed: 119 additions & 13 deletions

File tree

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
PRAGMA foreign_keys = ON;
2+
3+
-- Repair records deleted before this migration. A deletion is terminal for the
4+
-- pending moderation lane: the review is cancelled, its pending revision is
5+
-- rejected, and the record no longer advertises a pending revision.
6+
INSERT INTO publication_review_transitions (
7+
id, review_id, decision, actor_type, actor_id, review_note, created_at
8+
)
9+
SELECT
10+
'record-delete-repair-' || pr.id,
11+
pr.id,
12+
'cancelled',
13+
rd.actor_type,
14+
rd.actor_id,
15+
'Record deleted before review completed.',
16+
rd.created_at
17+
FROM publication_reviews pr
18+
JOIN records r ON r.id = pr.record_id
19+
JOIN record_deletion_transitions rd ON rd.record_id = r.id
20+
WHERE r.deleted_at IS NOT NULL
21+
AND pr.status = 'pending';
22+
23+
UPDATE record_revisions
24+
SET state = 'rejected'
25+
WHERE state = 'pending'
26+
AND id IN (
27+
SELECT pending_revision_id
28+
FROM records
29+
WHERE deleted_at IS NOT NULL
30+
AND pending_revision_id IS NOT NULL
31+
);
32+
33+
UPDATE records
34+
SET pending_revision_id = NULL
35+
WHERE deleted_at IS NOT NULL
36+
AND pending_revision_id IS NOT NULL;
37+
38+
-- The original Slice 4 trigger made the record terminal but left an active
39+
-- moderation review attached to a deleted record. Keep deletion append-only,
40+
-- but close that moderation lane atomically through the existing review
41+
-- transition mechanism before clearing the pending revision pointer.
42+
DROP TRIGGER record_deletion_transitions_apply;
43+
44+
CREATE TRIGGER record_deletion_transitions_apply
45+
AFTER INSERT ON record_deletion_transitions
46+
BEGIN
47+
INSERT INTO publication_review_transitions (
48+
id, review_id, decision, actor_type, actor_id, review_note, created_at
49+
)
50+
SELECT
51+
NEW.id || '-review-' || pr.id,
52+
pr.id,
53+
'cancelled',
54+
NEW.actor_type,
55+
NEW.actor_id,
56+
'Record deleted before review completed.',
57+
NEW.created_at
58+
FROM publication_reviews pr
59+
WHERE pr.record_id = NEW.record_id
60+
AND pr.status = 'pending';
61+
62+
UPDATE record_revisions
63+
SET state = 'rejected'
64+
WHERE id = (
65+
SELECT pending_revision_id
66+
FROM records
67+
WHERE id = NEW.record_id
68+
)
69+
AND state = 'pending';
70+
71+
UPDATE records
72+
SET lifecycle_state = 'deleted',
73+
pending_revision_id = NULL,
74+
deleted_at = NEW.created_at,
75+
updated_at = NEW.created_at,
76+
version = version + 1
77+
WHERE id = NEW.record_id
78+
AND deleted_at IS NULL;
79+
END;

‎scripts/orbit-slice1-tests.ts‎

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1365,19 +1365,10 @@ let firstCredentialToken = '';
13651365
const deletedDirectPost = await postJson(
13661366
`/v1/mcp/grants/${encodeURIComponent(recipientGrant.grantId)}/records/${encodeURIComponent(directPostBody.record.id)}/delete`,
13671367
{ reason: 'v0.5.1 regression cleanup' },
1368-
mcpServiceHeaders('mcp-v051-delete'),
1369-
NOW + 17_130,
1368+
mcpServiceHeaders('mcp-v051-delete-direct-post'),
1369+
NOW + 17_133,
13701370
);
13711371
assert.equal(deletedDirectPost.status, 200, await deletedDirectPost.clone().text());
1372-
assert.equal((await deletedDirectPost.json() as { record: { status: string } }).record.status, 'deleted');
1373-
const deletedRecordDetail = await postJson(
1374-
`/v1/mcp/grants/${encodeURIComponent(recipientGrant.grantId)}/agent/records/${encodeURIComponent(directPostBody.record.id)}`,
1375-
{},
1376-
mcpServiceHeaders(),
1377-
NOW + 17_131,
1378-
);
1379-
assert.equal(deletedRecordDetail.status, 200, await deletedRecordDetail.clone().text());
1380-
assert.equal((await deletedRecordDetail.json() as { record: { lifecycleState: string } }).record.lifecycleState, 'deleted');
13811372

13821373
const initialUnread = await postJson(
13831374
`/v1/mcp/grants/${encodeURIComponent(recipientGrant.grantId)}/direct-messages/unread-count`,
@@ -1526,11 +1517,47 @@ let firstCredentialToken = '';
15261517
);
15271518
assert.equal(revokedRecipientGrant.status, 200, await revokedRecipientGrant.clone().text());
15281519

1520+
const pendingDeleteReply = await postJson(
1521+
`/v1/mcp/grants/${encodeURIComponent(full.grantId)}/records/${encodeURIComponent(createdPostBody.record.id)}/replies`,
1522+
{ bodyMarkdown: 'MCP v0.5.1 pending delete cleanup testi.', projectSlug: null, topicSlugs: [], mediaId: null },
1523+
mcpServiceHeaders('mcp-v051-pending-delete-create'),
1524+
NOW + 32_100,
1525+
);
1526+
assert.equal(pendingDeleteReply.status, 202, await pendingDeleteReply.clone().text());
1527+
const pendingDeleteReplyBody = await pendingDeleteReply.json() as { record: { id: string } };
1528+
1529+
const deletedPendingReply = await postJson(
1530+
`/v1/mcp/grants/${encodeURIComponent(full.grantId)}/records/${encodeURIComponent(pendingDeleteReplyBody.record.id)}/delete`,
1531+
{ reason: 'v0.5.1 regression cleanup' },
1532+
mcpServiceHeaders('mcp-v051-delete'),
1533+
NOW + 32_101,
1534+
);
1535+
assert.equal(deletedPendingReply.status, 200, await deletedPendingReply.clone().text());
1536+
assert.equal((await deletedPendingReply.json() as { record: { status: string } }).record.status, 'deleted');
1537+
const deletedRecordDetail = await postJson(
1538+
`/v1/mcp/grants/${encodeURIComponent(full.grantId)}/agent/records/${encodeURIComponent(pendingDeleteReplyBody.record.id)}`,
1539+
{},
1540+
mcpServiceHeaders(),
1541+
NOW + 32_102,
1542+
);
1543+
assert.equal(deletedRecordDetail.status, 200, await deletedRecordDetail.clone().text());
1544+
const deletedRecordDetailBody = await deletedRecordDetail.json() as {
1545+
record: {
1546+
lifecycleState: string;
1547+
pendingRevision: unknown;
1548+
latestReview: { status: string; revision: { state: string } } | null;
1549+
};
1550+
};
1551+
assert.equal(deletedRecordDetailBody.record.lifecycleState, 'deleted');
1552+
assert.equal(deletedRecordDetailBody.record.pendingRevision, null);
1553+
assert.equal(deletedRecordDetailBody.record.latestReview?.status, 'cancelled');
1554+
assert.equal(deletedRecordDetailBody.record.latestReview?.revision.state, 'rejected');
1555+
15291556
const revokedMcpWriteGrant = await postJson(
15301557
`/v1/mcp/authorizations/${encodeURIComponent(full.grantId)}/revoke`,
15311558
{},
15321559
authenticatedHeaders(sponsorCookies, true),
1533-
NOW + 17_002,
1560+
NOW + 32_103,
15341561
);
15351562
assert.equal(
15361563
revokedMcpWriteGrant.status,
@@ -1542,7 +1569,7 @@ let firstCredentialToken = '';
15421569
`/v1/mcp/grants/${encodeURIComponent(full.grantId)}/records`,
15431570
{ ...mcpWritePostBody, bodyMarkdown: 'İptalden sonra yazılamaz.' },
15441571
mcpServiceHeaders('mcp-after-revoke'),
1545-
NOW + 17_003,
1572+
NOW + 32_104,
15461573
);
15471574
assert.equal(afterRevoke.status, 401);
15481575
const afterRevokeBody = await afterRevoke.json() as { error: { code: string } };

0 commit comments

Comments
 (0)