Refuse an empty key list before delete-many renders IN ()

Table of Contents

This page is a capture in the inbox bucket of the product backlog — a pre-sprint idea, not yet pulled into a sprint as a story.

1. What

A batch removal that resolves no rows must not reach the store. The generated service checks that the request carries removals, then resolves each key to a row and collects the ids it found. When the request carries a key that names no row, the collected id list is empty, the check has already passed, and the repository is called with {}. The generated repository renders the ids with sqlgen, so the statement reads ... AND ("id" IN ())) AND ... and Postgres refuses it:

Repository error: Query execution failed: ERROR:  syntax error at or near ")"
LINE 1: ...61d4a-aaaf-440d-857f-5e69365ffa06') AND ("id" IN ())) AND ("...

The repository should refuse an empty id list with a verdict of its own, and the service should treat "nothing resolved" as the empty batch its own comment already promises: a row that is already gone is not a failure.

2. Why

Found on 2026-09-26 during the ores.analytics clean pass, in the live V04 run of the 40 generated scripts against the Acme tenant. The trigger is narrow and was measured directly: the command fails when its key resolves no row, and answers ok when one matches. Both branches were observed on the same base with the same command, pricing_model_configs delete-many:

Run State when it ran Verdict
Sweep the add before it had inserted a row named __none__ ok
Alone no such row in the tenant internal_error, syntax error at )

pricing_model_product_parameters delete-many fails in the sweep because its add aborts in the client and leaves no row behind, so the empty branch is the one it always takes. pricing_engine_types delete-many answers ok because that entity is keyed by text and its key always resolves to a non-empty predicate.

So the condition is "no row matched", not "the entity is uuid-keyed": a caller reaches it whenever the row is already gone, which is exactly the case the service's own comment calls a success.

The failure is worth its own capture because it is not the sentinel defect: the sentinel decides which key arrives, but a caller can reach this path with a key for a row that is already gone, which is the case the service explicitly treats as a success. A caller cannot tell an empty batch from a broken server, and the answer names an internal error rather than the batch. It is generated code on both sides of the join, so the fix belongs on a codegen story with a regression test.

3. References

  • projects/ores.analytics/core/src/service/pricing_model_config_service.cpp — delete_many_pricing_model_configs: guards request.removals.empty(), not an empty resolved set.
  • projects/ores.analytics/core/src/repository/pricing_model_config_repository.cpp:335 — where("tenant_id"_c = tid && "id"_c.in(ids) && …)=, which sqlgen renders as =IN () = for an empty vector.
  • projects/ores.analytics/modeling/ores.analytics.pricing_model_config.org and the two sibling uuid-keyed models.

4. See also

Emacs 29.3 (Org mode 9.6.15)