Check Then Act Without Guard
ID |
check_then_act_without_guard |
Severity |
high |
Remediation Complexity |
medium |
Remediation Risk |
medium |
Remediation Effort |
medium |
Family |
API6:2023 - Unrestricted Access to Sensitive Business Flows |
CWE |
CWE-367, CWE-362 |
Resource |
source |
Language |
Java, C#, JavaScript/TypeScript, Kotlin, Go, PHP, Ruby, Swift |
Description
A request reads persisted data, decides on it, and writes the same data — and nothing makes the read and the write one atomic step. Two concurrent requests both read, both pass the check, and both write: a coupon redeemed twice, a refund issued twice, a per-user limit exceeded, a name that must be unique inserted twice.
The detector follows the endpoint’s handler into the services and helpers it calls and flattens what it reaches into one ordered sequence of repository reads and writes: a check made in a helper and the write that follows it in the caller read as one sequence, with the helper’s parameters translated into the caller’s variables. A pair is reported when a read’s result, or a value computed from it, decides a condition, and a later write of the same entity is built from the same variables.
| Shape | Example |
|---|---|
State |
read a row, test a value of it, write the row back: |
Count against a limit |
count or list rows, compare against a limit, insert another: |
Existence |
require a row to be absent, then insert it: |
An existence check produces duplicate rows rather than a double spend, and its findings are lowered
to low, unless the insert it guards sets a field that carries money. Then two requests credit the
welcome balance, or charge the joining fee, twice, and the finding stays high. A field carries money
when the last word of its name is on the moneyFieldNames list of the detector’s configuration
(amount, balance, price, total, fee, and so on: unitPrice does, totalCount does not), or
when the application’s convention profile names it in moneyFields, which is how a domain’s own
word (Wallet.credits, LineItem.coste) is recognised. The finding’s money_field property says
which one it was.
The finding carries both locations — where the decision is read and where it is acted on — and the
list of guards that were looked for and not found. When the write is a stored procedure’s statement
whose own condition guards it, what two requests both write is what the procedure wrote before it:
the finding names those tables, in its written_tables property too, rather than the table checked.
Security Impact
Business rules that hold for one request at a time break under two: a single-use coupon used twice, a balance or stock taken below zero, a quota exceeded, a refund paid twice. The attack needs no vulnerability beyond sending the same request in parallel.
Example
@PostMapping("/coupons/{id}/use")
public void use(@PathVariable Long id) {
couponService.use(id);
}
public void use(Long id) {
CouponDO coupon = couponMapper.selectById(id); // read
if (!UNUSED.equals(coupon.getStatus())) { // decide
throw new IllegalStateException("already used");
}
couponMapper.updateById(new CouponDO().setId(id).setStatus(USED)); // FLAW: two requests both use it
}
C#
In ASP.NET Core the handler is followed through its injected services (a field, a property, a
constructor or primary-constructor parameter typed as an interface, into every class implementing
it). A read is the last call of an EF Core query over a DbSet (Find, FirstOrDefault,
SingleOrDefault, Any, Count, ToList, and their Async forms), a Dapper query
(QueryFirstOrDefault<T>, ExecuteScalar) or a repository method named as a read. A write is an EF
call on a set or on the context (Add, Update, Remove, ExecuteUpdate, ExecuteDelete), SQL
run through Dapper or ExecuteSqlRaw, and EF’s unit of work: an assignment to a member of an entity
that was read, made durable by the SaveChanges that follows it. That write is reported at the
assignment.
public async Task<bool> DoCheckout(CheckoutModel model)
{
foreach (var item in cartDetail)
{
var stock = await _db.Stocks.FirstOrDefaultAsync(a => a.BookId == item.BookId); // read
if (item.Quantity > stock.Quantity) // decide
throw new InvalidOperationException("not enough stock");
stock.Quantity -= item.Quantity; // two checkouts both pass the check
}
_db.SaveChanges();
}
What makes the pair atomic in C#:
-
a transaction at
IsolationLevel.Serializable(BeginTransaction(IsolationLevel.Serializable), aTransactionScopewith serializableTransactionOptions); a transaction at the default level is not a guard; -
a locking read (
FromSqlRaw("SELECT … WITH (UPDLOCK)"),FOR UPDATE), alockstatement around both, or a lock taken before the read (await semaphore.WaitAsync(),Monitor.Enter, a distributed lock); -
a concurrency token on the entity:
[ConcurrencyCheck]or[Timestamp]on a member, orIsRowVersion()/IsConcurrencyToken()inOnModelCreatingor anIEntityTypeConfiguration; -
a write that carries the condition or computes from the row:
db.Stocks.Where(s ⇒ s.Id == id && s.Quantity > 0).ExecuteUpdate(s ⇒ s.SetProperty(x ⇒ x.Quantity, x ⇒ x.Quantity - 1)), SQL with the condition in itsWHERE; -
a
catch (DbUpdateException)orcatch (DbUpdateConcurrencyException)around the write or itsSaveChanges; -
for an existence check, a unique key:
[Index(nameof(Name), IsUnique = true)],HasIndex(x ⇒ x.Name).IsUnique()orHasAlternateKey(…)in the model or its snapshot, a migration’sCreateIndex(…, unique: true)ortable.UniqueConstraint(…), or aUNIQUEkey in the repository’s.sqlDDL. An existence check on an entity outside the EF model whose table the DDL does not declare is not reported.
Not read in C#: a change made through a method of the entity (stock.Decrease(n)) rather than an
assignment, a query held in an IQueryable variable and materialized later, a generic repository
whose entity is only a type parameter (Repository<T>), an extension method or a chained call on
the path, and ?? throw as the check.
JavaScript and TypeScript
The handler is followed as destructive_operation_without_scope follows it: from the NestJS
controller method or the function a route registration serves, through this methods, providers
injected through the constructor, imports, module-level instances and factories. A read is an ORM
call on an entity — Prisma (prisma.coupon.findUnique, findFirst, count), TypeORM
(repository.findOne, findOneBy, count, exists, manager.findOne(Coupon, …)), Sequelize (Coupon.findByPk, findOne,
count), Mongoose (Coupon.findById, findOne, countDocuments) — or a raw SELECT
(prisma.$queryRaw, query(…)). A write is an ORM call on the same entity (update, create,
save, insert, delete, updateOne…), a raw INSERT, UPDATE or DELETE, or a write on the
row a read returned (coupon.used = true; await coupon.save(), await coupon.update({ … })). Keys
are the variables both calls are built from: where: { id } and where: { id: couponId } name
id and couponId. A read a helper or a repository method returns
(return this.prisma.coupon.findUnique(…)) decides where its caller tests the result.
A condition is read operand by operand, so if (!coupon || coupon.used) is both a not-found check
and a decision about the coupon’s state. A presence test is coupon, !coupon, coupon == null,
count > 0, rows.length === 0, or the read itself (if (await repo.exists(…))).
async redeem(id: string, userId: string) {
const coupon = await this.prisma.coupon.findUnique({ where: { id } }); // read
if (!coupon || coupon.status !== 'UNUSED') { // decide
throw new BadRequestException('coupon not available');
}
await this.prisma.coupon.update({ where: { id }, data: { status: 'USED', userId } }); // two requests both redeem
}
What makes the pair atomic in JavaScript:
-
a transaction at the serializable level:
prisma.$transaction(fn, { isolationLevel: 'Serializable' })orPrisma.TransactionIsolationLevel.Serializable, TypeORMmanager.transaction('SERIALIZABLE', fn), Sequelizetransaction({ isolationLevel: Transaction.ISOLATION_LEVELS.SERIALIZABLE }, fn),@Transactional({ isolationLevel: … }); an interactive transaction at the default level is not a guard; -
a locking read: TypeORM
lock: { mode: 'pessimistic_write' }, Sequelizelock: true, aforUpdate()in the read’s chain,FOR UPDATEin raw SQL; -
a lock taken before the read: async-mutex
mutex.runExclusive(…)oracquire(), redlocklock(…),acquire(…)orusing(…); -
an optimistic version: TypeORM
@VersionColumn(), MongooseoptimisticConcurrency: true, Sequelizeversion: true; -
a write that carries the condition or the arithmetic: Prisma
updateMany({ where: { id, status: 'UNUSED' }, data }), a filter that repeats a field besides the key or the owner (where: { id, lastUsedAt: token.lastUsedAt }, the optimistic guard on the value checked),data: { stock: { decrement: 1 } }, an operator in the filter ({ gt: 0 },$ne), Mongoose$incor a filter carrying the condition (updateOne({ _id: id, used: false }, …)), TypeORM or Sequelizeincrement()/decrement(), SQL with the condition in itsWHEREor computing from the row (stock = stock - 1); -
a
trywhosecatchhandles the unique violation, or a.catch()that does: PrismaP2002, TypeORMQueryFailedError, SequelizeUniqueConstraintError, MongoDB11000, MySQLER_DUP_ENTRY, PostgreSQL23505; -
for an existence check, a unique key: Prisma
@unique,@@uniqueor@@id([…])inschema.prisma, TypeORM@Unique(…),@Column({ unique: true })or@Index({ unique: true }), Sequelizeunique: trueor a unique index, Mongooseunique: trueorschema.index(…, { unique: true }), Drizzle.unique()oruniqueIndex(…), or aUNIQUEkey in the repository’s.sqlDDL. An existence check on an entity whose schema the repository does not hold is not reported.
A state pair is a read, a decision on that row, and a write of the same row — identified by the
result (save(coupon), where: { id: coupon.id }) or by the key the read used. Not a state pair: a
write to another row that only shares a variable with the read.
Not read in JavaScript: a query builder’s reads and writes (knex, Kysely, Drizzle, TypeORM’s
createQueryBuilder), a write through a method of the row (coupon.redeem()), ?? throw as the
check, module-level state a handler writes (let current = …), which is memory rather than
persisted data, and every call destructive_operation_without_scope cannot follow.
Kotlin
The handler is followed as destructive_operation_without_scope follows it: from a Ktor route
lambda or a named controller method, through constructor properties, route-function parameters,
injected locals, objects, companions and top-level functions. A read or a write is a call on a
Spring Data repository declared over an entity (couponRepository.findById(id), save(coupon),
countByUserId(userId), existsByCode(code)), an Exposed call on a table
(Coupons.select { Coupons.id eq id }.singleOrNull(), Coupons.selectAll().where { … }.count(),
Coupons.update({ Coupons.id eq id }) { … }, Coupons.insert { … }) or on a DAO class
(CouponDAO.findById(id), CouponDAO.find { … }.firstOrNull(), CouponDAO.new { … }), or SQL run
through JdbcTemplate or Exposed’s exec. Assigning a property of a DAO row that was read writes it
(coupon.usageCount += 1), as does coupon.delete(); that write is reported at the assignment.
Any other function called on a row (product.decrementStock(n)) is the project’s code, read where
the handler reaches it, and not a write of that row by itself. A
condition is an if, a when subject, require(…) or check(…), an elvis that throws or returns
(?: throw NotFoundException()), a safe call that leaves when a row is there
(existing?.let { throw ConflictException() }), or the predicate of a collection call on a local.
fun use(id: Long) = transaction {
val coupon = Coupons.select { Coupons.id eq id }.single() // read
if (coupon[Coupons.stock] <= 0) throw IllegalStateException() // decide
Coupons.update({ Coupons.id eq id }) { // two requests both pass
it[stock] = coupon[Coupons.stock] - 1
}
}
What makes the pair atomic in Kotlin:
-
a serializable transaction around both: Exposed
transaction(transactionIsolation = Connection.TRANSACTION_SERIALIZABLE) { … }, Spring@Transactional(isolation = Isolation.SERIALIZABLE); -
a locking read:
.forUpdate()in the read’s chain, also on a local the read was stored in, a repository function annotated@Lock(…)or whose@QuerysaysFOR UPDATE,FOR UPDATEin SQL; -
synchronized(lock) { … },mutex.withLock { … }or@Synchronizedaround both, or a lock taken before the read:mutex.lock(),lock.tryLock(), an earlier locking read in the same flow (the order read.forUpdate()before its payments are counted), or a call asking for one (product.effectiveStock(forUpdate = true)); -
@Versionon a property of the entity; -
a write that carries the condition or computes from the row: an Exposed where-lambda testing more than equality (
Coupons.update({ (Coupons.id eq id) and (Coupons.stock greater 0) })), a body setting a column from itself (it.update(stock, stock - 1),it[stock] = stock - 1), a repository@Queryor SQL with the condition in itsWHEREor computing from the row; -
a
catch (e: DuplicateKeyException),DataIntegrityViolationExceptionorExposedSQLExceptionaround the write; -
for an existence check, a unique key:
uniqueIndex()on an Exposed column or in the table’sinit,index(isUnique = true, …),@Column(unique = true),@Table(uniqueConstraints = …), or aUNIQUEkey in the repository’s DDL or migrations. An existence check on an entity that is neither an Exposed table nor a JPA entity, and whose table the DDL does not declare, is not reported.
Not read in Kotlin: a change made through apply { … } or a method of the entity rather than an
assignment on the row, a JPA entity changed and left to dirty checking with no save, a query
builder’s reads and writes (jOOQ), a read wrapped in a lambda it is not the last statement of, and
every call destructive_operation_without_scope cannot follow.
Go
The handler is followed as destructive_operation_without_scope follows it. A read or a write is SQL
given to database/sql, sqlx or GORM (db.QueryRowContext(ctx, "SELECT status FROM coupons WHERE code
= ?", code).Scan(&status), db.GetContext(ctx, &n, "SELECT COUNT(*) …"), db.ExecContext(ctx,
"UPDATE coupons …")), a GORM chain (db.Where("code = ?", code).First(&c),
db.Model(&Claim{}).Where(…).Count(&n), db.Model(&c).Update("status", "USED"), db.Save(&c),
db.Create(&Claim{…})), or an ent builder (client.Coupon.Query().Where(coupon.Code(code)).Only(ctx),
… .Count(ctx), … .Exist(ctx), client.Coupon.UpdateOne(c).SetStatus("USED").Save(ctx),
client.Claim.Create().SetUser(u).Save(ctx)). A read’s result is what it binds (c, err := …) and
the destinations it fills (First(&c), Scan(&status)), and an error decides presence:
if err == nil { return } after First(&existing) requires the row to be absent. A condition is
about a read when the value it tests was last written by that read; err != nil || taken is about
taken.
var c models.Coupon
if err := h.db.Where("code = ?", code).First(&c).Error; err != nil { // read
return
}
if c.Status != "UNUSED" { // decide
return
}
h.db.Model(&c).Update("status", "USED") // two requests both pass
What makes the pair atomic in Go:
-
sql.TxOptions{Isolation: sql.LevelSerializable}in the function that reads; -
a locking read:
FOR UPDATEin the SQL, GORM’sClauses(clause.Locking{Strength: "UPDATE"}), ent’sForUpdate(); -
a lock taken before the read:
mu.Lock(), a semaphore’sAcquire, a distributed lock’sObtain; -
an
optimisticlock.Versionfield on the GORM model; -
a write that carries the condition or computes from the row:
UPDATE … WHERE code = ? AND status = 'UNUSED',Where("status = ?", "UNUSED")in the GORM update,gorm.Expr("stock - ?", 1), an ent predicate testing a value (coupon.StatusEQ("UNUSED"),coupon.StockGT(0)) or anAddStock(-1); -
the write’s error tested for a unique violation:
errors.Is(err, gorm.ErrDuplicatedKey),ent.IsConstraintError(err); -
for an existence check, a unique key:
gorm:"uniqueIndex"orgorm:"unique"on the model, an ent field’s or index’sUnique(), or aUNIQUEkey in the repository’s DDL. GORM and ent create the tables of their models, so a model with no unique key is reported; SQL over a table the DDL does not declare is not.
A transaction alone does not make the pair atomic: at the default isolation level both requests read before either writes.
Not read in Go: a GORM query or an ent builder built in a variable and run later, SQL built at
runtime, and every call destructive_operation_without_scope cannot follow.
PHP
The handler is followed as destructive_operation_without_scope follows it, and each method it
reaches is read on its own. A read or a write is an Eloquent chain on a model
(Wallet::where('user_id', Auth::id())→first(), Pass::where(…)→count(), Member::where(…)→exists(),
Pass::create([…])), Laravel’s query builder on a table (DB::table('wallets')→…→update([…])), a
write on a row a read returned ($wallet→save()), a Doctrine repository call
($em→getRepository(Seat::class)→count([…]), →findOneBy([…])), DQL given to createQuery, a
persist() of a new entity, or SQL a PDO statement prepares and executes
($stmt = $pdo→prepare('SELECT balance FROM wallets WHERE user_id = ?'); $stmt→execute([$id])).
Doctrine writes a row it loaded at flush(), when a setter of that row was called before it, and a
new row at the persist() that schedules it. A read’s result is the local it is assigned to and the
locals fetched from it ($balance = $stmt→fetchColumn()), and a condition decides it when its
arm ends the request: throw, return, abort(), abort_if(…).
$wallet = Wallet::where('user_id', Auth::id())->first(); // read
if ($wallet->balance < 10) { // decide
abort(409);
}
$wallet->balance = $wallet->balance - 10;
$wallet->save(); // two requests both pass
What makes the pair atomic in PHP:
-
a SERIALIZABLE isolation set in the method before the read;
-
a locking read:
lockForUpdate(),sharedLock(),FOR UPDATEin the SQL or the DQL, Doctrine’ssetLockMode(LockMode::PESSIMISTIC_WRITE); -
a lock held over both: the closure of
Cache::lock('k', 5)→block(5, function () { … }), or a lock taken before the read (a Symfony Lock’sacquire(),GET_LOCK()in SQL); -
#[ORM\Version]on the Doctrine entity; -
a write that carries the condition:
where('balance', '>=', 10)→decrement('balance', 10),UPDATE … WHERE user_id = ? AND balance >= 10,upsert(); -
the write in a
trythat catchesUniqueConstraintViolationExceptionorQueryException; -
for an existence check, a unique key:
→unique()in the Laravel migration that creates the table,[ORM\Column(unique: true)]or[ORM\UniqueConstraint]on the entity, or aUNIQUEkey in the repository’s DDL. A table no migration, mapping or DDL declares is not reported.
DB::transaction() and beginTransaction() alone do not make the pair atomic: at the default
isolation level both requests read before either writes.
Not read in PHP: a read in one method decided and written in another, a builder kept in a property, and SQL built at runtime.
Ruby
The handler is followed as destructive_operation_without_scope follows it, and each method it
reaches is read on its own. A read or a write is an ActiveRecord chain on a model
(Coupon.find_by!(code: code), Claim.where(holder: holder).count, Member.exists?(name: name),
Member.create!(name: name)), a Sequel chain on a dataset (DB[:gifts].where(code: code).first,
DB[:passes].insert(holder: holder)), a write on a row a read returned (coupon.update!(status:
'USED'), coupon.save!), or SQL handed to the connection (exec_query, exec_update,
exec_insert, Sequel’s fetch and run). A read’s result is the local it is assigned to and the
locals taken from it (made = ….first['made']), and a condition decides it when it ends the request:
return head(:conflict) unless …, Sinatra’s halt 409 if …, raise, as a modifier or as an if
block.
coupon = Coupon.find_by!(code: params[:code]) # read
return head(:conflict) unless coupon.status == 'UNUSED' # decide
coupon.update!(status: 'USED') # two requests both pass
What makes the pair atomic in Ruby:
-
a SERIALIZABLE isolation set in the method before the read:
transaction(isolation: :serializable); -
a locking read:
Coupon.lock.find_by!(…), Sequel’sfor_update,FOR UPDATEin the SQL; -
a lock held over both: the block of
Mutex#synchronize,with_lockorwith_advisory_lock, a lock taken before the read, orcoupon.lock!on the row read before it is written; -
an optimistic version on the table: a
lock_versioncolumn, the column the model’slocking_columnnames, Sequel’soptimistic_lockingplugin; -
a write that carries the condition:
Coupon.where(id: coupon.id, status: 'UNUSED').update_all(…),DB[:gifts].where(code: code, status: 'UNUSED').update(…),UPDATE … WHERE code = $1 AND status = 'UNUSED',upsert,find_or_create_by; -
a
rescueofActiveRecord::RecordNotUniqueorSequel::UniqueConstraintViolationafter the write; -
for an existence check, a unique index on the table:
t.index ["name"], unique: trueindb/schema.rb,add_index … unique: truein a migration,index :name, unique: trueorString :name, unique: truein a Sequel migration. A table no schema declares is not reported.
A transaction alone (Coupon.transaction do … end, DB.transaction do … end) does not make the pair
atomic: at the default isolation level both requests read before either writes. Nor does
validates :name, uniqueness: true: the validation is itself a read before the insert, and both
requests pass it. It is also a check of its own: a create!, a save of a new record or a << onto
an association, of a model validating a column’s uniqueness, is reported as an existence check then
an insert even when the handler writes no check, unless a unique index covers the column and its
scope: columns.
A read or a write through an association of one record (channel.shares.find_by(post_code: code),
channel.shares.create!(…), board.watchers << Watcher.new(…)) is on the model the association
names, and is keyed by the column it adds (channel_id), so a unique index over
(channel_id, post_code) covers it; held in a local (shares = channel.shares), it is read the same
way. Through a join — has_many :readers, through: :subscriptions, has_and_belongs_to_many :tags —
<< inserts the join’s row, so the index that decides is the join’s, over both keys
((channel_id, reader_id), (board_id, tag_id) in boards_tags or the join_table:).
Not read in Ruby: a read in one method decided and written in another, a relation kept in an instance variable, and SQL built at runtime.
Swift
The handler is followed as destructive_operation_without_scope follows it, and each method it
reaches is read on its own. A read or a write is a Fluent query on a model
(Voucher.query(on: db).filter(\.$code == code).first(), Claim.query(on: db).filter(\.$claimant ==
claimant).count(), Voucher.query(on: db).filter(…).set(\.$status, to: "USED").update()), a write of
a row a query returned (voucher.save(on: db)) or of a model built in place (Member(name:
name).create(on: db)), an SQLKit builder (sql.select().column("status").from("vouchers").where("code",
.equal, code).first(), sql.update("vouchers")…run(), sql.insert(into: "claims")…run()), or SQL
given to raw, keyed by the values it interpolates (\(bind: code)). A read’s result is the local it
is bound to (let row = …, guard let voucher = … else), and a condition decides it when it ends the
request: a guard … else { throw … }, or an if whose block throws or returns.
guard let voucher = try await Voucher.query(on: req.db).filter(\.$code == code).first() else { … } // read
guard voucher.status == "UNUSED" else { throw Abort(.conflict) } // decide
voucher.status = "USED"
try await voucher.save(on: req.db) // two requests both pass
What makes the pair atomic in Swift:
-
a SERIALIZABLE isolation set in SQL before the read;
-
a locking read: SQLKit’s
.for(.update),FOR UPDATEin the SQL, or a row lockedFOR UPDATEbefore Fluent reads it; -
a lock held over both: an advisory lock taken before the read (
SELECT pg_advisory_xact_lock(…)), alock()call before it, the closure of awithLock; -
a write that carries the condition:
Voucher.query(on: db).filter(\.$code == code).filter(\.$status == "UNUSED").set(…).update(), SQLKit’s.where("status", .equal, "UNUSED")on the update,UPDATE … WHERE code = $1 AND status = 'UNUSED'; -
a
catchof a constraint failure around the write:catch let error as DatabaseError where error.isConstraintFailure; -
for an existence check, a unique key on the table:
.unique(on: "name")in the migration that builds it. A table no migration builds is not reported.
A transaction alone (req.db.transaction { db in … }) does not make the pair atomic: at the default
isolation level both requests read before either writes. Fluent has no optimistic version:
@Timestamp(key: "updated_at", on: .update) is written on every save and compared with nothing.
Not read in Swift: a read in one method decided and written in another, a query kept in a property, and SQL built at runtime.
Remediation
Make the check part of the write:
int updated = afterSaleMapper.updateByIdAndStatus(id, WAIT_REFUND, new AfterSaleDO().setStatus(REFUNDING));
if (updated == 0) {
throw new IllegalStateException("already being refunded");
}
-
Put the condition in the write —
UPDATE … WHERE id = ? AND status = 'WAIT_REFUND'— and treat zero rows updated as "someone else got there first". -
Or lock what was read (
SELECT … FOR UPDATE,@Lock(PESSIMISTIC_WRITE), a distributed lock around the pair), or version the entity with@Versionso the second write fails. -
For an existence check, let a unique constraint decide and handle the violation.
Not Reported
-
A pair made atomic: a
SERIALIZABLEtransaction; a locking read (…ForUpdate,@Lock,FOR UPDATEin its query); alock()/tryLock()taken before the read, or a locking read earlier in the same flow, which serialises every request reaching the reads after it (the lock may sit in a helper the handler calls);synchronizedaround both;@Versionon the entity; a write carrying the condition or computing the row from itself (WHERE stock > 0,take_count = take_count + 1, a wrapper with.gt(…)/.apply(…)); a write inside atrythat handles a duplicate-key or integrity violation. -
An existence check covered by a unique constraint — on the JPA entity (
@Column(unique = true),@Table(uniqueConstraints = …)) or in the repository’s DDL, a JPA entity included — and one whose table the repository’s DDL does not declare, since its constraint cannot be read. In Java and Kotlin a DDL key covers a derived read (selectByName,existsByUserIdAndName) only when all its columns are among those the read tests:UNIQUE KEY (code)does not cover a check onname. -
An existence check on the primary key:
selectById(code),existsById(name), a finder on the field carrying@Idor@TableId, Django’sfilter(pk=…)or a field declaredprimary_key=True. The insert that follows collides on the key. -
An existence check on a value an atomic generator minted just before (Java and Python): Redis
INCR/increment,incrementAndGet,UUID.randomUUID(),uuid.uuid4(), called in place or in a helper that returns what it minted (String no = serialNumbers.next("DK")). No two requests check the same value. -
A write that resolves the conflict itself (Go): GORM
clause.OnConflict{…}, ent’sOnConflict(), SQLINSERT … ON CONFLICTor MySQL’sON DUPLICATE KEY UPDATE, which is atomic for whatever shape precedes it, a count against a limit included. -
A read on a branch that always returns or throws, and a write after that branch (Java and Python):
if (dryRun) { return mapper.selectByName(name) != null; },if request.method == "GET": return …exists(). No path runs both. -
A read in a helper that locks under a flag its caller hands a literal (Java and Go):
loadWallet(owner, true)whoseif (forUpdate)picks the@Lock(PESSIMISTIC_WRITE)read,if forUpdate { q = q.Clauses(clause.Locking{…}) }. The branch a literal rules out is not read: withfalse, the pair is reported. -
A check that requires a row to exist before writing another (a parent before its child), and a not-found check before an update that does not compute from the row read. A not-found check before an update that does (
if (purse == null) throw …; purse.setBalance(purse.getBalance() - amount)) is a lost update, and reported as a state pair. In Java, a value computed from the row is one built from a field of it: a value minted from the whole row (setApiKey(keys.issue(user))) and a fallback to the field as it was (setName(form.getName() != null ? form.getName() : row.getName())) compute nothing another request can lose. -
A value check followed by a new row rather than the row read, or by a delete of it.
-
An "already done" check that returns (
if (row.getBlocked()) return;) followed by setting that same field to a fixed value — a literal, or in Python an enum member (Order.Status.PENDING.value): repeating it changes nothing. -
An equality between the row and a value that is not a constant (
row.getUserId().equals(userId),user.email !== intent.email): it asserts which row this is, not what state it is in. -
A test of fields the write leaves alone, followed by a write of other fields: two requests passing it together leave it as true as one does. That holds for any test — a truthiness or null test (
if (!booking.getUser())), a comparison with a runtime value (startTime < now), a comparison with a constant (if (!DRAFT.equals(draft.getStatus())),status !== 'PENDING') — before writing a note. A write that computes from the row it read (stock = coupon.stock - 1,coupon.Stock -= 1) is reported whatever the check tested, since the second request’s value loses the first. In C# that is read from assignments on a tracked row (saved bySaveChangesor handed toUpdate) and fromExecuteUpdate’s setters; SQL run through `ExecuteSqlRawor Dapper carries no value to read, and such a write is judged by the fields it sets alone. -
Pairs of one endpoint that share their read or their write are one defect, reported once at its first pair; the finding’s
other_pairslists the rest.
Not read, and so not reported:
-
A check whose only consequence is a side effect outside the data, with no write of the field it tested:
if (!WAIT_REFUND.equals(afterSale.getStatus())) throw …; payRefundApi.create(afterSale); afterSaleMapper.updateById(new AfterSaleDO().setId(id).setPayRefundId(refundId));. Two requests both issue the refund, but the write sets a field the check did not test. -
A decision on one row that protects a write of another:
if (original.duplicate) …; duplicate.duplicate_finding = original; duplicate.save()— the pair concurrent marks can turn into a cycle is two rows, and a state pair is one.