What the gate found
The full-scope workflow_dispatch run reports:
[gate-57] orphaned-write-capability: FAIL — 1 orphaned write-capability method(s)
lib/Service/DashboardService.php:3189 method=writeDashboardContent rule=orphaned-write-capability class=DashboardService
This is a true positive, and the real scope is much larger than one method.
Measured scope: no producer and no consumer
| symbol |
callers outside its own definition |
DashboardService::writeDashboardContent() |
0 |
DashboardService::readDashboardContent() |
0 |
DashboardService::deleteDashboardContent() |
0 |
DashboardContentStorageFactory::getStorage() |
0 (only the three methods above) |
Dashboard::setContent() |
only DbContentStorage::write() (unreachable, per above) and MigrateStorageToGroupFolder (which sets it to null) |
Consequences that follow from that table:
- Nothing in the application ever populates
oc_launchpad_dashboards.content. Widgets live in WidgetPlacement rows. Dashboard::jsonSerialize() emits 'content' => $this->decodeContent(), which is therefore always null.
launchpad.content_storage is a no-op setting. An admin can switch it to groupfolder via the admin UI, launchpad:storage:toggle-backend, or the setup wizard, and nothing changes — content was never routed through the factory at either end.
launchpad:storage:migrate-to-groupfolder migrates nothing. It reads $dashboard->getContent() (always null), decodes that, and writes the result to the GroupFolder. With --prune-source it then nulls a column that was already null.
Root cause: a phantom-ticked task
openspec/changes/archive/2026-06-14-groupfolder-storage-backend/tasks.md line 9:
- [x] Task 5: Wire `DashboardContentStorageFactory` into `DashboardService` so
`get/create/update/delete` route through the active backend; catch storage
exceptions and rethrow with user-friendly messages
Task 5 is ticked. The three facade methods were written; the wiring named in the task was not. Tasks 1–4 and 6–12 did land, which is why the capability looks complete from every angle except the one that matters.
openspec/specs/groupfolder-storage-backend/spec.md carries status: done and REQ-GFSB-001 states that DashboardService::getDashboard() MUST delegate to getStorage()->read(). There is no getDashboard() method anywhere in lib/ — the spec's read scenario names a method that does not exist.
Why this is not being fixed unilaterally
Completing Task 5 requires answering a genuine architectural question that the spec and the code currently answer differently:
- The spec treats the
content blob as the source of truth for a dashboard's widgets/layout (REQ-GFSB-002: "content MUST be read from and written to the content field").
- The code treats
WidgetPlacement rows as the source of truth, and has done since long before this capability existed.
Wiring the blob in as a second source of truth invites divergence between the two. Wiring it in as a projection (write-through for human-readable admin inspection, reads still from placements) contradicts REQ-GFSB-001's read scenario. Picking between those is a product/architecture decision, not a mechanical gate fix, so it belongs in an ADR rather than in a quality-sweep PR.
Suggested resolution paths
- Complete the capability — decide the source-of-truth question in an ADR, add
content to the create/update API contract, wire get/create/update/delete through the factory, and land the deferred Task 13 Playwright coverage once the dev stack ships groupfolders.
- Retire the capability — remove the storage layer, the factory, the admin setting, the two CLI commands, and the
content column, and archive the spec as withdrawn.
Either way the archived tasks.md Task 5 tick and the spec's status: done should be corrected, because right now the project record asserts a wiring that does not exist.
Until this is decided, gate-57 stays red on development and is the only non-coverage gate finding left in this repo.
What the gate found
The full-scope
workflow_dispatchrun reports:This is a true positive, and the real scope is much larger than one method.
Measured scope: no producer and no consumer
DashboardService::writeDashboardContent()DashboardService::readDashboardContent()DashboardService::deleteDashboardContent()DashboardContentStorageFactory::getStorage()Dashboard::setContent()DbContentStorage::write()(unreachable, per above) andMigrateStorageToGroupFolder(which sets it tonull)Consequences that follow from that table:
oc_launchpad_dashboards.content. Widgets live inWidgetPlacementrows.Dashboard::jsonSerialize()emits'content' => $this->decodeContent(), which is therefore alwaysnull.launchpad.content_storageis a no-op setting. An admin can switch it togroupfoldervia the admin UI,launchpad:storage:toggle-backend, or the setup wizard, and nothing changes — content was never routed through the factory at either end.launchpad:storage:migrate-to-groupfoldermigrates nothing. It reads$dashboard->getContent()(alwaysnull), decodes that, and writes the result to the GroupFolder. With--prune-sourceit then nulls a column that was already null.Root cause: a phantom-ticked task
openspec/changes/archive/2026-06-14-groupfolder-storage-backend/tasks.mdline 9:Task 5 is ticked. The three facade methods were written; the wiring named in the task was not. Tasks 1–4 and 6–12 did land, which is why the capability looks complete from every angle except the one that matters.
openspec/specs/groupfolder-storage-backend/spec.mdcarriesstatus: doneand REQ-GFSB-001 states thatDashboardService::getDashboard()MUST delegate togetStorage()->read(). There is nogetDashboard()method anywhere inlib/— the spec's read scenario names a method that does not exist.Why this is not being fixed unilaterally
Completing Task 5 requires answering a genuine architectural question that the spec and the code currently answer differently:
contentblob as the source of truth for a dashboard's widgets/layout (REQ-GFSB-002: "content MUST be read from and written to thecontentfield").WidgetPlacementrows as the source of truth, and has done since long before this capability existed.Wiring the blob in as a second source of truth invites divergence between the two. Wiring it in as a projection (write-through for human-readable admin inspection, reads still from placements) contradicts REQ-GFSB-001's read scenario. Picking between those is a product/architecture decision, not a mechanical gate fix, so it belongs in an ADR rather than in a quality-sweep PR.
Suggested resolution paths
contentto the create/update API contract, wireget/create/update/deletethrough the factory, and land the deferred Task 13 Playwright coverage once the dev stack shipsgroupfolders.contentcolumn, and archive the spec as withdrawn.Either way the archived
tasks.mdTask 5 tick and the spec'sstatus: doneshould be corrected, because right now the project record asserts a wiring that does not exist.Until this is decided,
gate-57stays red ondevelopmentand is the only non-coverage gate finding left in this repo.