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: if (!"UNUSED".equals(coupon.getStatus())) throw …; coupon.setStatus("USED"); mapper.updateById(coupon); — or find it and write a value computed from it, a lost update: if (purse == null) throw …; mapper.updateById(new Purse(id).setBalance(purse.getBalance() - amount));

Count against a limit

count or list rows, compare against a limit, insert another: if (mapper.selectCountByUserId(userId) >= activity.getLimit()) throw …; mapper.insert(help);

Existence

require a row to be absent, then insert it: if (mapper.selectByName(name) != null) throw …; mapper.insert(row);

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), a TransactionScope with serializable TransactionOptions); a transaction at the default level is not a guard;

  • a locking read (FromSqlRaw("SELECT …​ WITH (UPDLOCK)"), FOR UPDATE), a lock statement 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, or IsRowVersion() / IsConcurrencyToken() in OnModelCreating or an IEntityTypeConfiguration;

  • 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 its WHERE;

  • a catch (DbUpdateException) or catch (DbUpdateConcurrencyException) around the write or its SaveChanges;

  • for an existence check, a unique key: [Index(nameof(Name), IsUnique = true)], HasIndex(x ⇒ x.Name).IsUnique() or HasAlternateKey(…​) in the model or its snapshot, a migration’s CreateIndex(…​, unique: true) or table.UniqueConstraint(…​), or a UNIQUE key in the repository’s .sql DDL. 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' }) or Prisma.TransactionIsolationLevel.Serializable, TypeORM manager.transaction('SERIALIZABLE', fn), Sequelize transaction({ 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' }, Sequelize lock: true, a forUpdate() in the read’s chain, FOR UPDATE in raw SQL;

  • a lock taken before the read: async-mutex mutex.runExclusive(…) or acquire(), redlock lock(…), acquire(…) or using(…);

  • an optimistic version: TypeORM @VersionColumn(), Mongoose optimisticConcurrency: true, Sequelize version: 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 $inc or a filter carrying the condition (updateOne({ _id: id, used: false }, …)), TypeORM or Sequelize increment() / decrement(), SQL with the condition in its WHERE or computing from the row (stock = stock - 1);

  • a try whose catch handles the unique violation, or a .catch() that does: Prisma P2002, TypeORM QueryFailedError, Sequelize UniqueConstraintError, MongoDB 11000, MySQL ER_DUP_ENTRY, PostgreSQL 23505;

  • for an existence check, a unique key: Prisma @unique, @@unique or @@id([…]) in schema.prisma, TypeORM @Unique(…), @Column({ unique: true }) or @Index({ unique: true }), Sequelize unique: true or a unique index, Mongoose unique: true or schema.index(…, { unique: true }), Drizzle .unique() or uniqueIndex(…), or a UNIQUE key in the repository’s .sql DDL. 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 @Query says FOR UPDATE, FOR UPDATE in SQL;

  • synchronized(lock) { … }, mutex.withLock { … } or @Synchronized around 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));

  • @Version on 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 @Query or SQL with the condition in its WHERE or computing from the row;

  • a catch (e: DuplicateKeyException), DataIntegrityViolationException or ExposedSQLException around the write;

  • for an existence check, a unique key: uniqueIndex() on an Exposed column or in the table’s init, index(isUnique = true, …), @Column(unique = true), @Table(uniqueConstraints = …), or a UNIQUE key 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 UPDATE in the SQL, GORM’s Clauses(clause.Locking{Strength: "UPDATE"}), ent’s ForUpdate();

  • a lock taken before the read: mu.Lock(), a semaphore’s Acquire, a distributed lock’s Obtain;

  • an optimisticlock.Version field 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 an AddStock(-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" or gorm:"unique" on the model, an ent field’s or index’s Unique(), or a UNIQUE key 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 UPDATE in the SQL or the DQL, Doctrine’s setLockMode(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’s acquire(), 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 try that catches UniqueConstraintViolationException or QueryException;

  • 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 a UNIQUE key 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’s for_update, FOR UPDATE in the SQL;

  • a lock held over both: the block of Mutex#synchronize, with_lock or with_advisory_lock, a lock taken before the read, or coupon.lock! on the row read before it is written;

  • an optimistic version on the table: a lock_version column, the column the model’s locking_column names, Sequel’s optimistic_locking plugin;

  • 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 rescue of ActiveRecord::RecordNotUnique or Sequel::UniqueConstraintViolation after the write;

  • for an existence check, a unique index on the table: t.index ["name"], unique: true in db/schema.rb, add_index … unique: true in a migration, index :name, unique: true or String :name, unique: true in 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 UPDATE in the SQL, or a row locked FOR UPDATE before Fluent reads it;

  • a lock held over both: an advisory lock taken before the read (SELECT pg_advisory_xact_lock(…)), a lock() call before it, the closure of a withLock;

  • 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 catch of 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 @Version so the second write fails.

  • For an existence check, let a unique constraint decide and handle the violation.

Not Reported

  • A pair made atomic: a SERIALIZABLE transaction; a locking read (…ForUpdate, @Lock, FOR UPDATE in its query); a lock()/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); synchronized around both; @Version on 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 a try that 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 on name.

  • An existence check on the primary key: selectById(code), existsById(name), a finder on the field carrying @Id or @TableId, Django’s filter(pk=…) or a field declared primary_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’s OnConflict(), SQL INSERT … ON CONFLICT or MySQL’s ON 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) whose if (forUpdate) picks the @Lock(PESSIMISTIC_WRITE) read, if forUpdate { q = q.Clauses(clause.Locking{…}) }. The branch a literal rules out is not read: with false, 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 by SaveChanges or handed to Update) and from ExecuteUpdate’s setters; SQL run through `ExecuteSqlRaw or 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_pairs lists 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.

Configuration

The read and write prefixes, the count and existence words, the locking vocabulary, the condition calls and the schema file extensions are listed per language in the detector’s properties (checkThenAct). A new entry needs no code.

© 2026 Xygeni. All rights reserved