Extend the Lettuce Commands API
Implement a new Redis command (or extend an existing one) in Lettuce, following the
conventions maintainers enforce in review. The workflow is evidence-first: prove the
command's behavior on a real server before designing the Java API.
Read .agents/docs/architecture.md first for
the model behind this flow — especially that the sync/async/reactive/Kotlin command
interfaces are hand-edited source files kept in lockstep by the API consistency
test suite (see .agents/docs/api-consistency.md).
Historical caveat when reading old PRs: PRs merged before the generator removal
also edit src/test/java/io/lettuce/core/api/<Group>Commands.java — the old
template source files. Those files no longer exist; do not recreate them. The
flavor interfaces are now edited directly.
Phase 0 — Gather evidence BEFORE planning
Do all of the following before writing any plan or code:
Ask the user for the HLD. Interactively ask for the path to a markdown file
containing the High-Level Design for the command(s) (or confirm none exists). If
a path is given, read it fully — it is the primary source for syntax, semantics,
reply shape per RESP2/RESP3, and edge cases.
Find the server-side PR in the repo that owns the command. Route the
search by command family — module command families are developed in their
owning repositories, not in redis/redis:
| Command family |
Repository |
Syntax source |
Core commands, vector sets (VADD, …) |
redis/redis |
src/commands/*.json |
Search (FT.*) |
RediSearch/RediSearch |
PR diff + command docs (no src/commands/*.json) |
JSON (JSON.*) |
RedisJSON/RedisJSON |
PR diff + command docs |
Probabilistic (BF.*, CF.*, CMS.*, TOPK.*, TDIGEST.*) |
RedisBloom/RedisBloom |
PR diff + command docs |
Time series (TS.*) |
RedisTimeSeries/RedisTimeSeries |
PR diff + command docs |
If the search comes up empty in the routed repo, fall back to redis/redis
(and vice versa) before concluding there is no server PR.
First verify that gh works in the current (sandboxed) environment:
gh auth status
Sandboxes often block access to credential files, so gh may report
unauthenticated here even though it works on the user's machine. If so, ask the
user for permission to run these specific read-only gh commands outside the
sandbox; only fall back to the unauthenticated GitHub REST API if they decline.
Then search for the PR that adds/extends the command on the server:
gh search prs --repo <owning-repo> "<COMMAND NAME>" --limit 10
gh pr view <num> --repo <owning-repo>
gh pr diff <num> --repo <owning-repo> # in redis/redis: src/commands/*.json has exact syntax
Extract: exact wire syntax (argument order and optionality), reply type per
RESP2 and RESP3 (they can differ — this determines the CommandOutput and
the reactive Mono/Flux mapping), error conditions, and the first server
version carrying the feature (drives @EnabledOnCommand gating and the test
env version). Note whether the server marks the feature experimental/preview.
Verify the command exists on the "next" Redis OSS version. Start the
integration environment using the highest version the Makefile supports
(check SUPPORTED_TEST_ENV_VERSIONS in the Makefile, or the
src/test/resources/docker-env/.env.vX.XX files — pick numerically, 8.10 >
8.8):
grep SUPPORTED_TEST_ENV_VERSIONS Makefile
make start version=8.10
Then probe the running servers. Test connection defaults live in
src/test/java/io/lettuce/test/settings/TestSettings.java — the main standalone
node is localhost:6479 (no auth); module commands (FT.*, JSON.*, BF.*,
VADD, …) run on the stack node at localhost:16379:
redis-cli -p 6479 INFO server | grep redis_version
redis-cli -p 6479 COMMAND INFO <COMMAND> # non-empty → command exists
redis-cli -p 6479 COMMAND DOCS <COMMAND> # arity/args — compare with the server PR
For a NEW command, COMMAND INFO must return a non-empty reply. For an EXTENDED
command, additionally invoke it with the new syntax against a scratch key and
confirm the server accepts it (no ERR syntax error / ERR unknown argument).
If the command/option is missing on the latest env version, the image tag
pinned in the .env.vX.XX file is too old. Ask the user for a
redislabs/client-libs-test image tag that contains it (e.g. an RC/edge build —
tags are listed at https://hub.docker.com/r/redislabs/client-libs-test/tags; you
may check and suggest candidates), then:
make stop
CLIENT_LIBS_TEST_IMAGE_TAG=<tag> make start
If the PR should also move CI to that build, update the newest .env.vX.XX
pin as part of the change — command PRs do this when they need a fresh server
(cf. the hotkeys PR bumping .env.v8.6). If no tag carries the feature, report
it and proceed: implementation continues, integration tests stay gated until an
image ships the change. Keep the environment running for later test runs.
Create redis-cli showcase test cases. Once the command is available, derive
a small set of redis-cli scenarios from the HLD and the server PR and run them.
These serve two purposes:
- Smoke test: prove the documented behavior holds — happy path, each new
option/keyword, reply shape (run with
redis-cli -3 too when RESP3 replies
differ), edge cases and error conditions (missing key, out-of-range args,
conflicting options) — so the Java implementation is built against observed
replies, not assumptions.
- Showcase: each scenario should read as a mini use-case explaining WHY the
command/option exists. Prefer realistic data over
foo/bar.
Save the scenarios as a commented script in a scratch file (one block per
use-case: a "what this demonstrates" comment, the commands, the observed reply
pasted back as comments). Carry this forward: it feeds the Phase 1 plan, the
test assertions, and the PR description. If the command could not be made
available on any image, still write the scenarios as expected transcripts and
mark them unverified.
Read the testing and consistency docs:
.agents/docs/integration-testing.md
(environment, *UnitTests vs *IntegrationTests naming, base/overload test
structure, running one test) and
.agents/docs/api-consistency.md (the
flavors and their return-type mapping rules).
Trace one analogous existing command end-to-end (same command group, similar
reply shape) so the plan mirrors real code, not guesswork. Good reference PRs,
validated against git history:
| Commit |
What it exemplifies |
7ca3e9cc0 (CLIENT NO-TOUCH #3776) |
Minimal new command: all 6 flavors + builder + 2 dispatch layers + Kotlin impl + builder unit test + integration test |
312ecc7b4 (INCREX #3746) |
New command with args: self-typed abstract args base, new value type, new CommandOutputs, per-type args/output unit tests, creating a missing RESP2 overload class |
b009df37b (XNACK #3728) |
New command with an enum-valued argument |
9a0899875 (BITOP extensions #3334) |
New operations/overloads on an existing command |
c603c8120 (HSCAN NOVALUES #2816) |
New overload rippling into helpers (ScanIterator, ScanStream, ScanFlow) |
aa7b4b0be (stream idempotency #3637) |
Option added to an existing *Args class flows through with no interface change (the XAddArgs part) |
a209fba70 (CAS/CAD #3512) |
Read-only command registered in ReadOnlyCommands + count test update |
838a4d39a (HOTKEYS #3638) |
Map-shaped reply via ComplexOutput + a *ReplyParser, cluster interface additions, full integration overload set, .env image bump |
6567f2d5e (RediSearch #3375) |
Whole new command area: own Redis<Area>CommandBuilder, arguments/reply-parser packages |
Conventions beat precedent. Traced reference commands can predate the
current conventions; when the reference code conflicts with a written rule in
this skill or the linked docs, the rule wins. Example: sintercard(K...)
ships without a single-key overload because it predates the
one-overload-per-varargs rule — a new command mirroring it must still add the
single-argument overload.
Determine the @since version — recipe owned by
.agents/docs/javadoc.md:
mvn help:evaluate -Dexpression=project.version -q -DforceStdout
Drop -SNAPSHOT and the patch digit (7.7.0-SNAPSHOT → @since 7.7).
The evidence is your working context, not a deliverable. Hold it in mind to
drive the work — do not paste it back to the maintainer or pause for "spec
approval." The only stop for the maintainer is the Phase 1 plan approval; surface
genuinely ambiguous design choices there, with the proposed sync signatures,
rather than interrupting earlier.
Phase 1 — Plan mode, then explicit approval
Enter plan mode. Using the evidence, classify the change with the decision tree
below and enumerate the exact file-by-file touch list, the test matrix, and the
gating annotations. The plan must contain:
- A short "what this feature enables" section built from the showcase scenarios
(Phase 0 step 4), including one or two representative command/reply transcripts.
- The proposed sync interface signature(s) with their Javadoc. The sync
interface is the human-authored API contract; every other flavor is derived
from it and it is costly to change once mirrored. Plan approval is the
maintainer's sign-off on that contract — if the signatures must deviate later
during implementation, stop and re-confirm before mirroring.
- The complete overload set, enumerated. For every varargs parameter, list
the matching single-argument overload; for a multi-key command with an
*Args
object, use the List<K> shape (no varargs) and list the fixed-arity
convenience overloads instead — both house rules live in "Types & args
conventions". Justify any overload you omit so the maintainer signs off on the
exception. An overload discovered missing in review means reworking all six
flavors.
Present the plan and explicitly ask permission to execute before implementing.
Do not start editing files until the user approves.
Once approved, copy this checklist into your working notes and tick items off:
Extend-commands progress:
- [ ] 0. Evidence: HLD, server PR, live probe + showcase, RESP2/RESP3 replies, @since
- [ ] 1. Plan approved — incl. the sync signature(s), the API contract
- [ ] 2. Types: argument & response types (they must exist before any interface edit)
- [ ] 3. Sync interface: full overload set (varargs → single-arg too) + Javadoc
(@param constraints + @throws for builder-validated preconditions)
- [ ] 4. Mirror: async, reactive, Kotlin, NodeSelection×2 — consistency tests pass
- [ ] 5. Implementations: CommandType/Keyword, builder, async, reactive, Kotlin impl
- [ ] 6. Tests: args/builder/output unit tests + integration base/overloads
- [ ] 7. Docs: entry in the current-release section of docs/new-features.md
- [ ] 8. Verify: mvn clean test + a single integration test run; then make stop
Decision tree — what kind of change is this?
A. Extension of an existing command that fits an existing *Args class
(new option token / new field):
- Touch ONLY the
*Args class (+ CommandKeyword for new tokens) + tests. Do NOT
touch the command interfaces, builder signatures, or dispatch layers — the
existing args.build(commandArgs) delegation carries the new option through
automatically (cf. the XAddArgs part of the stream-idempotency PR).
- Add a fluent setter returning
this; register new tokens in
src/main/java/io/lettuce/core/protocol/CommandKeyword.java.
- If the reply shape grows, extend the response model/output backward-compatibly.
B. New command(s) in an existing group — core or module area — the FULL
matrix, in this order (types first — every flavor references them, so they must
exist to compile). For a command joining an existing module area (a new
FT.* method in the Search group, a new JSON.* method, …) the same matrix
applies with the area substitutions: the group is the area's flavor interfaces,
the builder is the area's Redis<Area>CommandBuilder (+ its
Redis<Area>CommandBuilderUnitTests), argument/reply types go in the area
package, and gating/tests follow the module rules in D (capability probe, stack
node). The dispatch layers are the same AbstractRedisAsyncCommands /
AbstractRedisReactiveCommands and Kotlin *Impl.kt as for core commands.
Argument/response types — see "Types & args conventions" below.
Sync interface src/main/java/io/lettuce/core/api/sync/<Group>Commands.java
— pick the group by command family (STRING → RedisStringCommands, HASH →
RedisHashCommands, generic-key → RedisKeyCommands, …). This is the reference
signature + Javadoc all flavors mirror (see the
writing-javadoc skill; @since mandatory):
/**
* Returns the length of the string value stored at {@code key}.
*
* @param key the key, must not be {@code null}.
* @return the length of the string at {@code key}, or {@code 0} when {@code key} does
* not exist.
* @since 7.7
*/
Long strlen(K key);
Three contract rules to apply while designing the signatures and their Javadoc:
- Multi-key commands that also take an
*Args object take the keys as
List<K>, not varargs. A varargs parameter must come last, so a trailing
options object can only follow it via an awkward leading placement (cf. the
older sintercard(long limit, K... keys)). For new commands, take the keys
as List<K> so the *Args argument comes last —
sunioncard(List<K> keys, SUnionCardArgs args) — and add fixed-arity
convenience overloads ((K key1, K key2) and their *Args variants)
instead of a varargs form. Since there is no varargs parameter here, the
single-argument-overload rule below does not apply; add only the overloads
that are meaningful — e.g. no single-key sunioncard/sdiffcard, whose
one-key union/difference is just SCARD.
- Every varargs parameter gets a single-argument overload —
foo(K key, V value) alongside foo(K key, V... values). This is a hard
convention for new commands and overrides any traced precedent that lacks
it (older commands predate the rule). Does not apply to the List<K>-shaped
multi-key-with-*Args commands above, which take no varargs.
- Validated preconditions are contract. Any constraint the builder will
enforce (null checks, non-empty varargs) must be stated in the
@param
text with the house phrases (must not be {@code null}.,
must not be empty.) and documented with a matching
@throws IllegalArgumentException if … tag — forms owned by
.agents/docs/javadoc.md. Both are then
mirrored to every flavor.
Mirror to every flavor: async, reactive, Kotlin coroutines, and the two
cluster node-selection interfaces (NodeSelection<Group>Commands /
NodeSelection<Group>AsyncCommands), applying the per-flavor return-type
mapping rules from
.agents/docs/api-consistency.md.
Protocol enums — command name in
src/main/java/io/lettuce/core/protocol/CommandType.java (wire bytes derive
from the enum name); subcommand tokens in CommandKeyword. Never add a
CommandKeyword that duplicates a name already in CommandType (e.g. SET,
DISCARD) — the builder static-imports both enums and the bare name becomes
ambiguous; reference CommandType.<NAME> directly instead.
Builder — RedisCommandBuilder.java (or the area builder, see D): null
checks via LettuceAssert/notNullKey, CommandArgs in wire order, the
CommandOutput chosen from the observed RESP2/RESP3 replies:
public Command<K, V, Long> strlen(K key) {
notNullKey(key);
return createCommand(STRLEN, new IntegerOutput<>(codec), key);
}
public Command<K, V, Boolean> copy(K source, K destination, CopyArgs copyArgs) {
LettuceAssert.notNull(source, "Source " + MUST_NOT_BE_NULL);
LettuceAssert.notNull(destination, "Destination " + MUST_NOT_BE_NULL);
CommandArgs<K, V> args = new CommandArgs<>(codec).addKey(source).addKey(destination);
copyArgs.build(args);
return createCommand(COPY, new BooleanOutput<>(codec), args);
}
Every LettuceAssert precondition written here is public contract: if the
interface Javadoc (step 2) does not already state the constraint and its
@throws IllegalArgumentException, go back and add it — on every flavor.
Dispatch layers — one-liners in both AbstractRedisAsyncCommands and
AbstractRedisReactiveCommands (createMono for scalars,
createDissolvingFlux for List/Set replies, matching the interface's
Mono/Flux):
// AbstractRedisAsyncCommands
public RedisFuture<Long> strlen(K key) { return dispatch(commandBuilder.strlen(key)); }
// AbstractRedisReactiveCommands
public Mono<Long> strlen(K key) { return createMono(() -> commandBuilder.strlen(key)); }
Kotlin impl —
src/main/kotlin/io/lettuce/core/api/coroutines/<Group>CoroutinesCommandsImpl.kt:
override suspend fun strlen(key: K): Long = ops.strlen(key).awaitSingle()
Cluster — pick the routing shape deliberately; there are three cases:
- A single-key command flows through automatically (routed by slot).
- A broadcast/all-shards command (its answer is the aggregate over the
whole cluster — cf.
dbsize, flushall) needs hand-coded fan-out
overrides in RedisAdvancedClusterAsyncCommandsImpl and its reactive
sibling (executeOnUpstream + a MultiNodeExecution aggregator), and may
need methods on the cluster aggregate interfaces.
- A node-specific command (keyless, but its result is only meaningful per
node — cf. HOTKEYS) must not be fanned out: add
default overrides on
the cluster aggregate interfaces that throw UnsupportedOperationException
and direct callers to the node-selection API or getConnection(nodeId)
(cf. RedisClusterCommands.hotkeysReset()); only the node-selection
flavors execute it.
See "Cluster routing" in
.agents/docs/architecture.md.
Read-only command? Register it in
src/main/java/io/lettuce/core/protocol/ReadOnlyCommands.java (CommandName
enum) so replica-read routing knows, and bump the size assertion in
ClusterReadOnlyCommandsUnitTests.
C. Extension needing new overloads / a new *Args class (hybrid): the new
methods go through the full matrix of B; the option plumbing follows A. Check
whether command helpers must follow (ScanIterator/ScanStream/ScanFlow for
scan-family commands).
D. New command group / module area (Search/JSON/Bloom/VectorSet-style):
- Unlike Jedis, Lettuce module areas are full citizens: every group gets all
six flavors — sync, async, reactive, Kotlin coroutines, and both node-selection
interfaces — plus Kotlin impls.
- Create the flavor interfaces by mirroring an existing area end-to-end, register
the group in the
CommandInterfaces enum
(src/test/java/io/lettuce/core/api/consistency/), and wire the group into the
hand-written aggregate interfaces (RedisCommands, RedisAsyncCommands,
RedisReactiveCommands, and the cluster variants) so they extend it — the
consistency tests enforce the aggregate wiring.
- Areas get their own builder (
Redis<Area>CommandBuilder, cf.
RediSearchCommandBuilder) with a matching
Redis<Area>CommandBuilderUnitTests, and keep their argument types and reply
parsers in an area package (e.g. core/search/arguments/).
- Module commands still gate on server capability, not version:
@EnabledOnCommand("FT.CREATE")-style probes; integration tests target the
stack node.
Types & args conventions
- Argument types: an options object is a
*Args implements CompositeArgument
class (e.g. io.lettuce.core.CopyArgs) whose build(CommandArgs) appends its
tokens. Fluent setters return this. If two overloads share options but differ
in a typed field (long vs double), use a self-typed abstract base
(BaseFooArgs<T extends BaseFooArgs<T>>) with concrete subclasses — cf.
BaseIncrexArgs/IncrexArgs/IncrexFloatArgs.
@since goes on every new public element, not just the class. A
class-level @since is not inherited: the nested Builder type, each of
its static factory methods, and each public fluent setter needs its own
@since tag, or the generated API docs lose the release provenance for those
members.
- Token-valued argument enums are plain enums whose values the builder/args class
appends (cf.
XNackMode).
- Response types: reuse existing models where possible —
Value, KeyValue,
ScoredValue, GeoCoordinates, GeoWithin, StreamMessage, KeyScanCursor
(all in io.lettuce.core) — and add a new one only when the reply genuinely
doesn't map. For a map-shaped/structured reply, pair a model class with a
ComplexDataParser consumed via ComplexOutput (cf. HotkeysReply +
HotkeysReplyParser).
- Return-type idioms (established conventions):
1/0 integer reply →
Boolean (cf. copy, expire, hsetnx); count → Long; status → String;
bulk value → V. And the overload rules from step B.2: every varargs
parameter also gets a single-argument overload, while a multi-key command with
an *Args object takes List<K> (not varargs) plus fixed-arity overloads.
- The
CommandOutput (the reply parser) is chosen at the builder step from the
observed RESP2/RESP3 replies of Phase 0 — if none fits, add one under
io.lettuce.core.output with a unit test (cf. IncrexLongOutput).
The consistency suite is the safety net
After mirroring, run:
mvn -Dtest='*ConsistencyUnitTests,CommandBuilderCoverageUnitTests' \
-Dsurefire.failIfNoSpecifiedTests=false test
It names exactly the flavor/signature you missed. For a genuinely unusual return
type (e.g. Flux<Value<Long>>, or Mono<List<Double>> because Redis returns
nulls), register it in the registry that owns the flavor —
src/test/java/io/lettuce/core/api/consistency/KnownApiDeviations.java for the
Java flavors, src/test/kotlin/io/lettuce/core/api/consistency/KnownKotlinApiDeviations.kt
for the coroutine flavor — with a comment justifying it. Never use a
deviation entry to paper over a sync/async signature mismatch — that breaks the
sync-over-async runtime proxy.
Format before you build: run mvn formatter:format after hand-editing — the
build's formatter:validate step fails the compile on unformatted code. Do not
submit formatting-only diffs.
Test matrix — what to write
Naming, placement, and the base/overload structure are owned by
.agents/docs/integration-testing.md
— follow it. The established per-command layers (write all that apply):
- Args unit tests (
*Args classes): assert the exact encoded tokens and
wire order, setter validation, and overload equivalence — e.g.
IncrexArgsUnitTests, XAddArgsUnitTests. No server needed.
- Builder unit test: assert the constructed command and encoded args —
including the RESP2/RESP3 output shape observed in Phase 0. Core commands go
in
src/test/java/io/lettuce/core/RedisCommandBuilderUnitTests.java; area
commands in their Redis<Area>CommandBuilderUnitTests.
- Output unit tests when a new
CommandOutput was added (cf.
IncrexOutputUnitTests).
- Integration tests: add methods to the sync base class
(
<Group>CommandIntegrationTests), gated per-test with
@EnabledOnCommand("<NAME>"). Use assertions derived from the redis-cli
showcase transcripts — real semantics, not just "no error", covering the whole
family the option touches (with/without optional args, error cases):@Test
@EnabledOnCommand("COPY")
void copy() {
redis.set(key, value);
assertThat(redis.copy(key, key + "2")).isTrue();
}
- Overloads: the base's
@Test methods re-run automatically under the
group's existing RESP2/cluster/reactive/Tx overload classes — but only the
ones that exist. Check the target group against peer groups and create a
missing overload class when it matters for the command (INCREX created
StringCommandResp2IntegrationTests because its reply differs by protocol).
Provide the base test at minimum; add overloads that carry real
risk (RESP2 when replies differ, cluster when routing matters).
Running the tests
The build pins a specific JDK to match CI — check .github/workflows/ and the
local-gotchas section of
.agents/docs/integration-testing.md
(pin JAVA_HOME, worktree git-commit-id-plugin skip, TEST_WORK_FOLDER).
Tear the environment down when you are done. The Docker topology started in
Phase 0 keeps running (and holds the test ports) until stopped. After the final
verification run — and equally when the task is aborted or fails partway — run:
make stop
PR hygiene checklist (verify before finishing)
Top pitfalls
- Skipping the live verification / not checking RESP2 vs RESP3. The reply
shape can differ between protocols; it determines the
CommandOutput and the
reactive mapping. Confirm against a running server, don't assume.
- Adding Args/response types after the interface edits — every flavor
references them; the project won't compile. Types come first.
- Editing only some flavors, or silencing the consistency suite with a
deviations-registry entry instead of fixing the signature.
- Forgetting a dispatch layer — both
AbstractRedisAsyncCommands and
AbstractRedisReactiveCommands, plus the Kotlin *Impl.kt.
- Recreating the removed generator source files under
src/test/java/io/lettuce/core/api/ because an old reference PR touched them.
- Wrong
CommandArgs order or CommandOutput, missing @since, missing
@EnabledOnCommand gating, or missing the read-only registry entry.
- Letting a traced precedent override a written convention — e.g. skipping
the single-argument overload because
sintercard(K...) doesn't have one, or
stopping at a class-level @since because an old *Args class did. Older
code predates the rules; the conventions win.
1---2name: extend-commands-api3description: Add or extend Redis commands in the Lettuce client API end-to-end — a new core command, a family of new commands, an extension to an existing command's options, or a module/area command (Search/JSON/Bloom/VectorSet). Gathers evidence first (HLD document, the server-side PR in the repo owning the command family, live verification against the Dockerized test environment with redis-cli), plans the full implementation matrix in plan mode, then implements across all API flavors with unit and integration tests. Trigger on "add support for the <X> command", "implement <REDIS COMMAND> in Lettuce", "extend <command> with <option>", or adding a new argument/overload to an existing command.4---56# Extend the Lettuce Commands API78Implement a new Redis command (or extend an existing one) in Lettuce, following the9conventions maintainers enforce in review. The workflow is evidence-first: prove the10command's behavior on a real server before designing the Java API.1112Read [.agents/docs/architecture.md](../../../.agents/docs/architecture.md) first for13the model behind this flow — especially that the sync/async/reactive/Kotlin command14**interfaces are hand-edited source files kept in lockstep by the API consistency15test suite** (see [.agents/docs/api-consistency.md](../../../.agents/docs/api-consistency.md)).1617> **Historical caveat when reading old PRs**: PRs merged before the generator removal18> also edit `src/test/java/io/lettuce/core/api/<Group>Commands.java` — the old19> template source files. Those files **no longer exist**; do not recreate them. The20> flavor interfaces are now edited directly.2122## Phase 0 — Gather evidence BEFORE planning2324Do all of the following before writing any plan or code:25261. **Ask the user for the HLD.** Interactively ask for the path to a markdown file27 containing the High-Level Design for the command(s) (or confirm none exists). If28 a path is given, read it fully — it is the primary source for syntax, semantics,29 reply shape per RESP2/RESP3, and edge cases.30312. **Find the server-side PR in the repo that owns the command.** Route the32 search by command family — module command families are developed in their33 owning repositories, not in `redis/redis`:3435 | Command family | Repository | Syntax source |36 |----------------|------------|---------------|37 | Core commands, vector sets (`VADD`, …) | `redis/redis` | `src/commands/*.json` |38 | Search (`FT.*`) | `RediSearch/RediSearch` | PR diff + command docs (no `src/commands/*.json`) |39 | JSON (`JSON.*`) | `RedisJSON/RedisJSON` | PR diff + command docs |40 | Probabilistic (`BF.*`, `CF.*`, `CMS.*`, `TOPK.*`, `TDIGEST.*`) | `RedisBloom/RedisBloom` | PR diff + command docs |41 | Time series (`TS.*`) | `RedisTimeSeries/RedisTimeSeries` | PR diff + command docs |4243 If the search comes up empty in the routed repo, fall back to `redis/redis`44 (and vice versa) before concluding there is no server PR.4546 First verify that `gh` works in the current (sandboxed) environment:47 ```bash48 gh auth status49 ```50 Sandboxes often block access to credential files, so `gh` may report51 unauthenticated here even though it works on the user's machine. If so, ask the52 user for permission to run these specific read-only `gh` commands outside the53 sandbox; only fall back to the unauthenticated GitHub REST API if they decline.5455 Then search for the PR that adds/extends the command on the server:56 ```bash57 gh search prs --repo <owning-repo> "<COMMAND NAME>" --limit 1058 gh pr view <num> --repo <owning-repo>59 gh pr diff <num> --repo <owning-repo> # in redis/redis: src/commands/*.json has exact syntax60 ```61 Extract: exact wire syntax (argument order and optionality), reply type per62 **RESP2 and RESP3** (they can differ — this determines the `CommandOutput` and63 the reactive `Mono`/`Flux` mapping), error conditions, and the **first server64 version carrying the feature** (drives `@EnabledOnCommand` gating and the test65 env version). Note whether the server marks the feature *experimental/preview*.66673. **Verify the command exists on the "next" Redis OSS version.** Start the68 integration environment using the **highest** version the Makefile supports69 (check `SUPPORTED_TEST_ENV_VERSIONS` in the `Makefile`, or the70 `src/test/resources/docker-env/.env.vX.XX` files — pick numerically, `8.10` >71 `8.8`):72 ```bash73 grep SUPPORTED_TEST_ENV_VERSIONS Makefile74 make start version=8.1075 ```76 Then probe the running servers. Test connection defaults live in77 `src/test/java/io/lettuce/test/settings/TestSettings.java` — the main standalone78 node is `localhost:6479` (no auth); module commands (`FT.*`, `JSON.*`, `BF.*`,79 `VADD`, …) run on the stack node at `localhost:16379`:80 ```bash81 redis-cli -p 6479 INFO server | grep redis_version82 redis-cli -p 6479 COMMAND INFO <COMMAND> # non-empty → command exists83 redis-cli -p 6479 COMMAND DOCS <COMMAND> # arity/args — compare with the server PR84 ```85 For a NEW command, `COMMAND INFO` must return a non-empty reply. For an EXTENDED86 command, additionally invoke it with the new syntax against a scratch key and87 confirm the server accepts it (no `ERR syntax error` / `ERR unknown argument`).8889 **If the command/option is missing on the latest env version**, the image tag90 pinned in the `.env.vX.XX` file is too old. Ask the user for a91 `redislabs/client-libs-test` image tag that contains it (e.g. an RC/edge build —92 tags are listed at https://hub.docker.com/r/redislabs/client-libs-test/tags; you93 may check and suggest candidates), then:94 ```bash95 make stop96 CLIENT_LIBS_TEST_IMAGE_TAG=<tag> make start97 ```98 If the PR should also move CI to that build, update the newest `.env.vX.XX`99 pin as part of the change — command PRs do this when they need a fresh server100 (cf. the hotkeys PR bumping `.env.v8.6`). If no tag carries the feature, report101 it and proceed: implementation continues, integration tests stay gated until an102 image ships the change. Keep the environment running for later test runs.1031044. **Create redis-cli showcase test cases.** Once the command is available, derive105 a small set of redis-cli scenarios from the HLD and the server PR and run them.106 These serve two purposes:107 - **Smoke test**: prove the documented behavior holds — happy path, each new108 option/keyword, reply shape (run with `redis-cli -3` too when RESP3 replies109 differ), edge cases and error conditions (missing key, out-of-range args,110 conflicting options) — so the Java implementation is built against observed111 replies, not assumptions.112 - **Showcase**: each scenario should read as a mini use-case explaining WHY the113 command/option exists. Prefer realistic data over `foo`/`bar`.114115 Save the scenarios as a commented script in a scratch file (one block per116 use-case: a "what this demonstrates" comment, the commands, the observed reply117 pasted back as comments). Carry this forward: it feeds the Phase 1 plan, the118 test assertions, and the PR description. If the command could not be made119 available on any image, still write the scenarios as *expected* transcripts and120 mark them unverified.1211225. **Read the testing and consistency docs**:123 [.agents/docs/integration-testing.md](../../../.agents/docs/integration-testing.md)124 (environment, `*UnitTests` vs `*IntegrationTests` naming, base/overload test125 structure, running one test) and126 [.agents/docs/api-consistency.md](../../../.agents/docs/api-consistency.md) (the127 flavors and their return-type mapping rules).1281296. **Trace one analogous existing command** end-to-end (same command group, similar130 reply shape) so the plan mirrors real code, not guesswork. Good reference PRs,131 validated against git history:132133 | Commit | What it exemplifies |134 |--------|---------------------|135 | `7ca3e9cc0` (CLIENT NO-TOUCH #3776) | Minimal new command: all 6 flavors + builder + 2 dispatch layers + Kotlin impl + builder unit test + integration test |136 | `312ecc7b4` (INCREX #3746) | New command with args: self-typed abstract args base, new value type, new `CommandOutput`s, per-type args/output unit tests, **creating a missing RESP2 overload class** |137 | `b009df37b` (XNACK #3728) | New command with an enum-valued argument |138 | `9a0899875` (BITOP extensions #3334) | New operations/overloads on an existing command |139 | `c603c8120` (HSCAN NOVALUES #2816) | New overload rippling into helpers (`ScanIterator`, `ScanStream`, `ScanFlow`) |140 | `aa7b4b0be` (stream idempotency #3637) | Option added to an existing `*Args` class flows through with **no interface change** (the `XAddArgs` part) |141 | `a209fba70` (CAS/CAD #3512) | Read-only command registered in `ReadOnlyCommands` + count test update |142 | `838a4d39a` (HOTKEYS #3638) | Map-shaped reply via `ComplexOutput` + a `*ReplyParser`, cluster interface additions, full integration overload set, `.env` image bump |143 | `6567f2d5e` (RediSearch #3375) | Whole new command area: own `Redis<Area>CommandBuilder`, `arguments`/reply-parser packages |144145 **Conventions beat precedent.** Traced reference commands can predate the146 current conventions; when the reference code conflicts with a written rule in147 this skill or the linked docs, the rule wins. Example: `sintercard(K...)`148 ships without a single-key overload because it predates the149 one-overload-per-varargs rule — a new command mirroring it must still add the150 single-argument overload.1511527. **Determine the `@since` version** — recipe owned by153 [.agents/docs/javadoc.md](../../../.agents/docs/javadoc.md):154 ```bash155 mvn help:evaluate -Dexpression=project.version -q -DforceStdout156 ```157 Drop `-SNAPSHOT` and the patch digit (`7.7.0-SNAPSHOT` → `@since 7.7`).158159**The evidence is your working context, not a deliverable.** Hold it in mind to160drive the work — do not paste it back to the maintainer or pause for "spec161approval." The only stop for the maintainer is the Phase 1 plan approval; surface162genuinely ambiguous design choices *there*, with the proposed sync signatures,163rather than interrupting earlier.164165## Phase 1 — Plan mode, then explicit approval166167Enter **plan mode**. Using the evidence, classify the change with the decision tree168below and enumerate the exact file-by-file touch list, the test matrix, and the169gating annotations. The plan must contain:170171- A short "what this feature enables" section built from the showcase scenarios172 (Phase 0 step 4), including one or two representative command/reply transcripts.173- **The proposed sync interface signature(s) with their Javadoc.** The sync174 interface is the human-authored **API contract**; every other flavor is derived175 from it and it is costly to change once mirrored. Plan approval is the176 maintainer's sign-off on that contract — if the signatures must deviate later177 during implementation, stop and re-confirm before mirroring.178- **The complete overload set, enumerated.** For every varargs parameter, list179 the matching single-argument overload; for a multi-key command with an `*Args`180 object, use the `List<K>` shape (no varargs) and list the fixed-arity181 convenience overloads instead — both house rules live in "Types & args182 conventions". Justify any overload you omit so the maintainer signs off on the183 exception. An overload discovered missing in review means reworking all six184 flavors.185186Present the plan and **explicitly ask permission to execute** before implementing.187Do not start editing files until the user approves.188189Once approved, copy this checklist into your working notes and tick items off:190191```192Extend-commands progress:193- [ ] 0. Evidence: HLD, server PR, live probe + showcase, RESP2/RESP3 replies, @since194- [ ] 1. Plan approved — incl. the sync signature(s), the API contract195- [ ] 2. Types: argument & response types (they must exist before any interface edit)196- [ ] 3. Sync interface: full overload set (varargs → single-arg too) + Javadoc197 (@param constraints + @throws for builder-validated preconditions)198- [ ] 4. Mirror: async, reactive, Kotlin, NodeSelection×2 — consistency tests pass199- [ ] 5. Implementations: CommandType/Keyword, builder, async, reactive, Kotlin impl200- [ ] 6. Tests: args/builder/output unit tests + integration base/overloads201- [ ] 7. Docs: entry in the current-release section of docs/new-features.md202- [ ] 8. Verify: mvn clean test + a single integration test run; then make stop203```204205## Decision tree — what kind of change is this?206207**A. Extension of an existing command that fits an existing `*Args` class**208(new option token / new field):209- Touch ONLY the `*Args` class (+ `CommandKeyword` for new tokens) + tests. Do NOT210 touch the command interfaces, builder signatures, or dispatch layers — the211 existing `args.build(commandArgs)` delegation carries the new option through212 automatically (cf. the `XAddArgs` part of the stream-idempotency PR).213- Add a fluent setter returning `this`; register new tokens in214 `src/main/java/io/lettuce/core/protocol/CommandKeyword.java`.215- If the reply shape grows, extend the response model/output backward-compatibly.216217**B. New command(s) in an existing group — core or module area** — the FULL218matrix, in this order (types first — every flavor references them, so they must219exist to compile). For a command joining an **existing module area** (a new220`FT.*` method in the Search group, a new `JSON.*` method, …) the same matrix221applies with the area substitutions: the group is the area's flavor interfaces,222the builder is the area's `Redis<Area>CommandBuilder` (+ its223`Redis<Area>CommandBuilderUnitTests`), argument/reply types go in the area224package, and gating/tests follow the module rules in D (capability probe, stack225node). The dispatch layers are the same `AbstractRedisAsyncCommands` /226`AbstractRedisReactiveCommands` and Kotlin `*Impl.kt` as for core commands.2271. **Argument/response types** — see "Types & args conventions" below.2282. **Sync interface** `src/main/java/io/lettuce/core/api/sync/<Group>Commands.java`229 — pick the group by command family (STRING → `RedisStringCommands`, HASH →230 `RedisHashCommands`, generic-key → `RedisKeyCommands`, …). This is the reference231 signature + Javadoc all flavors mirror (see the232 [writing-javadoc](../writing-javadoc/SKILL.md) skill; `@since` mandatory):233 ```java234 /**235 * Returns the length of the string value stored at {@code key}.236 *237 * @param key the key, must not be {@code null}.238 * @return the length of the string at {@code key}, or {@code 0} when {@code key} does239 * not exist.240 * @since 7.7241 */242 Long strlen(K key);243 ```244 Three contract rules to apply while designing the signatures and their Javadoc:245 - **Multi-key commands that also take an `*Args` object take the keys as246 `List<K>`, not varargs.** A varargs parameter must come last, so a trailing247 options object can only follow it via an awkward leading placement (cf. the248 older `sintercard(long limit, K... keys)`). For new commands, take the keys249 as `List<K>` so the `*Args` argument comes last —250 `sunioncard(List<K> keys, SUnionCardArgs args)` — and add fixed-arity251 convenience overloads (`(K key1, K key2)` and their `*Args` variants)252 instead of a varargs form. Since there is no varargs parameter here, the253 single-argument-overload rule below does not apply; add only the overloads254 that are meaningful — e.g. **no single-key `sunioncard`/`sdiffcard`**, whose255 one-key union/difference is just `SCARD`.256 - **Every varargs parameter gets a single-argument overload** —257 `foo(K key, V value)` alongside `foo(K key, V... values)`. This is a hard258 convention for new commands and overrides any traced precedent that lacks259 it (older commands predate the rule). Does not apply to the `List<K>`-shaped260 multi-key-with-`*Args` commands above, which take no varargs.261 - **Validated preconditions are contract.** Any constraint the builder will262 enforce (null checks, non-empty varargs) must be stated in the `@param`263 text with the house phrases (`must not be {@code null}.`,264 `must not be empty.`) *and* documented with a matching265 `@throws IllegalArgumentException if …` tag — forms owned by266 [.agents/docs/javadoc.md](../../../.agents/docs/javadoc.md). Both are then267 mirrored to every flavor.2683. **Mirror to every flavor**: async, reactive, Kotlin coroutines, and the two269 cluster node-selection interfaces (`NodeSelection<Group>Commands` /270 `NodeSelection<Group>AsyncCommands`), applying the per-flavor return-type271 mapping rules from272 [.agents/docs/api-consistency.md](../../../.agents/docs/api-consistency.md).2734. **Protocol enums** — command name in274 `src/main/java/io/lettuce/core/protocol/CommandType.java` (wire bytes derive275 from the enum name); subcommand tokens in `CommandKeyword`. **Never add a276 `CommandKeyword` that duplicates a name already in `CommandType`** (e.g. `SET`,277 `DISCARD`) — the builder static-imports both enums and the bare name becomes278 ambiguous; reference `CommandType.<NAME>` directly instead.2795. **Builder** — `RedisCommandBuilder.java` (or the area builder, see D): null280 checks via `LettuceAssert`/`notNullKey`, `CommandArgs` in **wire order**, the281 `CommandOutput` chosen from the observed RESP2/RESP3 replies:282 ```java283 public Command<K, V, Long> strlen(K key) {284 notNullKey(key);285 return createCommand(STRLEN, new IntegerOutput<>(codec), key);286 }287288 public Command<K, V, Boolean> copy(K source, K destination, CopyArgs copyArgs) {289 LettuceAssert.notNull(source, "Source " + MUST_NOT_BE_NULL);290 LettuceAssert.notNull(destination, "Destination " + MUST_NOT_BE_NULL);291 CommandArgs<K, V> args = new CommandArgs<>(codec).addKey(source).addKey(destination);292 copyArgs.build(args);293 return createCommand(COPY, new BooleanOutput<>(codec), args);294 }295 ```296 Every `LettuceAssert` precondition written here is public contract: if the297 interface Javadoc (step 2) does not already state the constraint and its298 `@throws IllegalArgumentException`, go back and add it — on every flavor.2996. **Dispatch layers** — one-liners in *both* `AbstractRedisAsyncCommands` *and*300 `AbstractRedisReactiveCommands` (`createMono` for scalars,301 `createDissolvingFlux` for `List`/`Set` replies, matching the interface's302 `Mono`/`Flux`):303 ```java304 // AbstractRedisAsyncCommands305 public RedisFuture<Long> strlen(K key) { return dispatch(commandBuilder.strlen(key)); }306 // AbstractRedisReactiveCommands307 public Mono<Long> strlen(K key) { return createMono(() -> commandBuilder.strlen(key)); }308 ```3097. **Kotlin impl** —310 `src/main/kotlin/io/lettuce/core/api/coroutines/<Group>CoroutinesCommandsImpl.kt`:311 ```kotlin312 override suspend fun strlen(key: K): Long = ops.strlen(key).awaitSingle()313 ```3148. **Cluster** — pick the routing shape deliberately; there are three cases:315 - A **single-key** command flows through automatically (routed by slot).316 - A **broadcast/all-shards** command (its answer is the aggregate over the317 whole cluster — cf. `dbsize`, `flushall`) needs hand-coded fan-out318 overrides in `RedisAdvancedClusterAsyncCommandsImpl` *and* its reactive319 sibling (`executeOnUpstream` + a `MultiNodeExecution` aggregator), and may320 need methods on the cluster aggregate interfaces.321 - A **node-specific** command (keyless, but its result is only meaningful per322 node — cf. HOTKEYS) must **not** be fanned out: add `default` overrides on323 the cluster aggregate interfaces that throw `UnsupportedOperationException`324 and direct callers to the node-selection API or `getConnection(nodeId)`325 (cf. `RedisClusterCommands.hotkeysReset()`); only the node-selection326 flavors execute it.327328 See "Cluster routing" in329 [.agents/docs/architecture.md](../../../.agents/docs/architecture.md).3309. **Read-only command?** Register it in331 `src/main/java/io/lettuce/core/protocol/ReadOnlyCommands.java` (`CommandName`332 enum) so replica-read routing knows, and bump the size assertion in333 `ClusterReadOnlyCommandsUnitTests`.334335**C. Extension needing new overloads / a new `*Args` class** (hybrid): the new336methods go through the full matrix of B; the option plumbing follows A. Check337whether command helpers must follow (`ScanIterator`/`ScanStream`/`ScanFlow` for338scan-family commands).339340**D. New command group / module area** (Search/JSON/Bloom/VectorSet-style):341- **Unlike Jedis, Lettuce module areas are full citizens**: every group gets all342 six flavors — sync, async, reactive, Kotlin coroutines, and both node-selection343 interfaces — plus Kotlin impls.344- Create the flavor interfaces by mirroring an existing area end-to-end, register345 the group in the `CommandInterfaces` enum346 (`src/test/java/io/lettuce/core/api/consistency/`), and wire the group into the347 hand-written aggregate interfaces (`RedisCommands`, `RedisAsyncCommands`,348 `RedisReactiveCommands`, and the cluster variants) so they `extend` it — the349 consistency tests enforce the aggregate wiring.350- Areas get their **own builder** (`Redis<Area>CommandBuilder`, cf.351 `RediSearchCommandBuilder`) with a matching352 `Redis<Area>CommandBuilderUnitTests`, and keep their argument types and reply353 parsers in an area package (e.g. `core/search/arguments/`).354- Module commands still gate on server capability, not version:355 `@EnabledOnCommand("FT.CREATE")`-style probes; integration tests target the356 stack node.357358## Types & args conventions359360- **Argument types**: an options object is a `*Args implements CompositeArgument`361 class (e.g. `io.lettuce.core.CopyArgs`) whose `build(CommandArgs)` appends its362 tokens. Fluent setters return `this`. If two overloads share options but differ363 in a typed field (long vs double), use a self-typed abstract base364 (`BaseFooArgs<T extends BaseFooArgs<T>>`) with concrete subclasses — cf.365 `BaseIncrexArgs`/`IncrexArgs`/`IncrexFloatArgs`.366- **`@since` goes on every new public element, not just the class.** A367 class-level `@since` is **not inherited**: the nested `Builder` type, each of368 its static factory methods, and each public fluent setter needs its own369 `@since` tag, or the generated API docs lose the release provenance for those370 members.371- Token-valued argument enums are plain enums whose values the builder/args class372 appends (cf. `XNackMode`).373- **Response types**: reuse existing models where possible — `Value`, `KeyValue`,374 `ScoredValue`, `GeoCoordinates`, `GeoWithin`, `StreamMessage`, `KeyScanCursor`375 (all in `io.lettuce.core`) — and add a new one only when the reply genuinely376 doesn't map. For a map-shaped/structured reply, pair a model class with a377 `ComplexDataParser` consumed via `ComplexOutput` (cf. `HotkeysReply` +378 `HotkeysReplyParser`).379- **Return-type idioms** (established conventions): `1/0` integer reply →380 `Boolean` (cf. `copy`, `expire`, `hsetnx`); count → `Long`; status → `String`;381 bulk value → `V`. And the overload rules from step B.2: every varargs382 parameter also gets a single-argument overload, while a multi-key command with383 an `*Args` object takes `List<K>` (not varargs) plus fixed-arity overloads.384- The `CommandOutput` (the reply *parser*) is chosen at the builder step from the385 **observed** RESP2/RESP3 replies of Phase 0 — if none fits, add one under386 `io.lettuce.core.output` with a unit test (cf. `IncrexLongOutput`).387388## The consistency suite is the safety net389390After mirroring, run:391392```bash393mvn -Dtest='*ConsistencyUnitTests,CommandBuilderCoverageUnitTests' \394 -Dsurefire.failIfNoSpecifiedTests=false test395```396397It names exactly the flavor/signature you missed. For a genuinely unusual return398type (e.g. `Flux<Value<Long>>`, or `Mono<List<Double>>` because Redis returns399nulls), register it in the registry that owns the flavor —400`src/test/java/io/lettuce/core/api/consistency/KnownApiDeviations.java` for the401Java flavors, `src/test/kotlin/io/lettuce/core/api/consistency/KnownKotlinApiDeviations.kt`402for the coroutine flavor — **with a comment justifying it**. Never use a403deviation entry to paper over a sync/async signature mismatch — that breaks the404sync-over-async runtime proxy.405406**Format before you build**: run `mvn formatter:format` after hand-editing — the407build's `formatter:validate` step fails the compile on unformatted code. Do not408submit formatting-only diffs.409410## Test matrix — what to write411412Naming, placement, and the base/overload structure are owned by413[.agents/docs/integration-testing.md](../../../.agents/docs/integration-testing.md)414— follow it. The established per-command layers (write all that apply):4154161. **Args unit tests** (`*Args` classes): assert the exact encoded tokens and417 wire order, setter validation, and overload equivalence — e.g.418 `IncrexArgsUnitTests`, `XAddArgsUnitTests`. No server needed.4192. **Builder unit test**: assert the constructed command and encoded args —420 including the RESP2/RESP3 output shape observed in Phase 0. Core commands go421 in `src/test/java/io/lettuce/core/RedisCommandBuilderUnitTests.java`; area422 commands in their `Redis<Area>CommandBuilderUnitTests`.4233. **Output unit tests** when a new `CommandOutput` was added (cf.424 `IncrexOutputUnitTests`).4254. **Integration tests**: add methods to the sync base class426 (`<Group>CommandIntegrationTests`), gated per-test with427 `@EnabledOnCommand("<NAME>")`. Use assertions derived from the redis-cli428 showcase transcripts — real semantics, not just "no error", covering the whole429 family the option touches (with/without optional args, error cases):430 ```java431 @Test432 @EnabledOnCommand("COPY")433 void copy() {434 redis.set(key, value);435 assertThat(redis.copy(key, key + "2")).isTrue();436 }437 ```4385. **Overloads**: the base's `@Test` methods re-run automatically under the439 group's existing RESP2/cluster/reactive/Tx overload classes — but **only the440 ones that exist**. Check the target group against peer groups and **create a441 missing overload class** when it matters for the command (INCREX created442 `StringCommandResp2IntegrationTests` because its reply differs by protocol).443 Provide the base test at minimum; add overloads that carry real444 risk (RESP2 when replies differ, cluster when routing matters).445446## Running the tests447448The build pins a specific JDK to match CI — check `.github/workflows/` and the449local-gotchas section of450[.agents/docs/integration-testing.md](../../../.agents/docs/integration-testing.md)451(pin `JAVA_HOME`, worktree `git-commit-id-plugin` skip, `TEST_WORK_FOLDER`).452453- Unit tests (Surefire, no server): `mvn clean test`, or `mvn -Dtest=FooUnitTests test`.454- Integration tests (Failsafe) need the Docker env from Phase 0 and the `verify`455 lifecycle — **`-Dit.test=` filters Failsafe, `-Dtest=` does not**:456 ```bash457 TEST_WORK_FOLDER=./work/docker mvn -DskipITs=false -DskipUnitTests=true \458 -Dit.test=FooIntegrationTests verify -Pci459 ```460 If the command isn't in any published `redislabs/client-libs-test` tag yet, say461 so: the integration tests will be skipped by `@EnabledOnCommand` (expected and462 acceptable), but they must still be written and compile.463464**Tear the environment down when you are done.** The Docker topology started in465Phase 0 keeps running (and holds the test ports) until stopped. After the final466verification run — and equally when the task is aborted or fails partway — run:467468```bash469make stop470```471472## PR hygiene checklist (verify before finishing)473474- [ ] Every layer of the chosen matrix updated consistently; the consistency suite475 and `CommandBuilderCoverageUnitTests` pass.476- [ ] Every varargs parameter has its single-argument overload, and every477 multi-key command with an `*Args` object uses `List<K>` (not varargs) with478 fixed-arity overloads — mirrored across all flavors (or a479 maintainer-approved justification from the plan).480- [ ] `@since` on **every** new public element — including the nested `Builder`,481 static factories, and fluent setters of new `*Args` classes; class-level482 tags are not inherited (see483 [.agents/docs/javadoc.md](../../../.agents/docs/javadoc.md)). Javadoc written on484 the sync interface and mirrored with flavor-appropriate `@return` phrasing.485- [ ] Builder-validated preconditions documented on all flavors: `@param`486 constraint phrases + `@throws IllegalArgumentException if …`.487- [ ] No dead `CommandKeyword` constants; no keyword duplicating a `CommandType`.488- [ ] Read-only commands registered in `ReadOnlyCommands` (+ count test bumped).489- [ ] `mvn formatter:format` run; no formatting-only noise in the diff.490- [ ] Tests at every applicable layer, gated with `@EnabledOnCommand`; missing491 integration overload classes created where the command needs them.492- [ ] `docs/` (MkDocs) updated — a new command or option **is** user-facing: add493 a one-line entry to the current-release section of `docs/new-features.md`494 (follow its existing "Support for [`X`](redis.io link) …" pattern), plus495 any feature page the change affects.496- [ ] `.env.vX.XX` image pin bumped if the feature needed a newer server build.497- [ ] PR description states: server PR link, HLD link, gating choice and why,498 behavior against older servers, and includes a showcase transcript. (Draft499 with the [draft-pr-description](../draft-pr-description/SKILL.md) skill;500 remember the guardrail — the agent never creates the PR itself.)501- [ ] Docker test environment stopped (`make stop`) after the final verification502 run.503504## Top pitfalls5055061. **Skipping the live verification / not checking RESP2 vs RESP3.** The reply507 shape can differ between protocols; it determines the `CommandOutput` and the508 reactive mapping. Confirm against a running server, don't assume.5092. **Adding Args/response types after the interface edits** — every flavor510 references them; the project won't compile. Types come first.5113. **Editing only some flavors, or silencing the consistency suite** with a512 deviations-registry entry instead of fixing the signature.5134. **Forgetting a dispatch layer** — both `AbstractRedisAsyncCommands` *and*514 `AbstractRedisReactiveCommands`, plus the Kotlin `*Impl.kt`.5155. **Recreating the removed generator source files** under516 `src/test/java/io/lettuce/core/api/` because an old reference PR touched them.5176. **Wrong `CommandArgs` order or `CommandOutput`**, missing `@since`, missing518 `@EnabledOnCommand` gating, or missing the read-only registry entry.5197. **Letting a traced precedent override a written convention** — e.g. skipping520 the single-argument overload because `sintercard(K...)` doesn't have one, or521 stopping at a class-level `@since` because an old `*Args` class did. Older522 code predates the rules; the conventions win.