Early-days hardening
Not every bug is a multi-issue saga. These five are smaller, independent, and mostly came out of the same pre-real-database-testing audit pass (dated 2026-07-21 in the tracker) that also found several of the issues covered elsewhere in this section — someone reading every write path and asking "what would this actually do" before ever pointing it at a live cluster. Grouped here because each is a complete, self-contained story, not because they share one root cause.
ISS-007: there was no way to ask "give me the non-deleted rows"
The 0.4.0 spec called for a findActive() method on @SoftDelete entities. But @SoftDelete writes
non-key columns with a TTL via UPDATE ... USING TTL — after the TTL expires, ScyllaDB removes the
column values but keeps the primary-key row as a tombstone until gc_grace_seconds passes. There
was no CQL predicate for "rows whose TTL has expired": TTL(col) exists in SELECT, but it requires
knowing which specific column to check, and returns 0 (not null) for a column with no TTL set at
all. Worse, immediately after a soft delete and before the TTL expires, the row's data is still fully
present — there was no way to distinguish a soft-deleted row from a live one at all, at any point in
the row's lifecycle.
The fix
An opt-in marker column: @SoftDelete(ttlSeconds = 86400, markerProperty = "isDeleted"), where
markerProperty names a Boolean field. Soft-delete now issues two writes instead of one: the
TTL'd UPDATE on the regular columns, and a separate, non-TTL'd UPDATE setting the marker to
true — so the marker persists after the other columns' values expire. findActive() queries
WHERE <marker> = false ALLOW FILTERING, with a WARN logged (it's a scatter-gather, same as any
other secondary-index-style query) and KandraSchemaException if called without markerProperty
configured. This is opt-in and additive — existing @SoftDelete usages without markerProperty
are unaffected.
findActive() is a full-table scan with a filter, not a partition-scoped query — recommend
@SecondaryIndex on the marker column for any table of meaningful size, and prefer ordinary
findPage/findAll plus client-side filtering when you just need "my non-deleted rows" within a
partition you already know.
ISS-011: Jakarta Bean Validation was documented but never implemented
The 0.4.0 spec mentioned auto-detecting jakarta.validation annotations (@NotNull, @Size, and
friends) on entity fields and running them before save/update. Only the custom KandraValidator<T>
hook actually existed — the documented behavior simply wasn't there.
The fix
A new kandra-jakarta module: JakartaKandraValidator<T> adapts a real jakarta.validation.Validator
into KandraValidator<T>, translating ConstraintViolations into KandraValidationErrors.
jakarta.validation-api is a compileOnly dependency — bring your own provider (Hibernate Validator,
typically) alongside it. KandraJakartaSupport.isAvailable detects whether a usable provider is
resolvable at runtime, and KandraConfig.validateJakarta<T>() registers the adapter from inside
plugin install, logging a WARN and skipping registration — not failing plugin install — if no
provider is found on the classpath:
install(Kandra) {
register(User::class)
validateJakarta<User>()
}ISS-015: deleteById had a different soft-delete story than every other delete method
KandraRepository.deleteById(vararg keyValues) built a raw LOGGED BATCH of the primary-key delete
plus lookup-table deletes and called session.execute(b) directly — it never checked
schema.isSoftDelete. Meanwhile the entity-based KandraRepository.delete(entity) and
KandraSuspendRepository.deleteById both correctly delegated to BatchEngine.delete/deleteSuspend,
which do check it.
For any @SoftDelete entity, deleteById(uuid) on the sync, blocking repository permanently
tombstoned the row — a real, immediate DELETE — while every other delete path on the identical
table correctly issued the TTL'd soft delete instead. Same table, same annotation, two call
signatures, two silently different outcomes. It also never invalidated the read-through cache, an
independent gap in the same method.
The fix
deleteById now looks up the entity first — which it already did, to find lookup rows to clean up —
and, when found, delegates to batchEngine.delete(schema, entity), the same soft-delete-aware path
every other delete method uses, instead of duplicating the batch-building logic. Cache invalidation
was added at the same time. When the entity doesn't exist, a plain hard-delete-by-key statement is
issued — harmless either way, since there's no row to have soft-deleted.
ISS-019: consistency-level settings were silently ignored on collection and counter writes
StatementBuilder.appendToCollection, removeFromCollection, and counterUpdate never called
.setConsistencyLevel(...), unlike insertPrimary/selectById, which did. @WriteConsistency on
the entity, and any explicit per-call override, were silently ignored for
append()/remove()/put()/increment()/decrement() — only the driver profile's own default
consistency applied, regardless of what the entity or the caller actually asked for.
The fix
An optional consistency: KandraConsistency? = null parameter added to appendToCollection,
removeFromCollection, and counterUpdate, resolved the same way writes already are —
per-call override, then @WriteConsistency, then the configured default — and applied via
.setConsistencyLevel(...). The same optional parameter was threaded through
KandraRepository/KandraSuspendRepository's append/remove/put/increment/decrement.
Whenever a codebase has more than one code path that's supposed to apply the same cross-cutting
setting — consistency levels, an auth check, a tenant filter — the paths will drift unless the
setting is applied in exactly one shared place all of them call through, rather than being a step
each path is separately responsible for remembering. insertPrimary had it. appendToCollection
didn't. Nothing about writing appendToCollection looked wrong in isolation; it was only wrong
relative to a sibling function it didn't share code with.
ISS-021: the error message pointed callers at a method that didn't exist
When an IN predicate targeted a column that's neither a partition key nor a @SecondaryIndex,
QueryExecutor threw KandraQueryException telling the caller to "Add allowFiltering() to the
query or use a @SecondaryIndex." No such method exists on QueryContext — the DSL's own doc
comment states plainly that Kandra does not support ALLOW FILTERING, by design. The error message
contradicted the actual design and pointed callers at code that would fail to compile if they tried
to follow it.
The fix
Reworded to match the documented design: the query would require ALLOW FILTERING, which Kandra
does not support — add a @SecondaryIndex to the column instead. No false promise of a DSL escape
hatch that was never going to exist.
Small, but worth internalizing on its own: an error message is part of your API surface, not an afterthought bolted on after the real logic is done. One that names a method that doesn't exist doesn't just fail to help — it actively sends the reader chasing something that was never real.