Summary
No conditional-visibility EXCLUDE rule can be created — through any path. isInclude: false is refused with HTTP 400 on PostgreSQL, which is the shared quality workflow's default database, so this is the CI fixture too and not a local quirk.
The whole "Hide when…" half of conditional visibility is therefore dead in production. VisibilityChecker's exclude-overrides-include logic can never fire, because no exclude rule can exist to fire it.
Reproduction (isolated, identical bodies, one field different)
Against a stock nextcloud:34.0.0-apache + PostgreSQL 16, launchpad development @ 66957c17:
POST /index.php/apps/launchpad/api/widgets/164/rules
{"ruleType":"group","ruleConfig":{"groups":["marketing"]},"isInclude":true}
-> 201 {"id":8,...,"isInclude":true}
POST /index.php/apps/launchpad/api/widgets/164/rules
{"ruleType":"date","ruleConfig":{"startDate":"2026-12-01"},"isInclude":false}
-> 400 {"error":"Operation failed"}
POST /index.php/apps/launchpad/api/widgets/164/rules
{"ruleType":"date","ruleConfig":{"startDate":"2026-12-01"},"isInclude":true}
-> 201 {"id":9,...}
The update path is refused the same way, so there is no create-then-toggle workaround either:
PUT /index.php/apps/launchpad/api/rules/9 {"isInclude":false} -> 400 {"error":"Operation failed"}
The UI hits exactly this: ConditionalVisibilityEditor emits {"ruleType":…,"ruleConfig":…,"isInclude":false} on the same endpoint when a row is toggled to Exclude.
Root cause: a boolean bound into a smallint column
=# \d oc_launchpad_conditional_rules
is_include | smallint | not null | 1
lib/Db/ConditionalRule.php:
protected bool $isInclude = true;
$this->addType(fieldName: 'isInclude', type: 'boolean');
QBMapper::getParameterTypeForProperty() maps bool to IQueryBuilder::PARAM_BOOL, so PostgreSQL is handed a real boolean for a smallint column. Confirmed directly against the table:
INSERT INTO oc_launchpad_conditional_rules (..., is_include, ...) VALUES (..., 0, ...); -- OK
INSERT INTO oc_launchpad_conditional_rules (..., is_include, ...) VALUES (..., false, ...);
ERROR: column "is_include" is of type smallint but expression is of type boolean
MySQL and SQLite coerce silently, which is why this has survived: it is invisible on those backends and fatal on the one CI actually uses.
Second defect, and the reason this took so long to find
RuleApiController::addRule() reports the failure with
return ResponseHelper::error(exception: $e);
and passes no logger. ResponseHelper::error()'s own docblock says callers SHOULD pass their LoggerInterface so the real exception reaches the server log; this caller does not. The result is a generic {"error":"Operation failed"} to the client and nothing at all in nextcloud.log — an error path that is undiagnosable from either end. Worth fixing independently of the type bug, and worth sweeping for elsewhere: grep -rn "ResponseHelper::error" lib/ shows more call sites without a logger.
Candidate fixes (deliberately not chosen here)
- Migrate the column to boolean, matching the entity. Correct long-term; needs raw platform-specific DDL, because a Doctrine type change emits
ALTER COLUMN … TYPE BOOLEAN with no USING clause and PostgreSQL rejects that on smallint.
- Make the entity integer-backed, matching the column (
protected int $isInclude, addType('isInclude','integer')), and cast to bool in jsonSerialize() so the JSON contract is unchanged. Smaller migration risk, but it touches the two strict comparisons in the gating path — VisibilityChecker.php:146 (=== $isInclude) and ConditionalService.php:207 (=== true) — plus tests/Unit/Db/ConditionalRuleTest.php:44,80,83.
Option 2 is the app's own convention elsewhere (WidgetPlacement serialises isVisible/isCompulsory/showTitle as 1/0), but both options change behaviour in a visibility-gating path, which is why this is filed rather than patched in a coverage sweep.
Impact on gate-19
conditional-visibility-editor::include-rules-grouped-under-an-or-heading cannot be covered until this is fixed — its GIVEN requires a stored exclude rule. It is left uncovered and NOT @e2e excluded: a browser observes it fine once the rule can be saved, so an exclusion would record a false statement, and per .github#345 the gate counts an exclusion as positive coverage.
includeexclude-toggle IS covered, narrowly and on purpose — that requirement is about what the row emits and where it moves to, both observable before the request leaves the browser. tests/e2e/conditional-visibility-editor.spec.ts asserts exactly that and deliberately does not assert persistence.
Found while writing gate-19 round-3 coverage.
Summary
No conditional-visibility EXCLUDE rule can be created — through any path.
isInclude: falseis refused with HTTP 400 on PostgreSQL, which is the shared quality workflow's default database, so this is the CI fixture too and not a local quirk.The whole "Hide when…" half of conditional visibility is therefore dead in production.
VisibilityChecker's exclude-overrides-include logic can never fire, because no exclude rule can exist to fire it.Reproduction (isolated, identical bodies, one field different)
Against a stock
nextcloud:34.0.0-apache+ PostgreSQL 16, launchpaddevelopment@66957c17:The update path is refused the same way, so there is no create-then-toggle workaround either:
The UI hits exactly this:
ConditionalVisibilityEditoremits{"ruleType":…,"ruleConfig":…,"isInclude":false}on the same endpoint when a row is toggled to Exclude.Root cause: a boolean bound into a smallint column
lib/Db/ConditionalRule.php:QBMapper::getParameterTypeForProperty()mapsbooltoIQueryBuilder::PARAM_BOOL, so PostgreSQL is handed a real boolean for asmallintcolumn. Confirmed directly against the table:MySQL and SQLite coerce silently, which is why this has survived: it is invisible on those backends and fatal on the one CI actually uses.
Second defect, and the reason this took so long to find
RuleApiController::addRule()reports the failure withand passes no logger.
ResponseHelper::error()'s own docblock says callers SHOULD pass theirLoggerInterfaceso the real exception reaches the server log; this caller does not. The result is a generic{"error":"Operation failed"}to the client and nothing at all innextcloud.log— an error path that is undiagnosable from either end. Worth fixing independently of the type bug, and worth sweeping for elsewhere:grep -rn "ResponseHelper::error" lib/shows more call sites without a logger.Candidate fixes (deliberately not chosen here)
ALTER COLUMN … TYPE BOOLEANwith noUSINGclause and PostgreSQL rejects that onsmallint.protected int $isInclude,addType('isInclude','integer')), and cast to bool injsonSerialize()so the JSON contract is unchanged. Smaller migration risk, but it touches the two strict comparisons in the gating path —VisibilityChecker.php:146(=== $isInclude) andConditionalService.php:207(=== true) — plustests/Unit/Db/ConditionalRuleTest.php:44,80,83.Option 2 is the app's own convention elsewhere (
WidgetPlacementserialisesisVisible/isCompulsory/showTitleas1/0), but both options change behaviour in a visibility-gating path, which is why this is filed rather than patched in a coverage sweep.Impact on gate-19
conditional-visibility-editor::include-rules-grouped-under-an-or-headingcannot be covered until this is fixed — its GIVEN requires a stored exclude rule. It is left uncovered and NOT@e2e excluded: a browser observes it fine once the rule can be saved, so an exclusion would record a false statement, and per.github#345the gate counts an exclusion as positive coverage.includeexclude-toggleIS covered, narrowly and on purpose — that requirement is about what the row emits and where it moves to, both observable before the request leaves the browser.tests/e2e/conditional-visibility-editor.spec.tsasserts exactly that and deliberately does not assert persistence.Found while writing gate-19 round-3 coverage.