Skip to content

fix(isms): serialize approve/decline to prevent concurrent double-publish (CS-701)#3366

Merged
tofikwest merged 1 commit into
mainfrom
tofik/isms-approve-race-fix
Jul 7, 2026
Merged

fix(isms): serialize approve/decline to prevent concurrent double-publish (CS-701)#3366
tofikwest merged 1 commit into
mainfrom
tofik/isms-approve-race-fix

Conversation

@tofikwest

@tofikwest tofikwest commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Why

The production-deploy PR #3364 flagged a concurrent-approve race in isms.service.ts (CS-701, already on main). This PR fixes it — plus a closely-related decline race found while auditing. Base is main; it reaches release on the next deploy.

Root cause (verified)

assertPendingApprovalBy runs before db.$transaction. Under READ COMMITTED, a plain in-transaction status re-read does not serialize — the losing approval reads the still-committed needs_review. @@unique([documentId, version]) only converts the perfectly simultaneous interleaving into an error; the staggered interleaving silently creates a second published version and overwrites currentVersionId, orphaning the first.

Fix

  • approve(): takes a per-document advisory lock, then atomically claims the approval via updateMany({ where: { status: 'needs_review', approverId } }) inside the transaction, aborting when count !== 1. Exactly one caller freezes a version; losers abort before any derivation/version creation. The pre-transaction guard is kept for normal-case error messages.
  • decline(): same atomic claim, so an approve/decline race can no longer leave a declined document that still owns a live currentVersionId.
  • Renamed lockDocumentForPositionslockDocument (it now serializes approval too), updating the four register-create callers.

Audit

I ran an adversarial audit of the whole approve/publish/version/export flow (concurrency, data-integrity, and cross-org/IDOR lenses, each independently verified). Confirmed-safe: getVersionExport org-scoping, S3 key derivation (no IDOR), snapshot parsing, currentVersionId integrity, and every migrated-doc read path. The only residual is a narrow, self-healing edit-during-approve window for register update/delete — low severity, noted as a follow-up rather than plumbing advisory locks through every edit path in a production hotfix.

Testing

  • 321 API ISMS tests passing, including new approve + decline race tests; typecheck clean.

Flagged in #3364. Follows #3361 (CS-701) and #3365.

🤖 Generated with Claude Code

https://claude.ai/code/session_012CweXoSSEP89mX93u3Bdcp


Summary by cubic

Prevents double-publish by serializing ISMS approve/decline with a per-document advisory lock and an in-transaction atomic claim. Ensures CS-701 versioning keeps exactly one published version and consistent history.

  • Bug Fixes

    • approve() takes a per-document advisory lock and atomically claims the approval with updateMany(...) inside the transaction; only one caller proceeds, others abort before version creation.
    • decline() uses the same atomic claim to avoid approve/decline races that could leave a declined doc with a live currentVersionId.
  • Refactors

    • Renamed lockDocumentForPositions to lockDocument and updated register-create callers; the lock now also serializes approvals.
    • Added tests for racing approve/decline paths; kept the pre-transaction guard for clear, user-facing errors.

Written for commit 68df7f4. Summary will update on new commits.

Review in cubic

…lish (CS-701)

Flagged on the production deploy PR (#3364): assertPendingApprovalBy runs before
the transaction, so two racing approve() calls could both pass the guard.

Root cause (verified): under READ COMMITTED a plain in-transaction status re-read
does NOT serialize — the loser reads the still-committed `needs_review`. The
@@unique([documentId, version]) constraint only turns the perfectly-simultaneous
case into an error; the staggered case silently creates a second published version
and overwrites currentVersionId.

Fix:
- approve() now takes a per-document advisory lock and then claims the approval
  atomically via updateMany({ where: status='needs_review' + approverId }) inside
  the transaction, aborting when count !== 1. Exactly one caller freezes a version;
  losers abort before any derivation/version creation. The pre-transaction guard is
  kept for the normal-case specific error messages.
- decline() gets the same atomic claim, so an approve/decline race can no longer
  leave a "declined" document that still owns a live currentVersionId.
- Renamed the shared advisory-lock helper lockDocumentForPositions -> lockDocument
  (it now serializes approval too, not just register-row position allocation);
  updated the four register-create callers.

Adversarially audited the whole approve/publish/version/export flow for related
concurrency + IDOR issues; getVersionExport org-scoping, S3 keys, snapshot parsing,
currentVersionId integrity and the migration paths verified safe. The narrow
edit-during-approve window for register update/delete is low-severity/self-healing
and left as a follow-up.

Tests: 321 api ISMS tests passing (incl. new approve + decline race tests);
typecheck clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CweXoSSEP89mX93u3Bdcp
@linear

linear Bot commented Jul 7, 2026

Copy link
Copy Markdown

CS-701

@vercel

vercel Bot commented Jul 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
comp-framework-editor Ready Ready Preview, Comment Jul 7, 2026 8:16pm
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
app Skipped Skipped Jul 7, 2026 8:16pm
portal Skipped Skipped Jul 7, 2026 8:16pm

Request Review

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 8 files

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Re-trigger cubic

@tofikwest tofikwest merged commit b158bc9 into main Jul 7, 2026
11 checks passed
@tofikwest tofikwest deleted the tofik/isms-approve-race-fix branch July 7, 2026 20:21
claudfuen pushed a commit that referenced this pull request Jul 7, 2026
# [3.99.0](v3.98.1...v3.99.0) (2026-07-07)

### Bug Fixes

* **integrations:** don't persist unverified GitHub installation_id ([#3370](#3370)) ([9c6f6fa](9c6f6fa))
* **isms:** lock before writing control links so edits serialize with approve ([#3369](#3369)) ([a21f866](a21f866))
* **isms:** production-deploy review follow-ups (CS-701) ([#3365](#3365)) ([d756d4a](d756d4a)), closes [#3364](#3364)
* **isms:** serialize approve against ALL register/content edits (central lock) ([#3367](#3367)) ([5c88a3d](5c88a3d))
* **isms:** serialize approve/decline to prevent concurrent double-publish (CS-701) ([#3366](#3366)) ([b158bc9](b158bc9)), closes [#3364](#3364)

### Features

* **integrations:** add read-only GitHub App integration (CS-710) ([#3363](#3363)) ([a5a9243](a5a9243))
* **isms:** document versioning and history (CS-701) ([#3361](#3361)) ([d95dc2e](d95dc2e))
@claudfuen

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 3.99.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants